refactor(Web): 统一入口 — 合并旧控制台与 AI 对话页为单一 chat.html
- / 和 /index.html 和 /chat 都服务统一页面(原 / 服务旧 WEB_UI_HTML) - 删除 routes.rs 内嵌的 WEB_UI_HTML 常量(424 行),HTML 拆为 chat.html + chat.js - 整合 8 个 tab:对话 / 角色 / 模型 / 播放控制 / 视频管理 / 文件管理 / 网络设置 / 系统配置 - 保留全部旧功能:WS 实时状态、播放控制、触发器、视频上传/删除、文件管理器、WiFi 扫描/连接/热点、BLE、配置切换/编辑/显示设置、APK 下载弹窗 - 保留全部新功能:文字对话、语音录音(客户端 WAV 转码)、角色切换、模型管理、Live2D 占位
This commit is contained in:
@@ -6,145 +6,42 @@
|
|||||||
<title>Showen 数字生命控制台</title>
|
<title>Showen 数字生命控制台</title>
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px;--pet:#f59e0b;--human:#6366f1;--singer:#ec4899;--live2d:#22c55e}
|
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px;--pet:#f59e0b;--human:#6366f1;--singer:#ec4899;--live2d:#22c55e}
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}::selection{background:var(--accent);color:#fff}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||||
body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}
|
.app{max-width:1200px;margin:0 auto;padding:16px 20px}
|
||||||
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px;flex-wrap:wrap}header h1{font-size:20px;font-weight:600;display:flex;align-items:center;gap:8px;background:linear-gradient(135deg,#6366f1,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}header h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green);-webkit-text-fill-color:initial}.header-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.header-link{display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border-radius:8px;color:#fff;font:inherit;cursor:pointer;text-decoration:none;background:var(--accent)}.header-link.dl{background:linear-gradient(135deg,var(--accent),#a78bfa)}.ws-badge{font-size:11px;padding:4px 10px;border-radius:99px;border:1px solid var(--border);color:var(--muted);background:var(--surface);white-space:nowrap}
|
||||||
.app{max-width:1000px;margin:0 auto;padding:16px 20px}
|
|
||||||
h1{font-size:20px;font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px}
|
|
||||||
h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
|
|
||||||
.subtitle{font-size:12px;color:var(--muted);margin-bottom:16px}
|
.subtitle{font-size:12px;color:var(--muted);margin-bottom:16px}
|
||||||
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
|
.tabs{display:flex;gap:4px;background:var(--surface);border-radius:var(--radius);padding:4px;margin-bottom:16px;overflow-x:auto}.tab{padding:8px 16px;border-radius:8px;border:0;background:0;color:var(--muted);cursor:pointer;font:inherit;white-space:nowrap;transition:all .2s}.tab:hover{color:var(--text);background:var(--surface2)}.tab.active{color:#fff;background:var(--accent);box-shadow:0 2px 8px rgba(99,102,241,.3)}.panel{display:none}.panel.active{display:block}
|
||||||
.tab{padding:8px 16px;cursor:pointer;border-radius:8px 8px 0 0;color:var(--muted);font-size:14px;transition:.2s;border-bottom:2px solid transparent;margin-bottom:-1px}
|
.char-bar{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:12px}.char-bar .avatar{width:40px;height:40px;border-radius:50%;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:20px}.char-bar .info{flex:1}.char-bar .info .name{font-weight:600;font-size:15px}.char-bar .info .type-badge{font-size:11px;padding:2px 8px;border-radius:4px;margin-left:8px;text-transform:uppercase}.type-badge.pet{background:rgba(245,158,11,.2);color:var(--pet)}.type-badge.human{background:rgba(99,102,241,.2);color:var(--human)}.type-badge.singer{background:rgba(236,72,153,.2);color:var(--singer)}.type-badge.live2d{background:rgba(34,197,94,.2);color:var(--live2d)}.char-bar .render-badge{font-size:11px;color:var(--muted);padding:2px 8px;border:1px solid var(--border);border-radius:4px}
|
||||||
.tab:hover{color:var(--text)}
|
.chat-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;overflow-y:auto;padding:16px;margin-bottom:12px}.msg{margin-bottom:12px;max-width:80%}.msg.user{margin-left:auto;text-align:right}.msg .bubble{display:inline-block;padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.5}.msg.user .bubble{background:var(--accent);color:#fff;border-bottom-right-radius:4px}.msg.bot .bubble{background:var(--surface2);border:1px solid var(--border);border-bottom-left-radius:4px}.msg .meta{font-size:11px;color:var(--muted);margin-top:4px}.chat-input{display:flex;gap:8px}.chat-input input{flex:1;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;outline:none}.chat-input input:focus{border-color:var(--accent)}.send-btn{padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;white-space:nowrap}button:disabled{opacity:.5;cursor:not-allowed}.status-bar{font-size:12px;color:var(--muted);margin-top:8px;min-height:18px}.status-bar.error{color:var(--red)}.status-bar.busy{color:var(--amber)}
|
||||||
.tab.active{color:var(--text);border-bottom-color:var(--accent);background:var(--accent-glow)}
|
.live2d-container{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;margin-bottom:12px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}.live2d-placeholder{color:var(--muted);text-align:center}.live2d-placeholder .icon{font-size:48px;margin-bottom:8px}.live2d-talking{position:absolute;top:8px;right:8px;background:var(--green);color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;animation:pulse 1s infinite}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
||||||
.panel{display:none}.panel.active{display:block}
|
.mic-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--surface2);color:var(--text);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:.2s;flex-shrink:0}.mic-btn:hover:not(:disabled){background:var(--accent);color:#fff}.mic-btn.recording{background:var(--red);color:#fff;animation:micPulse 1s infinite}.mic-btn:disabled{opacity:.4;cursor:not-allowed}@keyframes micPulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.5)}50%{box-shadow:0 0 0 8px rgba(239,68,68,0)}}.rec-tip{font-size:11px;color:var(--amber);margin-top:6px;line-height:1.5}.rec-tip code{background:var(--surface2);padding:1px 5px;border-radius:3px;font-size:10px}.audio-inline{margin-top:8px}.audio-inline audio{width:100%;max-width:280px;height:32px}
|
||||||
.char-bar{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:12px}
|
.char-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.char-card{background:var(--surface);border:2px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;transition:.2s}.char-card:hover{border-color:var(--accent)}.char-card.active{border-color:var(--green);background:var(--accent-glow)}.char-card .name{font-size:16px;font-weight:600;margin-bottom:4px}.char-card .strat{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}.char-card .badge{display:inline-block;margin-top:8px;padding:2px 8px;background:var(--green);color:#fff;border-radius:4px;font-size:11px}
|
||||||
.char-bar .avatar{width:40px;height:40px;border-radius:50%;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:20px}
|
.model-bar{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap}.model-stat{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 14px;font-size:12px}.model-stat .label{color:var(--muted);margin-right:4px}.model-stat .val{font-weight:600}.model-list{display:flex;flex-direction:column;gap:8px}.model-item{display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px}.model-item .info{flex:1}.model-item .info .name{font-weight:600;font-size:14px}.model-item .info .desc{font-size:12px;color:var(--muted);margin-top:2px}.model-item .actions{display:flex;gap:6px}.model-item .actions button{padding:6px 12px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px}.model-item .actions button:hover{border-color:var(--accent);color:var(--accent)}.model-item .actions button.danger:hover{border-color:var(--red);color:var(--red)}.model-item .actions button.active{background:var(--green);color:#fff;border-color:var(--green)}.tag{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:6px}.tag.llm{background:rgba(99,102,241,.2);color:#a5b4fc}.tag.asr{background:rgba(34,197,94,.2);color:#86efac}.tag.tts{background:rgba(245,158,11,.2);color:#fcd34d}.tag.downloaded{background:rgba(34,197,94,.2);color:#86efac}.tag.active{background:var(--green);color:#fff}
|
||||||
.char-bar .info{flex:1}
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}.card h2{font-size:15px;font-weight:600;margin-bottom:12px}.stats{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.stat{background:var(--surface2);border-radius:8px;padding:8px 12px}.stat .label{display:block;font-size:11px;color:var(--muted)}.stat .val{font-size:14px;font-weight:600}.stat .val.ok{color:var(--green)}.stat .val.warn{color:var(--amber)}.stat .val.err{color:var(--red)}.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}.btns>*{flex:1;min-width:80px}button{font:inherit;cursor:pointer;border:0;border-radius:8px;padding:8px 16px;transition:all .15s}.btn{background:var(--accent);color:#fff}.btn:hover{filter:brightness(1.1)}.btn-s{background:var(--surface2);color:var(--text);border:1px solid var(--border)}.btn-s:hover{border-color:var(--accent);color:var(--accent)}.btn-g{background:var(--green);color:#fff}.btn-d{background:rgba(239,68,68,.15);color:var(--red);border:1px solid rgba(239,68,68,.3)}.btn-d:hover{background:rgba(239,68,68,.25)}.row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap}.row>div{flex:1;min-width:120px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}input,select,textarea{width:100%;padding:8px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;color:var(--text);font:inherit}input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none}textarea{min-height:200px;font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:12px;resize:vertical}.list{display:flex;flex-direction:column;gap:6px;max-height:260px;overflow-y:auto}.list .item{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--surface2);border-radius:8px}.list .item .name{flex:1;font-size:13px}.list .item .meta{font-size:11px;color:var(--muted);white-space:nowrap}.list .item .dir-icon{font-size:16px}.file-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px}.file-breadcrumb{font-size:12px;color:var(--muted);margin-bottom:8px}.file-breadcrumb span{cursor:pointer}.file-breadcrumb span:hover{text-decoration:underline}.file-breadcrumb .sep{margin:0 4px;cursor:default}.checkbox-label{display:flex;align-items:center;gap:4px;margin:0}
|
||||||
.char-bar .info .name{font-weight:600;font-size:15px}
|
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:10px 20px;border-radius:8px;z-index:999;display:none;box-shadow:0 4px 20px rgba(0,0,0,.4)}.toast.ok{border-color:rgba(34,197,94,.4)}.toast.err{border-color:rgba(239,68,68,.4);color:var(--red)}
|
||||||
.char-bar .info .type-badge{font-size:11px;padding:2px 8px;border-radius:4px;margin-left:8px;text-transform:uppercase}
|
.modal-backdrop{position:fixed;inset:0;background:rgba(10,12,18,.72);backdrop-filter:blur(10px);display:none;align-items:center;justify-content:center;padding:20px;z-index:998}.modal-backdrop.open{display:flex}.download-modal{width:min(100%,420px);background:linear-gradient(180deg,rgba(36,40,54,.98),rgba(26,29,39,.98));border:1px solid rgba(99,102,241,.28);border-radius:20px;padding:22px;box-shadow:0 24px 60px rgba(0,0,0,.4)}.download-modal h2{font-size:22px;margin-bottom:8px}.download-modal p{color:var(--muted);margin-bottom:18px}.download-qr{display:flex;justify-content:center;align-items:center;padding:14px;background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.2);border-radius:16px;margin-bottom:16px}.download-qr img{display:block;width:180px;height:180px;border-radius:12px;background:#fff}.download-actions{display:flex;gap:10px;flex-wrap:wrap}.download-actions>*{flex:1}.download-meta{font-size:13px;margin-bottom:14px;padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid var(--border)}.download-meta.warn{color:var(--amber);border-color:rgba(245,158,11,.3);background:rgba(245,158,11,.08)}.download-note{font-size:12px;color:var(--muted);margin-top:12px}
|
||||||
.type-badge.pet{background:rgba(245,158,11,.2);color:var(--pet)}
|
@media(max-width:600px){.app{padding:12px}.tabs{flex-wrap:wrap}.grid{grid-template-columns:1fr}.header-actions{width:100%}}
|
||||||
.type-badge.human{background:rgba(99,102,241,.2);color:var(--human)}
|
|
||||||
.type-badge.singer{background:rgba(236,72,153,.2);color:var(--singer)}
|
|
||||||
.type-badge.live2d{background:rgba(34,197,94,.2);color:var(--live2d)}
|
|
||||||
.char-bar .render-badge{font-size:11px;color:var(--muted);padding:2px 8px;border:1px solid var(--border);border-radius:4px}
|
|
||||||
.chat-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;overflow-y:auto;padding:16px;margin-bottom:12px}
|
|
||||||
.msg{margin-bottom:12px;max-width:80%}
|
|
||||||
.msg.user{margin-left:auto;text-align:right}
|
|
||||||
.msg .bubble{display:inline-block;padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.5}
|
|
||||||
.msg.user .bubble{background:var(--accent);color:#fff;border-bottom-right-radius:4px}
|
|
||||||
.msg.bot .bubble{background:var(--surface2);border:1px solid var(--border);border-bottom-left-radius:4px}
|
|
||||||
.msg .audio{margin-top:6px;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
|
||||||
.msg .audio audio{height:28px;max-width:240px}
|
|
||||||
.msg .meta{font-size:11px;color:var(--muted);margin-top:4px}
|
|
||||||
.chat-input{display:flex;gap:8px}
|
|
||||||
.chat-input input{flex:1;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;outline:none}
|
|
||||||
.chat-input input:focus{border-color:var(--accent)}
|
|
||||||
.chat-input button{padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;white-space:nowrap}
|
|
||||||
.chat-input button:disabled{opacity:.5;cursor:not-allowed}
|
|
||||||
.status-bar{font-size:12px;color:var(--muted);margin-top:8px;min-height:18px}
|
|
||||||
.status-bar.error{color:var(--red)}
|
|
||||||
.status-bar.busy{color:var(--amber)}
|
|
||||||
.live2d-container{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;margin-bottom:12px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}
|
|
||||||
.live2d-placeholder{color:var(--muted);text-align:center}
|
|
||||||
.live2d-placeholder .icon{font-size:48px;margin-bottom:8px}
|
|
||||||
.live2d-talking{position:absolute;top:8px;right:8px;background:var(--green);color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;animation:pulse 1s infinite}
|
|
||||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
|
||||||
.char-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
|
|
||||||
.char-card{background:var(--surface);border:2px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;transition:.2s}
|
|
||||||
.char-card:hover{border-color:var(--accent)}
|
|
||||||
.char-card.active{border-color:var(--green);background:var(--accent-glow)}
|
|
||||||
.char-card .name{font-size:16px;font-weight:600;margin-bottom:4px}
|
|
||||||
.char-card .strat{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}
|
|
||||||
.char-card .badge{display:inline-block;margin-top:8px;padding:2px 8px;background:var(--green);color:#fff;border-radius:4px;font-size:11px}
|
|
||||||
.model-bar{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap}
|
|
||||||
.model-stat{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 14px;font-size:12px}
|
|
||||||
.model-stat .label{color:var(--muted);margin-right:4px}
|
|
||||||
.model-stat .val{font-weight:600}
|
|
||||||
.model-list{display:flex;flex-direction:column;gap:8px}
|
|
||||||
.model-item{display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px}
|
|
||||||
.model-item .info{flex:1}
|
|
||||||
.model-item .info .name{font-weight:600;font-size:14px}
|
|
||||||
.model-item .info .desc{font-size:12px;color:var(--muted);margin-top:2px}
|
|
||||||
.model-item .actions{display:flex;gap:6px}
|
|
||||||
.model-item .actions button{padding:6px 12px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px}
|
|
||||||
.model-item .actions button:hover{border-color:var(--accent);color:var(--accent)}
|
|
||||||
.model-item .actions button.danger:hover{border-color:var(--red);color:var(--red)}
|
|
||||||
.model-item .actions button.active{background:var(--green);color:#fff;border-color:var(--green)}
|
|
||||||
.tag{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:6px}
|
|
||||||
.tag.llm{background:rgba(99,102,241,.2);color:#a5b4fc}
|
|
||||||
.tag.asr{background:rgba(34,197,94,.2);color:#86efac}
|
|
||||||
.tag.tts{background:rgba(245,158,11,.2);color:#fcd34d}
|
|
||||||
.tag.downloaded{background:rgba(34,197,94,.2);color:#86efac}
|
|
||||||
.tag.active{background:var(--green);color:#fff}
|
|
||||||
.mic-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--surface2);color:var(--text);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:.2s;flex-shrink:0}
|
|
||||||
.mic-btn:hover:not(:disabled){background:var(--accent);color:#fff}
|
|
||||||
.mic-btn.recording{background:var(--red);color:#fff;animation:micPulse 1s infinite}
|
|
||||||
.mic-btn:disabled{opacity:.4;cursor:not-allowed}
|
|
||||||
@keyframes micPulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.5)}50%{box-shadow:0 0 0 8px rgba(239,68,68,0)}}
|
|
||||||
.rec-tip{font-size:11px;color:var(--amber);margin-top:6px;line-height:1.5}
|
|
||||||
.rec-tip code{background:var(--surface2);padding:1px 5px;border-radius:3px;font-size:10px}
|
|
||||||
.audio-inline{margin-top:8px}
|
|
||||||
.audio-inline audio{width:100%;max-width:280px;height:32px}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<h1><span class="dot"></span> Showen 数字生命控制台</h1>
|
<header><h1><span class="dot"></span> Showen 数字生命控制台</h1><div class="header-actions"><span class="ws-badge" id="ws-badge">WS 连接中</span><button class="header-link dl" onclick="openDownloadModal()">下载 App</button></div></header>
|
||||||
<div class="subtitle">统一入口 · 角色对话 · 模型管理</div>
|
<div class="subtitle">统一入口 · 角色对话 · 模型管理 · 设备控制</div>
|
||||||
<div class="char-bar" id="charBar">
|
<div class="char-bar" id="charBar"><div class="avatar" id="charAvatar">🐶</div><div class="info"><span class="name" id="charName">加载中...</span><span class="type-badge pet" id="charType">pet</span></div><div class="render-badge" id="charRender">video</div></div>
|
||||||
<div class="avatar" id="charAvatar">🐶</div>
|
|
||||||
<div class="info"><span class="name" id="charName">加载中...</span><span class="type-badge pet" id="charType">pet</span></div>
|
|
||||||
<div class="render-badge" id="charRender">video</div>
|
|
||||||
</div>
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" data-tab="chat">对话</div>
|
<button class="tab active" data-tab="chat">对话</button><button class="tab" data-tab="character">角色</button><button class="tab" data-tab="models">模型</button><button class="tab" data-tab="control">播放控制</button><button class="tab" data-tab="videos">视频管理</button><button class="tab" data-tab="files">文件管理</button><button class="tab" data-tab="wifi">网络设置</button><button class="tab" data-tab="settings">系统配置</button>
|
||||||
<div class="tab" data-tab="character">角色</div>
|
|
||||||
<div class="tab" data-tab="models">模型</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="panel active" id="panel-chat">
|
<div class="panel active" id="panel-chat"><div id="renderArea"></div><div class="chat-box" id="chatBox" style="display:none"></div><div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button class="send-btn" id="sendBtn" onclick="sendChat()">发送</button><button class="mic-btn" id="micBtn" title="按住说话(松开发送)" disabled>🎙</button></div><div class="status-bar" id="chatStatus"></div><div class="rec-tip" id="recTip"></div></div>
|
||||||
<div id="renderArea"></div>
|
<div class="panel" id="panel-character"><div style="margin-bottom:12px;font-size:13px;color:var(--muted)">不同角色类型采用不同渲染和对话策略:pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)</div><div class="char-grid" id="charGrid"><div style="color:var(--muted)">加载中...</div></div></div>
|
||||||
<div class="chat-box" id="chatBox" style="display:none"></div>
|
<div class="panel" id="panel-models"><div class="model-bar" id="modelBar"></div><div class="model-list" id="modelList"><div style="color:var(--muted)">加载中...</div></div></div>
|
||||||
<div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button id="sendBtn" onclick="sendChat()">发送</button><button class="mic-btn" id="micBtn" title="按住说话(松开发送)" disabled>🎙</button></div>
|
<section class="panel" id="panel-control"><div class="grid"><div class="card"><h2>播放状态</h2><div class="stats"><div class="stat"><span class="label">状态</span><span id="st-state" class="val">--</span></div><div class="stat"><span class="label">当前视频</span><span id="st-video" class="val">--</span></div><div class="stat"><span class="label">索引</span><span id="st-index" class="val">--</span></div><div class="stat"><span class="label">列表长度</span><span id="st-len" class="val">--</span></div></div></div><div class="card"><h2>播放操作</h2><div class="btns"><button class="btn-s" onclick="wsReady?wsCmd({cmd:'prev'}):api('POST','/api/previous')">上一个</button><button class="btn" onclick="wsReady?wsCmd({cmd:'play'}):api('POST','/api/play')">播放</button><button class="btn-s" onclick="wsReady?wsCmd({cmd:'pause'}):api('POST','/api/pause')">暂停</button><button class="btn" onclick="wsReady?wsCmd({cmd:'next'}):api('POST','/api/next')">下一个</button></div><div class="row" style="margin-top:12px"><div><label>跳转到索引</label><input id="goto-idx" type="number" min="0" placeholder="0"></div><div style="flex:0"><label> </label><button class="btn" onclick="gotoVideo()">跳转</button></div></div></div><div class="card" style="grid-column:1/-1"><h2>触发器</h2><div class="btns"><button class="btn-s" onclick="triggerPreset('voice','name')">语音唤醒</button><button class="btn-s" onclick="triggerPreset('button','button1')">按钮 1</button><button class="btn-s" onclick="triggerPreset('button','button2')">按钮 2</button><button class="btn-s" onclick="triggerPreset('sensor','touch')">触摸感应</button></div><div class="row" style="margin-top:10px"><div><label>触发器名称</label><input id="tr-name" type="text" placeholder="voice"></div><div><label>触发器值</label><input id="tr-value" type="text" placeholder="name"></div><div style="flex:0"><label> </label><button class="btn" onclick="triggerCustom()">发送</button></div></div></div></div></section>
|
||||||
<div class="status-bar" id="chatStatus"></div>
|
<section class="panel" id="panel-videos"><div class="grid"><div class="card"><h2>上传视频</h2><input id="upload-file" type="file" accept="video/*" multiple><div class="btns"><button class="btn" onclick="uploadVideos()">上传</button></div></div><div class="card"><h2>设备视频文件</h2><div id="video-list" class="list"><div class="item"><span class="name">加载中...</span></div></div><div class="btns"><button class="btn-s" onclick="loadVideoList()">刷新</button></div></div></div></section>
|
||||||
<div class="rec-tip" id="recTip"></div>
|
<section class="panel" id="panel-files"><div class="card"><h2>文件管理器</h2><div class="file-toolbar"><select id="fm-dir" onchange="fmNavigate('')"><option value="videos">视频目录</option><option value="configs">配置目录</option><option value="plugins">插件目录</option></select><button class="btn-s" onclick="fmRefresh()">刷新</button><button class="btn-s" onclick="fmMkdir()">新建文件夹</button><button class="btn" onclick="document.getElementById('fm-upload-input').click()">上传文件</button><input id="fm-upload-input" type="file" multiple style="display:none" onchange="fmUpload(this)"></div><div id="fm-breadcrumb" class="file-breadcrumb"></div><div id="fm-list" class="list" style="max-height:400px"><div class="item"><span class="name">选择目录后加载...</span></div></div><div class="btns" style="margin-top:8px"><button class="btn-d" id="fm-del-btn" onclick="fmDeleteSelected()" style="display:none">删除选中</button></div></div></section>
|
||||||
|
<section class="panel" id="panel-wifi"><div class="grid"><div class="card"><h2>网络状态</h2><div class="stats"><div class="stat"><span class="label">WiFi</span><span id="wifi-connected" class="val">--</span></div><div class="stat"><span class="label">SSID</span><span id="wifi-ssid" class="val">--</span></div><div class="stat"><span class="label">IP</span><span id="wifi-ip" class="val">--</span></div><div class="stat"><span class="label">BLE</span><span id="ble-status" class="val">--</span></div></div><div class="btns"><button class="btn-s" onclick="loadWifiStatus();loadBleStatus()">刷新状态</button></div></div><div class="card"><h2>WiFi 扫描</h2><div id="wifi-list" class="list"><div class="item"><span class="name">点击扫描搜索附近网络</span></div></div><div class="btns"><button class="btn" onclick="scanWifi()">扫描网络</button></div></div><div class="card"><h2>连接 WiFi</h2><label>SSID</label><input id="wifi-ssid-input" type="text" placeholder="网络名称"><label style="margin-top:8px">密码</label><input id="wifi-pass-input" type="password" placeholder="WiFi 密码"><div class="btns"><button class="btn" onclick="connectWifi()">连接</button></div></div><div class="card"><h2>热点 / BLE</h2><label>热点名称</label><input id="ap-ssid" type="text" value="Showen"><label style="margin-top:8px">热点密码</label><input id="ap-pass" type="text" value="12345678"><div class="btns"><button class="btn-g" onclick="startAP()">开启热点</button><button class="btn-d" onclick="stopAP()">关闭热点</button></div><label style="margin-top:8px">BLE 设备名</label><input id="ble-name" type="text" value="Showen"><div class="btns"><button class="btn-s" onclick="startBLE()">启动 BLE</button><button class="btn-d" onclick="stopBLE()">停止 BLE</button></div></div></div></section>
|
||||||
|
<section class="panel" id="panel-settings"><div class="grid"><div class="card" style="grid-column:1/-1"><h2>配置文件切换</h2><div class="row"><div><label>当前配置</label><select id="cfg-select"><option>加载中...</option></select></div><div style="flex:0"><label> </label><button class="btn" onclick="switchConfig()">切换</button></div></div></div><div class="card"><h2>显示设置</h2><div id="display-form"></div><div class="btns"><button class="btn" onclick="saveDisplay()">保存显示设置</button></div></div><div class="card"><h2>配置编辑器</h2><textarea id="cfg-editor" placeholder="加载中..."></textarea><div class="btns"><button class="btn-s" onclick="loadConfig()">重新加载</button><button class="btn-s" onclick="formatConfig()">格式化</button><button class="btn" onclick="saveConfig()">保存配置</button></div></div></div></section>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" id="panel-character">
|
<div class="toast" id="toast"></div>
|
||||||
<div style="margin-bottom:12px;font-size:13px;color:var(--muted)">不同角色类型采用不同渲染和对话策略:pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)</div>
|
<div class="modal-backdrop" id="download-modal" onclick="closeDownloadModal(event)"><div class="download-modal"><h2>下载手机 App</h2><p>扫描二维码或点击下载,支持蓝牙配网和远程控制</p><div class="download-qr"><img id="download-qr-image" style="display:none"></div><div class="download-meta" id="download-meta">正在获取 App 信息...</div><div class="download-actions"><a class="header-link dl" id="download-apk-link" style="display:none;text-decoration:none" href="#">下载 APK</a><button class="btn-s" onclick="closeDownloadModal()">关闭</button></div><div class="download-note">仅支持 Android 设备</div></div></div>
|
||||||
<div class="char-grid" id="charGrid"><div style="color:var(--muted)">加载中...</div></div>
|
<script src="chat.js"></script>
|
||||||
</div>
|
|
||||||
<div class="panel" id="panel-models">
|
|
||||||
<div class="model-bar" id="modelBar"></div>
|
|
||||||
<div class="model-list" id="modelList"><div style="color:var(--muted)">加载中...</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
const $=s=>document.querySelector(s);const sessionId='web_'+Date.now();let chatBusy=false;let currentChar={name:'未知',character_type:'pet',render_type:'video',talk_state:'talk'};
|
|
||||||
const charStrategies={pet:{icon:'🐶',label:'宠物',render:'video',placeholder:'和宠物说说话...'},human:{icon:'🧑',label:'数字人',render:'video',placeholder:'和数字人对话...'},singer:{icon:'🎤',label:'歌姬',render:'video',placeholder:'和歌姬聊天...'},live2d:{icon:'✨',label:'Live2D数字人',render:'live2d',placeholder:'和Live2D数字人对话...'}};
|
|
||||||
document.querySelectorAll('.tab').forEach(t=>{t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');$('#panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='character')loadCharacters();if(t.dataset.tab==='models')loadModels();};});
|
|
||||||
function applyCharStrategy(char){currentChar=char;const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('#charAvatar').textContent=strat.icon;$('#charName').textContent=char.name||'未知角色';const tb=$('#charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('#charRender').textContent=char.render_type||strat.render;const ra=$('#renderArea');const cb=$('#chatBox');if((char.render_type||strat.render)==='live2d'){cb.style.display='none';ra.innerHTML='<div class="live2d-container" id="live2dContainer"><div class="live2d-placeholder"><div class="icon">✨</div>Live2D 渲染区<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>';}else{ra.innerHTML='';cb.style.display='block';}$('#chatInput').placeholder=strat.placeholder;}
|
|
||||||
async function sendChat(){const input=$('#chatInput');const text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;$('#chatStatus').textContent='思考中...';$('#chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,session_id:sessionId})});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;$('#chatInput').focus();}
|
|
||||||
function audioUrl(path){return '/api/chat/audio/file?path='+encodeURIComponent(path);}
|
|
||||||
function handleChatResponse(data,botMsg){if(data.error){showChatError(botMsg,'错误: '+data.error);return;}botMsg.querySelector('.bubble').textContent=data.reply_text||(data.transcription?'(无回复)':'(空回复)');if(data.transcription&&data.transcription.trim()){const tr=document.createElement('div');tr.className='meta';tr.style.color='var(--muted)';tr.textContent='🎤 你说: '+data.transcription;botMsg.querySelector('.bubble').insertAdjacentElement('beforebegin',tr);}if(data.reply_audio_path){const ad=document.createElement('div');ad.className='audio-inline';ad.innerHTML='<span style="font-size:12px;color:var(--muted)">🔊 回复语音</span><audio controls src="'+audioUrl(data.reply_audio_path)+'"></audio>';botMsg.appendChild(ad);}$('#chatStatus').textContent='';$('#chatStatus').className='status-bar';const rl=(data.reply_text||'').length;const tm=Math.max(2000,rl*200);setTimeout(()=>{triggerTalkState(false);showLive2dTalking(false);},tm);}
|
|
||||||
function showChatError(botMsg,msg){botMsg.querySelector('.bubble').textContent=msg;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent=msg;$('#chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false);}
|
|
||||||
function triggerTalkState(talking){const scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene})}).catch(()=>{});}
|
|
||||||
function showLive2dTalking(talking){const c=$('#live2dContainer');if(!c)return;const ex=c.querySelector('.live2d-talking');if(talking&&!ex){const b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b);}else if(!talking&&ex)ex.remove();}
|
|
||||||
function addMsg(role,text){const box=$('#chatBox');const div=document.createElement('div');div.className='msg '+role;const t=new Date().toLocaleTimeString('zh-CN',{hour12:false});div.innerHTML='<div class="bubble"></div><div class="meta">'+t+'</div>';div.querySelector('.bubble').textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div;}
|
|
||||||
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat();}});
|
|
||||||
async function loadCharacters(){try{const res=await fetch('/api/config/available');const data=await res.json();const grid=$('#charGrid');grid.innerHTML='';const cfgRes=await fetch('/api/config');const cfg=await cfgRes.json();const char=cfg.character||{};const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;(data.configs||[]).forEach(c=>{const isActive=c===(data.active||'');const card=document.createElement('div');card.className='char-card'+(isActive?' active':'');const name=char.name||c.replace(/\.json$/,'').replace(/_/g,' ');card.innerHTML='<div class="name">'+(strat.icon||'📦')+' '+name+'</div><div><span class="type-badge '+(char.character_type||'pet')+'">'+strat.label+'</span> '+(char.render_type||strat.render)+'</div><div class="strat">策略: 拟声/自然/元气 (依据角色)</div>'+(isActive?'<span class="badge">使用中</span>':'');card.onclick=()=>switchCharacter(c);grid.appendChild(card);});}catch(e){$('#charGrid').innerHTML='<div style="color:var(--red)">加载失败</div>';}}
|
|
||||||
async function switchCharacter(filename){if(!confirm('切换到 '+filename+' ?'))return;try{const res=await fetch('/api/config/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename})});const data=await res.json();if(data.status==='ok'||(data.message&&data.message.includes('成功'))){alert('已切换');loadCurrentChar();loadCharacters();}else alert('失败: '+(data.message||'未知'));}catch(e){alert('失败: '+e.message);}}
|
|
||||||
async function loadCurrentChar(){try{const res=await fetch('/api/config');const cfg=await res.json();applyCharStrategy(cfg.character||{name:'未知',character_type:'pet',render_type:'video'});}catch(e){console.error(e);}}
|
|
||||||
async function loadModels(){try{const res=await fetch('/api/models');const data=await res.json();const bar=$('#modelBar');const u=Math.round((data.used_space||0)/1048576);const q=Math.round((data.quota||0)/1048576);bar.innerHTML='<div class="model-stat"><span class="label">配额:</span><span class="val">'+u+'/'+q+' MB</span></div><div class="model-stat"><span class="label">已下载:</span><span class="val">'+(data.models||[]).filter(m=>m.downloaded).length+'/'+(data.models||[]).length+'</span></div>';const list=$('#modelList');list.innerHTML='';const active=data.active||{};(data.models||[]).forEach(m=>{const item=document.createElement('div');item.className='model-item';const ia=active[m.kind]===m.id;const s=Math.round(m.size/1048576);const me=Math.round(m.memory_required/1048576);let a='';if(m.downloaded){if(ia)a='<button class="active" disabled>使用中</button>';else a='<button onclick="switchModel(\''+m.id+'\',\''+m.kind+'\')">切换</button> <button class="danger" onclick="deleteModel(\''+m.id+'\')">删除</button>';}else a='<button onclick="downloadModel(\''+m.id+'\')">下载</button>';item.innerHTML='<div class="info"><div class="name">'+m.name+' <span class="tag '+m.kind+'">'+m.kind.toUpperCase()+'</span>'+(m.downloaded?'<span class="tag downloaded">已下载</span>':'')+(ia?'<span class="tag active">活跃</span>':'')+'</div><div class="desc">'+s+'MB | 需 '+me+'MB | '+m.recommended_tier+'</div></div><div class="actions">'+a+'</div>';list.appendChild(item);});}catch(e){$('#modelList').innerHTML='<div style="color:var(--red)">失败</div>';}}
|
|
||||||
async function downloadModel(id){try{const res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'下载已启动');setTimeout(loadModels,3000);}catch(e){alert('失败');}}
|
|
||||||
async function switchModel(id,kind){try{const res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind})});const data=await res.json();alert(data.message||'已切换');loadModels();}catch(e){alert('失败');}}
|
|
||||||
async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{const res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'已删除');loadModels();}catch(e){alert('失败');}}
|
|
||||||
initMic();
|
|
||||||
async function initMic(){const micBtn=$('#micBtn');const tip=$('#recTip');if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){tip.innerHTML='当前浏览器不支持录音。请使用文字对话。';return;}if(!window.isSecureContext){micBtn.disabled=true;tip.innerHTML='⚠️ 录音需要安全上下文(HTTPS 或 localhost)。当前为 HTTP 访问,录音已禁用。<br>启用方法(桌面 Chrome/Edge):右键浏览器图标 → 属性 → 在目标末尾加 <code>--unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000</code> → 重启浏览器并访问本页。<br>或直接用文字对话,回复语音仍可正常播放。';return;}let mediaRecorder=null;let chunks=[];let recStream=null;let recTimer=null;const startRec=async()=>{if(chatBusy)return;try{recStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:16000,echoCancellation:true,noiseSuppression:true}});chunks=[];mediaRecorder=new MediaRecorder(recStream);mediaRecorder.ondataavailable=e=>{if(e.data.size>0)chunks.push(e.data);};mediaRecorder.onstop=()=>{if(recTimer){clearTimeout(recTimer);recTimer=null;}recStream.getTracks().forEach(t=>t.stop());recStream=null;const blob=new Blob(chunks,{type:mediaRecorder.mimeType||'audio/webm'});if(blob.size<1000){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='录音过短,已取消';$('#chatStatus').className='status-bar error';return;}sendAudioBlob(blob,mediaRecorder.mimeType||'audio/webm');};mediaRecorder.start();micBtn.classList.add('recording');micBtn.textContent='⏹';$('#chatStatus').textContent='录音中...(松开发送)';$('#chatStatus').className='status-bar busy';recTimer=setTimeout(()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){stopRec();$('#chatStatus').textContent='录音达到 30s 上限,自动发送';}},30000);}catch(e){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='麦克风启动失败: '+e.message;$('#chatStatus').className='status-bar error';}};const stopRec=()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙';}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',e=>{e.preventDefault();startRec();});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',e=>{e.preventDefault();stopRec();});}
|
|
||||||
async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;const micBtn=$('#micBtn');micBtn.disabled=true;$('#chatStatus').textContent='转码中...';$('#chatStatus').className='status-bar busy';const userMsg=addMsg('user','🎤 [语音消息]');const playUrl=URL.createObjectURL(blob);const up=document.createElement('div');up.className='audio-inline';up.innerHTML='<span style="font-size:11px;color:var(--muted)">你的录音</span><audio controls src="'+playUrl+'"></audio>';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const wavBlob=await blobToWav(blob);$('#chatStatus').textContent='识别中...';const res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;micBtn.disabled=false;}
|
|
||||||
async function blobToWav(blob){const arrayBuf=await blob.arrayBuffer();const audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});const decoded=await audioCtx.decodeAudioData(arrayBuf);const pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000);}
|
|
||||||
function decodeToMonoPCM16(audioBuffer,targetRate){const numCh=audioBuffer.numberOfChannels;const len=audioBuffer.length;const srcRate=audioBuffer.sampleRate;const outLen=Math.round(len*targetRate/srcRate);const out=new Int16Array(outLen);const tmp=new Float32Array(outLen);for(let ch=0;ch<numCh;ch++){const data=audioBuffer.getChannelData(ch);for(let i=0;i<outLen;i++){const srcIdx=i*srcRate/targetRate;const i0=Math.floor(srcIdx);const i1=Math.min(i0+1,len-1);const frac=srcIdx-i0;tmp[i]+=data[i0]*(1-frac)+data[i1]*frac;}}for(let i=0;i<outLen;i++){let s=tmp[i]/numCh;s=Math.max(-1,Math.min(1,s));out[i]=s<0?s*0x8000:s*0x7FFF;}return out;}
|
|
||||||
function encodeWavBlob(samples,sampleRate){const buf=new ArrayBuffer(44+samples.length*2);const view=new DataView(buf);const writeStr=(off,s)=>{for(let i=0;i<s.length;i++)view.setUint8(off+i,s.charCodeAt(i));};writeStr(0,'RIFF');view.setUint32(4,36+samples.length*2,true);writeStr(8,'WAVE');writeStr(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);view.setUint32(24,sampleRate,true);view.setUint32(28,sampleRate*2,true);view.setUint16(32,2,true);view.setUint16(34,16,true);writeStr(36,'data');view.setUint32(40,samples.length*2,true);let off=44;for(let i=0;i<samples.length;i++){view.setInt16(off,samples[i],true);off+=2;}return new Blob([buf],{type:'audio/wav'});}
|
|
||||||
loadCurrentChar();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
74
src/plugins/http/chat.js
Normal file
74
src/plugins/http/chat.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/* Showen 统一控制台 JS — chat.js */
|
||||||
|
var $=function(id){return typeof id==='string'?document.getElementById(id):id};
|
||||||
|
var ws=null,wsReady=false,cachedConfig=null,fmCurrentDir='videos',fmCurrentPath='',chatBusy=false,currentChar={name:'未知',character_type:'pet',render_type:'video',talk_state:'talk'},sessionId='web_'+Date.now();
|
||||||
|
var charStrategies={pet:{icon:'🐶',label:'宠物',render:'video',placeholder:'和宠物说说话...'},human:{icon:'🧑',label:'数字人',render:'video',placeholder:'和数字人对话...'},singer:{icon:'🎤',label:'歌姬',render:'video',placeholder:'和歌姬聊天...'},live2d:{icon:'✨',label:'Live2D数字人',render:'live2d',placeholder:'和Live2D数字人对话...'}};
|
||||||
|
function toast(m,e){var el=$('toast');el.textContent=m;el.className='toast '+(e?'err':'ok');el.style.display='block';clearTimeout(el._t);el._t=setTimeout(function(){el.style.display='none'},3000)}
|
||||||
|
function esc(v){return String(v).replace(/[&<>"]/g,function(c){return({'&':'&','<':'<','>':'>','"':'"'})[c]})}
|
||||||
|
function ea(v){return esc(v).replace(/'/g,''')}
|
||||||
|
function js(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
|
||||||
|
function formatBytes(s){if(s===undefined||s===null||isNaN(s))return'--';if(s<1024)return s+' B';if(s<1048576)return(s/1024).toFixed(1)+' KB';if(s<1073741824)return(s/1048576).toFixed(1)+' MB';return(s/1073741824).toFixed(1)+' GB'}
|
||||||
|
document.querySelectorAll('.tab').forEach(function(t){t.onclick=function(){document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(e){e.classList.remove('active')});t.classList.add('active');$('panel-'+t.dataset.tab).classList.add('active');var tab=t.dataset.tab;if(tab==='character')loadCharacters();if(tab==='models')loadModels();if(tab==='videos')loadVideoList();if(tab==='files')fmRefresh();if(tab==='wifi'){loadWifiStatus();loadBleStatus()}if(tab==='settings'&&!cachedConfig){loadConfig();loadAvailableConfigs()}}});
|
||||||
|
function connectWS(){var proto=location.protocol==='https:'?'wss:':'ws:';ws=new WebSocket(proto+'//'+location.host+'/ws');ws.onopen=function(){wsReady=true;$('ws-badge').textContent='WS 已连接';$('ws-badge').style.borderColor='rgba(34,197,94,.4)';$('ws-badge').style.color='#22c55e';$('ws-badge').style.background='rgba(34,197,94,.1)'};ws.onclose=function(){wsReady=false;$('ws-badge').textContent='WS 断开';$('ws-badge').style.borderColor='rgba(239,68,68,.3)';$('ws-badge').style.color='#ef4444';$('ws-badge').style.background='rgba(239,68,68,.1)';setTimeout(connectWS,2000)};ws.onerror=function(){wsReady=false};ws.onmessage=function(ev){try{var msg=JSON.parse(ev.data);if(msg.type==='status_update')applyStatus(msg.data);else if(msg.type==='state_update')toast('场景: '+msg.data.old_state+' -> '+msg.data.new_state);else if(msg.type==='wifi_update')applyWifi(msg.data);else if(msg.type==='ble_update')applyBle(msg.data);else if(msg.type==='config_update'){cachedConfig=msg.data;applyCharStrategy(msg.data.character||{})}}catch(e){}}}
|
||||||
|
function wsCmd(obj){if(wsReady)ws.send(JSON.stringify(obj));else toast('WebSocket 未连接',true)}
|
||||||
|
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}
|
||||||
|
function applyStatus(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val warn'}else if(d.paused){el.textContent='已暂停';el.className='val warn'}else{el.textContent='播放中';el.className='val ok'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}
|
||||||
|
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(applyStatus).catch(function(){})}
|
||||||
|
function applyWifi(d){if(d.connected!==undefined){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val ok':'val err';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}}
|
||||||
|
function applyBle(d){if(d.ready!==undefined){var el=$('ble-status');el.textContent=d.ready?'运行中':'未就绪';el.className=d.ready?'val ok':'val warn'}}
|
||||||
|
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}if(wsReady)wsCmd({cmd:'goto',index:parseInt(idx,10)});else api('POST','/api/goto/'+idx)}
|
||||||
|
function triggerPreset(n,v){if(wsReady)wsCmd({cmd:'trigger',name:n,value:v||''});else api('POST','/api/trigger/'+encodeURIComponent(n)+'/'+encodeURIComponent(v||''))}
|
||||||
|
function triggerCustom(){var n=$('tr-name').value,v=$('tr-value').value;if(!n){toast('请输入触发器名',true);return}triggerPreset(n,v)}
|
||||||
|
function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item"><span class="name">目录中没有视频文件</span></div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span class="name">'+esc(f.name)+'</span><span class="meta">'+sz+'</span><button class="btn-d" onclick="deleteVideo(\''+js(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})}
|
||||||
|
function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
|
||||||
|
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}
|
||||||
|
function fmRefresh(){fmNavigate(fmCurrentPath)}
|
||||||
|
function fmNavigate(subpath){fmCurrentDir=$('fm-dir').value;fmCurrentPath=subpath||'';var url='/api/files/'+fmCurrentDir;if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);var bc=$('fm-breadcrumb');var parts=fmCurrentPath?fmCurrentPath.split('/'):[];var html='<span onclick="fmNavigate(\'\')">'+({'videos':'视频目录','configs':'配置目录','plugins':'插件目录'}[fmCurrentDir]||fmCurrentDir)+'</span>';var acc='';for(var i=0;i<parts.length;i++){if(parts[i]){acc+=(acc?'/':'')+parts[i];html+='<span class="sep">/</span><span onclick="fmNavigate(\''+js(acc)+'\')">'+esc(parts[i])+'</span>'}}bc.innerHTML=html;fetch(url).then(function(r){return r.json()}).then(function(entries){var el=$('fm-list');if(entries.status==='error'){el.innerHTML='<div class="item"><span class="name">'+esc(entries.message)+'</span></div>';return}if(!entries.length){el.innerHTML='<div class="item"><span class="name">空目录</span></div>';$('fm-del-btn').style.display='none';return}var html='';for(var i=0;i<entries.length;i++){var e=entries[i];var sz=e.is_dir?'':e.size<1048576?(e.size/1024).toFixed(1)+' KB':(e.size/1048576).toFixed(1)+' MB';var fp=fmCurrentPath?(fmCurrentPath+'/'+e.name):e.name;html+='<div class="item"><label class="checkbox-label"><input type="checkbox" class="fm-check" data-path="'+esc(fp)+'" data-isdir="'+(e.is_dir?'1':'0')+'"></label>';if(e.is_dir){html+='<span class="dir-icon">📁</span><span class="name" style="cursor:pointer;color:var(--amber)" onclick="fmNavigate(\''+js(fp)+'\')">'+esc(e.name)+'</span>'}else{html+='<span class="name">'+esc(e.name)+'</span>'}html+='<span class="meta">'+sz+'</span>';if(!e.is_dir){html+='<button class="btn-s" onclick="fmDownload(\''+js(fp)+'\')">下载</button>'}html+='</div>'}el.innerHTML=html;el.querySelectorAll('.fm-check').forEach(function(cb){cb.onchange=fmUpdateDelBtn});fmUpdateDelBtn()}).catch(function(){toast('加载文件列表失败',true)})}
|
||||||
|
function fmUpdateDelBtn(){var cbs=document.querySelectorAll('.fm-check:checked');$('fm-del-btn').style.display=cbs.length?'':'none';$('fm-del-btn').textContent='删除选中 ('+cbs.length+')'}
|
||||||
|
function fmDeleteSelected(){var cbs=document.querySelectorAll('.fm-check:checked');if(!cbs.length)return;if(!confirm('确定删除 '+cbs.length+' 个项目?'))return;var promises=[];cbs.forEach(function(cb){var p=cb.dataset.path;promises.push(fetch('/api/files/'+fmCurrentDir+'/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})}).then(function(r){return r.json()}))});Promise.all(promises).then(function(results){var ok=0,fail=0;results.forEach(function(r){if(r.status==='ok')ok++;else fail++});toast('删除完成: '+ok+' 成功'+(fail?' / '+fail+' 失败':''),fail>0);fmRefresh()})}
|
||||||
|
function fmDownload(path){window.open('/api/files/'+fmCurrentDir+'/download?path='+encodeURIComponent(path),'_blank')}
|
||||||
|
function fmUpload(input){if(!input.files.length)return;var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);var url='/api/files/'+fmCurrentDir+'/upload';if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);fetch(url,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';fmRefresh()}).catch(function(){toast('上传失败',true)})}
|
||||||
|
function fmMkdir(){var name=prompt('输入新文件夹名称:');if(!name)return;var path=fmCurrentPath?(fmCurrentPath+'/'+name):name;fetch('/api/files/'+fmCurrentDir+'/mkdir',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');fmRefresh()}).catch(function(){toast('创建失败',true)})}
|
||||||
|
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(applyWifi).catch(function(){})}
|
||||||
|
function scanWifi(){$('wifi-list').innerHTML='<div class="item"><span class="name">扫描中...</span></div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item"><span class="name">未发现 WiFi</span></div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span class="name">'+esc(n.ssid||'隐藏网络')+'</span><span class="meta">'+esc(String(n.signal||0))+' / '+esc(n.security||'OPEN')+'</span><button class="btn-s" onclick="selectWifi(\''+js(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})}
|
||||||
|
function selectWifi(ssid){$('wifi-ssid-input').value=ssid}
|
||||||
|
function connectWifi(){var s=$('wifi-ssid-input').value,p=$('wifi-pass-input').value;if(!s){toast('请输入 WiFi 名称',true);return}if(wsReady)wsCmd({cmd:'connect',ssid:s,password:p});else api('POST','/api/wifi/connect',{ssid:s,password:p}).then(function(){setTimeout(loadWifiStatus,1500)})}
|
||||||
|
function startAP(){var s=$('ap-ssid').value||'showen',p=$('ap-pass').value||'12345678';if(p.length<8){toast('热点密码至少 8 位',true);return}if(wsReady)wsCmd({cmd:'ap_start',ssid:s,password:p});else api('POST','/api/wifi/ap/start',{ssid:s,password:p})}
|
||||||
|
function stopAP(){if(wsReady)wsCmd({cmd:'ap_stop'});else api('POST','/api/wifi/ap/stop')}
|
||||||
|
function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val ok'}else{el.textContent='未就绪';el.className='val warn'}}).catch(function(){})}
|
||||||
|
function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)}
|
||||||
|
function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)}
|
||||||
|
function loadAvailableConfigs(){fetch('/api/config/available').then(function(r){return r.json()}).then(function(d){var sel=$('cfg-select');sel.innerHTML='';d.configs.forEach(function(f){var opt=document.createElement('option');opt.value=f;opt.textContent=f;if(f===d.active)opt.selected=true;sel.appendChild(opt)})}).catch(function(){})}
|
||||||
|
function switchConfig(){var f=$('cfg-select').value;if(!f){toast('请选择配置',true);return}if(!confirm('确定切换到 '+f+' ?'))return;api('POST','/api/config/switch',{filename:f}).then(function(){loadConfig();loadAvailableConfigs()})}
|
||||||
|
function loadConfig(){fetch('/api/config').then(function(r){return r.json()}).then(function(cfg){cachedConfig=cfg;$('cfg-editor').value=JSON.stringify(cfg,null,2);renderDisplay(cfg.display);applyCharStrategy(cfg.character||{})}).catch(function(){toast('加载配置失败',true)})}
|
||||||
|
function formatConfig(){try{$('cfg-editor').value=JSON.stringify(JSON.parse($('cfg-editor').value),null,2)}catch(_){toast('JSON 格式错误',true)}}
|
||||||
|
function saveConfig(){var raw=$('cfg-editor').value;try{JSON.parse(raw)}catch(_){toast('JSON 格式错误',true);return}api('POST','/api/config',raw).then(loadConfig)}
|
||||||
|
function renderDisplay(d){if(!d)return;var h='';h+='<label class="checkbox-label"><input id="d-fullscreen" type="checkbox" '+(d.fullscreen?'checked':'')+'> 全屏模式</label>';h+='<label>窗口标题</label><input id="d-title" type="text" value="'+ea(d.window_title||'')+'">';h+='<label>旋转角度</label><input id="d-rotation" type="number" value="'+ea(String(d.rotation||0))+'">';h+='<label>渲染宽度</label><input id="d-render-width" type="number" value="'+ea(String(d.render_width||1024))+'">';h+='<label>渲染高度</label><input id="d-render-height" type="number" value="'+ea(String(d.render_height||1024))+'">';h+='<label>色键下限 (H,S,V)</label><input id="d-ck-min" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_min?d.chroma_key.hsv_min.join(','):'0,0,200'))+'">';h+='<label>色键上限 (H,S,V)</label><input id="d-ck-max" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_max?d.chroma_key.hsv_max.join(','):'180,30,255'))+'">';h+='<label>透视校正点 (JSON)</label><input id="d-points" type="text" value="'+ea(JSON.stringify((d.perspective_correction&&d.perspective_correction.points)||[]))+'">';$('display-form').innerHTML=h}
|
||||||
|
function saveDisplay(){if(!cachedConfig){loadConfig();return}var n=JSON.parse(JSON.stringify(cachedConfig));n.display.fullscreen=$('d-fullscreen').checked;n.display.window_title=$('d-title').value;n.display.rotation=parseInt($('d-rotation').value||'0',10);n.display.render_width=parseInt($('d-render-width').value||'1024',10);n.display.render_height=parseInt($('d-render-height').value||'1024',10);n.display.chroma_key=n.display.chroma_key||{};n.display.chroma_key.hsv_min=$('d-ck-min').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.chroma_key.hsv_max=$('d-ck-max').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.perspective_correction=n.display.perspective_correction||{};try{n.display.perspective_correction.points=JSON.parse($('d-points').value)}catch(_){toast('透视点 JSON 无效',true);return}cachedConfig=n;$('cfg-editor').value=JSON.stringify(n,null,2);saveConfig()}
|
||||||
|
function updateDownloadModal(info){var meta=$('download-meta'),link=$('download-apk-link'),qr=$('download-qr-image');if(!info||!info.apk_available){meta.textContent='APK 尚未发布,请稍后再试';meta.className='download-meta warn';link.style.display='none';qr.style.display='none';qr.removeAttribute('src');return}var url=location.origin+(info.download_url||'/download/showen-app.apk');meta.textContent='版本 '+(info.version||'0.1.0')+' · '+formatBytes(info.apk_size);meta.className='download-meta';link.style.display='inline-flex';link.href=info.download_url||'/download/showen-app.apk';qr.style.display='block';qr.src='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(url)}
|
||||||
|
function openDownloadModal(){var modal=$('download-modal');$('download-meta').textContent='正在获取 App 信息...';$('download-meta').className='download-meta';$('download-apk-link').style.display='none';$('download-qr-image').style.display='none';$('download-qr-image').removeAttribute('src');modal.classList.add('open');fetch('/api/app/info').then(function(r){if(!r.ok)throw new Error('加载 App 信息失败');return r.json()}).then(updateDownloadModal).catch(function(){$('download-meta').textContent='APK 信息加载失败,请稍后再试';$('download-meta').className='download-meta warn';$('download-apk-link').style.display='none';$('download-qr-image').style.display='none'})}
|
||||||
|
function closeDownloadModal(ev){if(ev&&ev.target!==$('download-modal'))return;$('download-modal').classList.remove('open')}
|
||||||
|
document.addEventListener('keydown',function(ev){if(ev.key==='Escape')closeDownloadModal()});
|
||||||
|
function applyCharStrategy(char){currentChar=char;var strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('charAvatar').textContent=strat.icon;$('charName').textContent=char.name||'未知角色';var tb=$('charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('charRender').textContent=char.render_type||strat.render;var ra=$('renderArea');var cb=$('chatBox');if((char.render_type||strat.render)==='live2d'){cb.style.display='none';ra.innerHTML='<div class="live2d-container" id="live2dContainer"><div class="live2d-placeholder"><div class="icon">✨</div>Live2D 渲染区<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>'}else{ra.innerHTML='';cb.style.display='block'}$('chatInput').placeholder=strat.placeholder}
|
||||||
|
async function sendChat(){var input=$('chatInput');var text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('sendBtn').disabled=true;$('chatStatus').textContent='思考中...';$('chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);var botMsg=addMsg('bot','思考中...');try{var res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:text,session_id:sessionId})});var data=await res.json();handleChatResponse(data,botMsg)}catch(e){showChatError(botMsg,'请求失败: '+e.message)}chatBusy=false;$('sendBtn').disabled=false;$('chatInput').focus()}
|
||||||
|
function audioUrl(path){return '/api/chat/audio/file?path='+encodeURIComponent(path)}
|
||||||
|
function handleChatResponse(data,botMsg){if(data.error){showChatError(botMsg,'错误: '+data.error);return}botMsg.querySelector('.bubble').textContent=data.reply_text||(data.transcription?'(无回复)':'(空回复)');if(data.transcription&&data.transcription.trim()){var tr=document.createElement('div');tr.className='meta';tr.style.color='var(--muted)';tr.textContent='🎤 你说: '+data.transcription;botMsg.querySelector('.bubble').insertAdjacentElement('beforebegin',tr)}if(data.reply_audio_path){var ad=document.createElement('div');ad.className='audio-inline';ad.innerHTML='<span style="font-size:12px;color:var(--muted)">🔊 回复语音</span><audio controls src="'+audioUrl(data.reply_audio_path)+'"></audio>';botMsg.appendChild(ad)}$('chatStatus').textContent='';$('chatStatus').className='status-bar';var rl=(data.reply_text||'').length;var tm=Math.max(2000,rl*200);setTimeout(function(){triggerTalkState(false);showLive2dTalking(false)},tm)}
|
||||||
|
function showChatError(botMsg,msg){botMsg.querySelector('.bubble').textContent=msg;botMsg.querySelector('.bubble').style.color='var(--red)';$('chatStatus').textContent=msg;$('chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false)}
|
||||||
|
function triggerTalkState(talking){var scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene:scene})}).catch(function(){})}
|
||||||
|
function showLive2dTalking(talking){var c=$('live2dContainer');if(!c)return;var ex=c.querySelector('.live2d-talking');if(talking&&!ex){var b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b)}else if(!talking&&ex)ex.remove()}
|
||||||
|
function addMsg(role,text){var box=$('chatBox');var div=document.createElement('div');div.className='msg '+role;var t=new Date().toLocaleTimeString('zh-CN',{hour12:false});div.innerHTML='<div class="bubble"></div><div class="meta">'+t+'</div>';div.querySelector('.bubble').textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div}
|
||||||
|
$('chatInput').addEventListener('keydown',function(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat()}});
|
||||||
|
async function loadCharacters(){try{var res=await fetch('/api/config/available');var data=await res.json();var grid=$('charGrid');grid.innerHTML='';var cfgRes=await fetch('/api/config');var cfg=await cfgRes.json();var char=cfg.character||{};var strat=charStrategies[char.character_type||'pet']||charStrategies.pet;(data.configs||[]).forEach(function(c){var isActive=c===(data.active||'');var card=document.createElement('div');card.className='char-card'+(isActive?' active':'');var name=char.name||c.replace(/\.json$/,'').replace(/_/g,' ');card.innerHTML='<div class="name">'+(strat.icon||'📦')+' '+name+'</div><div><span class="type-badge '+(char.character_type||'pet')+'">'+strat.label+'</span> '+(char.render_type||strat.render)+'</div><div class="strat">策略: 拟声/自然/元气 (依据角色)</div>'+(isActive?'<span class="badge">使用中</span>':'');card.onclick=function(){switchCharacter(c)};grid.appendChild(card)})}catch(e){$('charGrid').innerHTML='<div style="color:var(--red)">加载失败</div>'}}
|
||||||
|
async function switchCharacter(filename){if(!confirm('切换到 '+filename+' ?'))return;try{var res=await fetch('/api/config/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename:filename})});var data=await res.json();if(data.status==='ok'||(data.message&&data.message.includes('成功'))){toast('已切换');loadConfig();loadCharacters()}else toast('失败: '+(data.message||'未知'),true)}catch(e){toast('失败: '+e.message,true)}}
|
||||||
|
async function loadModels(){try{var res=await fetch('/api/models');var data=await res.json();var bar=$('modelBar');var u=Math.round((data.used_space||0)/1048576);var q=Math.round((data.quota||0)/1048576);bar.innerHTML='<div class="model-stat"><span class="label">配额:</span><span class="val">'+u+'/'+q+' MB</span></div><div class="model-stat"><span class="label">已下载:</span><span class="val">'+(data.models||[]).filter(function(m){return m.downloaded}).length+'/'+(data.models||[]).length+'</span></div>';var list=$('modelList');list.innerHTML='';var active=data.active||{};(data.models||[]).forEach(function(m){var item=document.createElement('div');item.className='model-item';var ia=active[m.kind]===m.id;var s=Math.round(m.size/1048576);var me=Math.round(m.memory_required/1048576);var a='';if(m.downloaded){if(ia)a='<button class="active" disabled>使用中</button>';else a='<button onclick="switchModel(\''+m.id+'\',\''+m.kind+'\')">切换</button> <button class="danger" onclick="deleteModel(\''+m.id+'\')">删除</button>'}else a='<button onclick="downloadModel(\''+m.id+'\')">下载</button>';item.innerHTML='<div class="info"><div class="name">'+m.name+' <span class="tag '+m.kind+'">'+m.kind.toUpperCase()+'</span>'+(m.downloaded?'<span class="tag downloaded">已下载</span>':'')+(ia?'<span class="tag active">活跃</span>':'')+'</div><div class="desc">'+s+'MB | 需 '+me+'MB | '+m.recommended_tier+'</div></div><div class="actions">'+a+'</div>';list.appendChild(item)})}catch(e){$('modelList').innerHTML='<div style="color:var(--red)">失败</div>'}}
|
||||||
|
async function downloadModel(id){try{var res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});var data=await res.json();toast(data.message||'下载已启动');setTimeout(loadModels,3000)}catch(e){toast('失败',true)}}
|
||||||
|
async function switchModel(id,kind){try{var res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind:kind})});var data=await res.json();toast(data.message||'已切换');loadModels()}catch(e){toast('失败',true)}}
|
||||||
|
async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{var res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});var data=await res.json();toast(data.message||'已删除');loadModels()}catch(e){toast('失败',true)}}
|
||||||
|
initMic();
|
||||||
|
async function initMic(){var micBtn=$('micBtn');var tip=$('recTip');if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){tip.innerHTML='当前浏览器不支持录音。请使用文字对话。';return}if(!window.isSecureContext){micBtn.disabled=true;tip.innerHTML='⚠️ 录音需要安全上下文(HTTPS 或 localhost)。当前为 HTTP 访问,录音已禁用。<br>启用方法(桌面 Chrome/Edge):右键浏览器图标 → 属性 → 在目标末尾加 <code>--unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000</code> → 重启浏览器并访问本页。<br>或直接用文字对话,回复语音仍可正常播放。';return}var mediaRecorder=null,chunks=[],recStream=null,recTimer=null;var startRec=async function(){if(chatBusy)return;try{recStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:16000,echoCancellation:true,noiseSuppression:true}});chunks=[];mediaRecorder=new MediaRecorder(recStream);mediaRecorder.ondataavailable=function(e){if(e.data.size>0)chunks.push(e.data)};mediaRecorder.onstop=function(){if(recTimer){clearTimeout(recTimer);recTimer=null}recStream.getTracks().forEach(function(t){t.stop()});recStream=null;var blob=new Blob(chunks,{type:mediaRecorder.mimeType||'audio/webm'});if(blob.size<1000){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('chatStatus').textContent='录音过短,已取消';$('chatStatus').className='status-bar error';return}sendAudioBlob(blob,mediaRecorder.mimeType||'audio/webm')};mediaRecorder.start();micBtn.classList.add('recording');micBtn.textContent='⏹';$('chatStatus').textContent='录音中...(松开发送)';$('chatStatus').className='status-bar busy';recTimer=setTimeout(function(){if(mediaRecorder&&mediaRecorder.state==='recording'){stopRec();$('chatStatus').textContent='录音达到 30s 上限,自动发送'}},30000)}catch(e){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('chatStatus').textContent='麦克风启动失败: '+e.message;$('chatStatus').className='status-bar error'}};var stopRec=function(){if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙'}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',function(e){e.preventDefault();startRec()});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',function(e){e.preventDefault();stopRec()})}
|
||||||
|
async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('sendBtn').disabled=true;var micBtn=$('micBtn');micBtn.disabled=true;$('chatStatus').textContent='转码中...';$('chatStatus').className='status-bar busy';var userMsg=addMsg('user','🎤 [语音消息]');var playUrl=URL.createObjectURL(blob);var up=document.createElement('div');up.className='audio-inline';up.innerHTML='<span style="font-size:11px;color:var(--muted)">你的录音</span><audio controls src="'+playUrl+'"></audio>';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);var botMsg=addMsg('bot','思考中...');try{var wavBlob=await blobToWav(blob);$('chatStatus').textContent='识别中...';var res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});var data=await res.json();handleChatResponse(data,botMsg)}catch(e){showChatError(botMsg,'请求失败: '+e.message)}chatBusy=false;$('sendBtn').disabled=false;micBtn.disabled=false}
|
||||||
|
/* WAV 转码:浏览器录音(webm/opus) → 16k mono PCM16 wav,whisper-cli 直接可处理 */
|
||||||
|
async function blobToWav(blob){var arrayBuf=await blob.arrayBuffer();var audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});var decoded=await audioCtx.decodeAudioData(arrayBuf);var pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000)}
|
||||||
|
function decodeToMonoPCM16(audioBuffer,targetRate){var numCh=audioBuffer.numberOfChannels;var len=audioBuffer.length;var srcRate=audioBuffer.sampleRate;var outLen=Math.round(len*targetRate/srcRate);var out=new Int16Array(outLen);var tmp=new Float32Array(outLen);for(var ch=0;ch<numCh;ch++){var data=audioBuffer.getChannelData(ch);for(var i=0;i<outLen;i++){var srcIdx=i*srcRate/targetRate;var i0=Math.floor(srcIdx);var i1=Math.min(i0+1,len-1);var frac=srcIdx-i0;tmp[i]+=data[i0]*(1-frac)+data[i1]*frac}}for(var i=0;i<outLen;i++){var s=tmp[i]/numCh;s=Math.max(-1,Math.min(1,s));out[i]=s<0?s*0x8000:s*0x7FFF}return out}
|
||||||
|
function encodeWavBlob(samples,sampleRate){var buf=new ArrayBuffer(44+samples.length*2);var view=new DataView(buf);var writeStr=function(off,s){for(var i=0;i<s.length;i++)view.setUint8(off+i,s.charCodeAt(i))};writeStr(0,'RIFF');view.setUint32(4,36+samples.length*2,true);writeStr(8,'WAVE');writeStr(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);view.setUint32(24,sampleRate,true);view.setUint32(28,sampleRate*2,true);view.setUint16(32,2,true);view.setUint16(34,16,true);writeStr(36,'data');view.setUint32(40,samples.length*2,true);var off=44;for(var i=0;i<samples.length;i++){view.setInt16(off,samples[i],true);off+=2}return new Blob([buf],{type:'audio/wav'})}
|
||||||
|
/* 启动 */
|
||||||
|
connectWS();refreshStatus();loadConfig();
|
||||||
@@ -158,7 +158,6 @@ pub(crate) fn build_routes(
|
|||||||
let api = core_api.or(media_api).or(plugin_api).or(file_api).or(ai_api);
|
let api = core_api.or(media_api).or(plugin_api).or(file_api).or(ai_api);
|
||||||
|
|
||||||
root_route()
|
root_route()
|
||||||
.or(chat_page_route())
|
|
||||||
.or(download_route(Arc::clone(&state)))
|
.or(download_route(Arc::clone(&state)))
|
||||||
.or(ws_route(tx.clone(), Arc::clone(&state)))
|
.or(ws_route(tx.clone(), Arc::clone(&state)))
|
||||||
.or(api)
|
.or(api)
|
||||||
@@ -170,24 +169,39 @@ pub(crate) fn build_routes(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET / 和 /index.html 和 /chat — 统一入口(chat.html)
|
||||||
fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
warp::path::end()
|
let index = warp::path::end()
|
||||||
.and(warp::get())
|
.and(warp::get())
|
||||||
.map(|| warp::reply::html(WEB_UI_HTML))
|
.map(|| warp::reply::html(include_str!("chat.html")));
|
||||||
.or(
|
let index_html = warp::path("index.html")
|
||||||
warp::path("index.html")
|
|
||||||
.and(warp::path::end())
|
|
||||||
.and(warp::get())
|
|
||||||
.map(|| warp::reply::html(WEB_UI_HTML)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /chat — AI 对话控制端页面
|
|
||||||
fn chat_page_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
|
||||||
warp::path("chat")
|
|
||||||
.and(warp::path::end())
|
.and(warp::path::end())
|
||||||
.and(warp::get())
|
.and(warp::get())
|
||||||
.map(|| warp::reply::html(include_str!("chat.html")))
|
.map(|| warp::reply::html(include_str!("chat.html")));
|
||||||
|
let chat = warp::path("chat")
|
||||||
|
.and(warp::path::end())
|
||||||
|
.and(warp::get())
|
||||||
|
.map(|| warp::reply::html(include_str!("chat.html")));
|
||||||
|
let chat_js = warp::path("chat.js")
|
||||||
|
.and(warp::path::end())
|
||||||
|
.and(warp::get())
|
||||||
|
.map(|| {
|
||||||
|
let body = include_str!("chat.js");
|
||||||
|
match warp::http::Response::builder()
|
||||||
|
.header("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.header("Content-Length", body.len().to_string())
|
||||||
|
.body(body.to_string())
|
||||||
|
{
|
||||||
|
Ok(resp) => resp.into_response(),
|
||||||
|
Err(_) => warp::reply::with_status(
|
||||||
|
warp::reply::html("JS 加载失败".to_string()),
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
index.or(index_html).or(chat).or(chat_js)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ws_route(
|
fn ws_route(
|
||||||
@@ -2345,427 +2359,3 @@ fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::
|
|||||||
warp::reply::with_status(warp::reply::json(payload), status).into_response()
|
warp::reply::with_status(warp::reply::json(payload), status).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEB_UI_HTML: &str = r##"<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Showen 控制台</title>
|
|
||||||
<style>
|
|
||||||
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px}
|
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}
|
|
||||||
::selection{background:var(--accent);color:#fff}
|
|
||||||
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
||||||
|
|
||||||
.app{max-width:1200px;margin:0 auto;padding:16px 20px}
|
|
||||||
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px;flex-wrap:wrap}
|
|
||||||
header h1{font-size:20px;font-weight:600;background:linear-gradient(135deg,#6366f1,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
||||||
header .badge{font-size:11px;padding:3px 10px;border-radius:99px;background:var(--accent-glow);color:var(--accent);border:1px solid rgba(99,102,241,.3)}
|
|
||||||
.header-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
||||||
.header-link{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:8px 16px;border-radius:8px;color:#fff;font:inherit;cursor:pointer;transition:all .15s;text-decoration:none}
|
|
||||||
|
|
||||||
.modal-backdrop{position:fixed;inset:0;background:rgba(10,12,18,.72);backdrop-filter:blur(10px);display:none;align-items:center;justify-content:center;padding:20px;z-index:998}
|
|
||||||
.modal-backdrop.open{display:flex}
|
|
||||||
.download-modal{width:min(100%,420px);background:linear-gradient(180deg,rgba(36,40,54,.98),rgba(26,29,39,.98));border:1px solid rgba(99,102,241,.28);border-radius:20px;padding:22px;box-shadow:0 24px 60px rgba(0,0,0,.4)}
|
|
||||||
.download-modal h2{font-size:22px;margin-bottom:8px}
|
|
||||||
.download-modal p{color:var(--muted);margin-bottom:18px}
|
|
||||||
.download-qr{display:flex;justify-content:center;align-items:center;padding:14px;background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.2);border-radius:16px;margin-bottom:16px}
|
|
||||||
.download-qr img{display:block;width:180px;height:180px;border-radius:12px;background:#fff}
|
|
||||||
.download-actions{display:flex;gap:10px;flex-wrap:wrap}
|
|
||||||
.download-actions>*{flex:1}
|
|
||||||
.download-meta{font-size:13px;color:var(--text);margin-bottom:14px;padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid var(--border)}
|
|
||||||
.download-meta.warn{color:var(--amber);border-color:rgba(245,158,11,.3);background:rgba(245,158,11,.08)}
|
|
||||||
.download-note{font-size:12px;color:var(--muted);margin-top:12px}
|
|
||||||
|
|
||||||
.tabs{display:flex;gap:4px;background:var(--surface);border-radius:var(--radius);padding:4px;margin-bottom:16px;overflow-x:auto}
|
|
||||||
.tab{padding:8px 16px;border-radius:8px;border:0;background:0;color:var(--muted);cursor:pointer;font:inherit;white-space:nowrap;transition:all .2s}
|
|
||||||
.tab:hover{color:var(--text);background:var(--surface2)}
|
|
||||||
.tab.active{color:#fff;background:var(--accent);box-shadow:0 2px 8px rgba(99,102,241,.3)}
|
|
||||||
.panel{display:none}.panel.active{display:block}
|
|
||||||
|
|
||||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px}
|
|
||||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}
|
|
||||||
.card h2{font-size:14px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px}
|
|
||||||
.stats{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
||||||
.stat{background:var(--surface2);border-radius:8px;padding:10px 12px}
|
|
||||||
.stat .label{font-size:11px;color:var(--muted);display:block}.stat .val{font-size:16px;font-weight:600;color:var(--accent)}
|
|
||||||
.stat .val.ok{color:var(--green)}.stat .val.warn{color:var(--amber)}.stat .val.err{color:var(--red)}
|
|
||||||
|
|
||||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
|
||||||
button{border:0;border-radius:8px;padding:8px 16px;font:inherit;cursor:pointer;transition:all .15s}
|
|
||||||
.btn{background:var(--accent);color:#fff}.btn:hover{opacity:.85;transform:translateY(-1px)}
|
|
||||||
.btn-s{background:var(--surface2);color:var(--text);border:1px solid var(--border)}.btn-s:hover{border-color:var(--accent);color:var(--accent)}
|
|
||||||
.btn-d{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2)}.btn-d:hover{background:rgba(239,68,68,.2)}
|
|
||||||
.btn-g{background:rgba(34,197,94,.1);color:var(--green);border:1px solid rgba(34,197,94,.2)}.btn-g:hover{background:rgba(34,197,94,.2)}
|
|
||||||
|
|
||||||
input,select,textarea{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface2);color:var(--text);font:inherit;outline:0;transition:border-color .2s}
|
|
||||||
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
|
|
||||||
textarea{min-height:200px;font-family:"Fira Code",monospace;font-size:13px;resize:vertical}
|
|
||||||
label{display:block;font-size:12px;color:var(--muted);margin:8px 0 4px}
|
|
||||||
.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}
|
|
||||||
|
|
||||||
.list{max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface2)}
|
|
||||||
.item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border);gap:8px;font-size:13px}
|
|
||||||
.item:last-child{border-bottom:0}
|
|
||||||
.item .name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
||||||
.item .meta{color:var(--muted);font-size:12px;white-space:nowrap}
|
|
||||||
.item .dir-icon{color:var(--amber);margin-right:4px}
|
|
||||||
|
|
||||||
.toast{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;color:#fff;font-size:13px;display:none;z-index:999;backdrop-filter:blur(8px);max-width:360px}
|
|
||||||
.toast.ok{background:rgba(34,197,94,.9)}.toast.err{background:rgba(239,68,68,.9)}
|
|
||||||
|
|
||||||
.file-breadcrumb{display:flex;gap:4px;align-items:center;margin-bottom:10px;font-size:13px;flex-wrap:wrap}
|
|
||||||
.file-breadcrumb span{color:var(--muted);cursor:pointer;padding:2px 6px;border-radius:4px}.file-breadcrumb span:hover{background:var(--surface2);color:var(--text)}
|
|
||||||
.file-breadcrumb .sep{color:var(--border);cursor:default;padding:0}.file-breadcrumb .sep:hover{background:none;color:var(--border)}
|
|
||||||
.file-toolbar{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;align-items:center}
|
|
||||||
.file-toolbar select{width:auto;min-width:120px}
|
|
||||||
.checkbox-label{display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer}
|
|
||||||
.checkbox-label input[type=checkbox]{width:auto;accent-color:var(--accent)}
|
|
||||||
|
|
||||||
@media (max-width:720px){.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr}header h1{font-size:16px}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="toast" class="toast"></div>
|
|
||||||
<div id="download-modal" class="modal-backdrop" onclick="closeDownloadModal(event)">
|
|
||||||
<div class="download-modal">
|
|
||||||
<h2>下载 Showen App</h2>
|
|
||||||
<p id="download-modal-desc">扫描二维码或点击下载,支持蓝牙配网和远程控制</p>
|
|
||||||
<div id="download-meta" class="download-meta">正在获取 App 信息...</div>
|
|
||||||
<div class="download-qr"><img id="download-qr-image" alt="Showen App 下载二维码"></div>
|
|
||||||
<div class="download-actions">
|
|
||||||
<a id="download-apk-link" class="btn header-link" href="/download/showen-app.apk" download>下载 APK</a>
|
|
||||||
<button class="btn-s" type="button" onclick="closeDownloadModal()">关闭</button>
|
|
||||||
</div>
|
|
||||||
<div class="download-note">若二维码加载失败,可直接点击下载按钮获取 APK。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app">
|
|
||||||
<header>
|
|
||||||
<h1>Showen 控制台</h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="btn" type="button" onclick="openDownloadModal()">下载 App</button>
|
|
||||||
<span class="badge" id="ws-badge">WS 连接中...</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab active" data-tab="control">播放控制</button>
|
|
||||||
<button class="tab" data-tab="videos">视频管理</button>
|
|
||||||
<button class="tab" data-tab="files">文件管理</button>
|
|
||||||
<button class="tab" data-tab="wifi">网络设置</button>
|
|
||||||
<button class="tab" data-tab="settings">系统配置</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 播放控制 -->
|
|
||||||
<section class="panel active" id="panel-control">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<h2>播放状态</h2>
|
|
||||||
<div class="stats">
|
|
||||||
<div class="stat"><span class="label">状态</span><span id="st-state" class="val">--</span></div>
|
|
||||||
<div class="stat"><span class="label">当前视频</span><span id="st-video" class="val">--</span></div>
|
|
||||||
<div class="stat"><span class="label">索引</span><span id="st-index" class="val">--</span></div>
|
|
||||||
<div class="stat"><span class="label">列表长度</span><span id="st-len" class="val">--</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>播放操作</h2>
|
|
||||||
<div class="btns">
|
|
||||||
<button class="btn-s" onclick="wsReady?wsCmd({cmd:'prev'}):api('POST','/api/previous')">上一个</button>
|
|
||||||
<button class="btn" onclick="wsReady?wsCmd({cmd:'play'}):api('POST','/api/play')">播放</button>
|
|
||||||
<button class="btn-s" onclick="wsReady?wsCmd({cmd:'pause'}):api('POST','/api/pause')">暂停</button>
|
|
||||||
<button class="btn" onclick="wsReady?wsCmd({cmd:'next'}):api('POST','/api/next')">下一个</button>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="margin-top:12px">
|
|
||||||
<div><label>跳转到索引</label><input id="goto-idx" type="number" min="0" placeholder="0"></div>
|
|
||||||
<div style="flex:0"><label> </label><button class="btn" onclick="gotoVideo()">跳转</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="grid-column:1/-1">
|
|
||||||
<h2>触发器</h2>
|
|
||||||
<div class="btns">
|
|
||||||
<button class="btn-s" onclick="triggerPreset('voice','name')">语音唤醒</button>
|
|
||||||
<button class="btn-s" onclick="triggerPreset('button','button1')">按钮 1</button>
|
|
||||||
<button class="btn-s" onclick="triggerPreset('button','button2')">按钮 2</button>
|
|
||||||
<button class="btn-s" onclick="triggerPreset('sensor','touch')">触摸感应</button>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="margin-top:10px">
|
|
||||||
<div><label>触发器名称</label><input id="tr-name" type="text" placeholder="voice"></div>
|
|
||||||
<div><label>触发器值</label><input id="tr-value" type="text" placeholder="name"></div>
|
|
||||||
<div style="flex:0"><label> </label><button class="btn" onclick="triggerCustom()">发送</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 视频管理 -->
|
|
||||||
<section class="panel" id="panel-videos">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<h2>上传视频</h2>
|
|
||||||
<input id="upload-file" type="file" accept="video/*" multiple>
|
|
||||||
<div class="btns"><button class="btn" onclick="uploadVideos()">上传</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>设备视频文件</h2>
|
|
||||||
<div id="video-list" class="list"><div class="item"><span class="name">加载中...</span></div></div>
|
|
||||||
<div class="btns"><button class="btn-s" onclick="loadVideoList()">刷新</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 文件管理 -->
|
|
||||||
<section class="panel" id="panel-files">
|
|
||||||
<div class="card">
|
|
||||||
<h2>文件管理器</h2>
|
|
||||||
<div class="file-toolbar">
|
|
||||||
<select id="fm-dir" onchange="fmNavigate('')">
|
|
||||||
<option value="videos">视频目录</option>
|
|
||||||
<option value="configs">配置目录</option>
|
|
||||||
<option value="plugins">插件目录</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn-s" onclick="fmRefresh()">刷新</button>
|
|
||||||
<button class="btn-s" onclick="fmMkdir()">新建文件夹</button>
|
|
||||||
<button class="btn" onclick="document.getElementById('fm-upload-input').click()">上传文件</button>
|
|
||||||
<input id="fm-upload-input" type="file" multiple style="display:none" onchange="fmUpload(this)">
|
|
||||||
</div>
|
|
||||||
<div id="fm-breadcrumb" class="file-breadcrumb"></div>
|
|
||||||
<div id="fm-list" class="list" style="max-height:400px"><div class="item"><span class="name">选择目录后加载...</span></div></div>
|
|
||||||
<div class="btns" style="margin-top:8px">
|
|
||||||
<button class="btn-d" id="fm-del-btn" onclick="fmDeleteSelected()" style="display:none">删除选中</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 网络设置 -->
|
|
||||||
<section class="panel" id="panel-wifi">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<h2>网络状态</h2>
|
|
||||||
<div class="stats">
|
|
||||||
<div class="stat"><span class="label">WiFi</span><span id="wifi-connected" class="val">--</span></div>
|
|
||||||
<div class="stat"><span class="label">SSID</span><span id="wifi-ssid" class="val">--</span></div>
|
|
||||||
<div class="stat"><span class="label">IP</span><span id="wifi-ip" class="val">--</span></div>
|
|
||||||
<div class="stat"><span class="label">BLE</span><span id="ble-status" class="val">--</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="btns"><button class="btn-s" onclick="loadWifiStatus();loadBleStatus()">刷新状态</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>WiFi 扫描</h2>
|
|
||||||
<div id="wifi-list" class="list"><div class="item"><span class="name">点击扫描搜索附近网络</span></div></div>
|
|
||||||
<div class="btns"><button class="btn" onclick="scanWifi()">扫描网络</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>连接 WiFi</h2>
|
|
||||||
<label>SSID</label><input id="wifi-ssid-input" type="text" placeholder="网络名称">
|
|
||||||
<label>密码</label><input id="wifi-pass-input" type="password" placeholder="WiFi 密码">
|
|
||||||
<div class="btns"><button class="btn" onclick="connectWifi()">连接</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>热点 / BLE</h2>
|
|
||||||
<label>热点名称</label><input id="ap-ssid" type="text" value="Showen">
|
|
||||||
<label>热点密码</label><input id="ap-pass" type="text" value="12345678">
|
|
||||||
<div class="btns">
|
|
||||||
<button class="btn-g" onclick="startAP()">开启热点</button>
|
|
||||||
<button class="btn-d" onclick="stopAP()">关闭热点</button>
|
|
||||||
</div>
|
|
||||||
<label>BLE 设备名</label><input id="ble-name" type="text" value="Showen">
|
|
||||||
<div class="btns">
|
|
||||||
<button class="btn-s" onclick="startBLE()">启动 BLE</button>
|
|
||||||
<button class="btn-d" onclick="stopBLE()">停止 BLE</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 系统配置 -->
|
|
||||||
<section class="panel" id="panel-settings">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card" style="grid-column:1/-1">
|
|
||||||
<h2>配置文件切换</h2>
|
|
||||||
<div class="row">
|
|
||||||
<div><label>当前配置</label><select id="cfg-select"><option>加载中...</option></select></div>
|
|
||||||
<div style="flex:0"><label> </label><button class="btn" onclick="switchConfig()">切换</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>显示设置</h2>
|
|
||||||
<div id="display-form"></div>
|
|
||||||
<div class="btns"><button class="btn" onclick="saveDisplay()">保存显示设置</button></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>配置编辑器</h2>
|
|
||||||
<textarea id="cfg-editor" placeholder="加载中..."></textarea>
|
|
||||||
<div class="btns">
|
|
||||||
<button class="btn-s" onclick="loadConfig()">重新加载</button>
|
|
||||||
<button class="btn-s" onclick="formatConfig()">格式化</button>
|
|
||||||
<button class="btn" onclick="saveConfig()">保存配置</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var cachedConfig=null,ws=null,wsReady=false;
|
|
||||||
var fmCurrentDir='videos',fmCurrentPath='';
|
|
||||||
function $(id){return document.getElementById(id)}
|
|
||||||
function toast(msg,err){var el=$('toast');el.textContent=msg;el.className='toast '+(err?'err':'ok');el.style.display='block';clearTimeout(el._t);el._t=setTimeout(function(){el.style.display='none'},3000)}
|
|
||||||
function formatBytes(size){if(size===undefined||size===null||isNaN(size))return '--';if(size<1024)return size+' B';if(size<1048576)return(size/1024).toFixed(1)+' KB';if(size<1073741824)return(size/1048576).toFixed(1)+' MB';return(size/1073741824).toFixed(1)+' GB'}
|
|
||||||
function updateDownloadModal(info){
|
|
||||||
var meta=$('download-meta'),link=$('download-apk-link'),qr=$('download-qr-image'),desc=$('download-modal-desc');
|
|
||||||
if(!info||!info.apk_available){
|
|
||||||
meta.textContent='APK 尚未发布,请稍后再试';
|
|
||||||
meta.className='download-meta warn';
|
|
||||||
link.style.display='none';
|
|
||||||
qr.style.display='none';
|
|
||||||
qr.removeAttribute('src');
|
|
||||||
desc.textContent='当前暂未提供可下载的 APK 安装包';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var url=location.origin+(info.download_url||'/download/showen-app.apk');
|
|
||||||
meta.textContent='版本 '+(info.version||'0.1.0')+' · '+formatBytes(info.apk_size);
|
|
||||||
meta.className='download-meta';
|
|
||||||
link.style.display='inline-flex';
|
|
||||||
link.href=info.download_url||'/download/showen-app.apk';
|
|
||||||
qr.style.display='block';
|
|
||||||
qr.src='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(url);
|
|
||||||
desc.textContent='扫描二维码或点击下载,支持蓝牙配网和远程控制';
|
|
||||||
}
|
|
||||||
function openDownloadModal(){
|
|
||||||
var modal=$('download-modal');
|
|
||||||
$('download-meta').textContent='正在获取 App 信息...';
|
|
||||||
$('download-meta').className='download-meta';
|
|
||||||
$('download-apk-link').style.display='none';
|
|
||||||
$('download-qr-image').style.display='none';
|
|
||||||
$('download-qr-image').removeAttribute('src');
|
|
||||||
$('download-modal-desc').textContent='扫描二维码或点击下载,支持蓝牙配网和远程控制';
|
|
||||||
modal.classList.add('open');
|
|
||||||
fetch('/api/app/info').then(function(r){if(!r.ok)throw new Error('加载 App 信息失败');return r.json()}).then(updateDownloadModal).catch(function(){
|
|
||||||
$('download-meta').textContent='APK 信息加载失败,请稍后再试';
|
|
||||||
$('download-meta').className='download-meta warn';
|
|
||||||
$('download-apk-link').style.display='none';
|
|
||||||
$('download-qr-image').style.display='none';
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function closeDownloadModal(ev){if(ev&&ev.target!==$('download-modal'))return;$('download-modal').classList.remove('open')}
|
|
||||||
document.addEventListener('keydown',function(ev){if(ev.key==='Escape')closeDownloadModal()})
|
|
||||||
|
|
||||||
// Tabs
|
|
||||||
document.querySelectorAll('.tab').forEach(function(t){t.onclick=function(){document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(e){e.classList.remove('active')});t.classList.add('active');$('panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='videos')loadVideoList();if(t.dataset.tab==='files')fmRefresh();if(t.dataset.tab==='wifi'){loadWifiStatus();loadBleStatus()}if(t.dataset.tab==='settings'&&!cachedConfig){loadConfig();loadAvailableConfigs()}}});
|
|
||||||
|
|
||||||
// WebSocket
|
|
||||||
function connectWS(){var proto=location.protocol==='https:'?'wss:':'ws:';ws=new WebSocket(proto+'//'+location.host+'/ws');ws.onopen=function(){wsReady=true;$('ws-badge').textContent='WS 已连接';$('ws-badge').style.borderColor='rgba(34,197,94,.4)';$('ws-badge').style.color='#22c55e';$('ws-badge').style.background='rgba(34,197,94,.1)'};ws.onclose=function(){wsReady=false;$('ws-badge').textContent='WS 断开';$('ws-badge').style.borderColor='rgba(239,68,68,.3)';$('ws-badge').style.color='#ef4444';$('ws-badge').style.background='rgba(239,68,68,.1)';setTimeout(connectWS,2000)};ws.onerror=function(){wsReady=false};ws.onmessage=function(ev){try{var msg=JSON.parse(ev.data);if(msg.type==='status_update')applyStatus(msg.data);else if(msg.type==='state_update')toast('场景: '+msg.data.old_state+' -> '+msg.data.new_state);else if(msg.type==='wifi_update')applyWifi(msg.data);else if(msg.type==='ble_update')applyBle(msg.data);else if(msg.type==='config_update')cachedConfig=msg.data}catch(e){}}}
|
|
||||||
function wsCmd(obj){if(wsReady)ws.send(JSON.stringify(obj));else toast('WebSocket 未连接',true)}
|
|
||||||
|
|
||||||
// API
|
|
||||||
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}
|
|
||||||
|
|
||||||
// Status
|
|
||||||
function applyStatus(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val warn'}else if(d.paused){el.textContent='已暂停';el.className='val warn'}else{el.textContent='播放中';el.className='val ok'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}
|
|
||||||
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(applyStatus).catch(function(){})}
|
|
||||||
function applyWifi(d){if(d.connected!==undefined){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val ok':'val err';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}}
|
|
||||||
function applyBle(d){if(d.ready!==undefined){var el=$('ble-status');el.textContent=d.ready?'运行中':'未就绪';el.className=d.ready?'val ok':'val warn'}}
|
|
||||||
|
|
||||||
// Controls
|
|
||||||
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}if(wsReady)wsCmd({cmd:'goto',index:parseInt(idx,10)});else api('POST','/api/goto/'+idx)}
|
|
||||||
function triggerPreset(n,v){if(wsReady)wsCmd({cmd:'trigger',name:n,value:v||''});else api('POST','/api/trigger/'+encodeURIComponent(n)+'/'+encodeURIComponent(v||''))}
|
|
||||||
function triggerCustom(){var n=$('tr-name').value,v=$('tr-value').value;if(!n){toast('请输入触发器名',true);return}triggerPreset(n,v)}
|
|
||||||
|
|
||||||
// Videos
|
|
||||||
function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item"><span class="name">目录中没有视频文件</span></div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span class="name">'+esc(f.name)+'</span><span class="meta">'+sz+'</span><button class="btn-d" onclick="deleteVideo(\''+js(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})}
|
|
||||||
function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
|
|
||||||
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}
|
|
||||||
|
|
||||||
// File Manager
|
|
||||||
function fmRefresh(){fmNavigate(fmCurrentPath)}
|
|
||||||
function fmNavigate(subpath){
|
|
||||||
fmCurrentDir=$('fm-dir').value;fmCurrentPath=subpath||'';
|
|
||||||
var url='/api/files/'+fmCurrentDir;if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);
|
|
||||||
// breadcrumb
|
|
||||||
var bc=$('fm-breadcrumb');var parts=fmCurrentPath?fmCurrentPath.split('/'):[];
|
|
||||||
var html='<span onclick="fmNavigate(\'\')">'+({'videos':'视频目录','configs':'配置目录','plugins':'插件目录'}[fmCurrentDir]||fmCurrentDir)+'</span>';
|
|
||||||
var acc='';
|
|
||||||
for(var i=0;i<parts.length;i++){if(parts[i]){acc+=(acc?'/':'')+parts[i];html+='<span class="sep">/</span><span onclick="fmNavigate(\''+js(acc)+'\')">'+esc(parts[i])+'</span>'}}
|
|
||||||
bc.innerHTML=html;
|
|
||||||
fetch(url).then(function(r){return r.json()}).then(function(entries){
|
|
||||||
var el=$('fm-list');
|
|
||||||
if(entries.status==='error'){el.innerHTML='<div class="item"><span class="name">'+esc(entries.message)+'</span></div>';return}
|
|
||||||
if(!entries.length){el.innerHTML='<div class="item"><span class="name">空目录</span></div>';$('fm-del-btn').style.display='none';return}
|
|
||||||
var html='';
|
|
||||||
for(var i=0;i<entries.length;i++){
|
|
||||||
var e=entries[i];var sz=e.is_dir?'':e.size<1048576?(e.size/1024).toFixed(1)+' KB':(e.size/1048576).toFixed(1)+' MB';
|
|
||||||
var fp=fmCurrentPath?(fmCurrentPath+'/'+e.name):e.name;
|
|
||||||
html+='<div class="item">';
|
|
||||||
html+='<label class="checkbox-label"><input type="checkbox" class="fm-check" data-path="'+esc(fp)+'" data-isdir="'+(e.is_dir?'1':'0')+'"></label>';
|
|
||||||
if(e.is_dir){html+='<span class="dir-icon">📁</span><span class="name" style="cursor:pointer;color:var(--amber)" onclick="fmNavigate(\''+js(fp)+'\')">'+esc(e.name)+'</span>'}
|
|
||||||
else{html+='<span class="name">'+esc(e.name)+'</span>'}
|
|
||||||
html+='<span class="meta">'+sz+'</span>';
|
|
||||||
if(!e.is_dir){html+='<button class="btn-s" onclick="fmDownload(\''+js(fp)+'\')">下载</button>'}
|
|
||||||
html+='</div>'
|
|
||||||
}
|
|
||||||
el.innerHTML=html;
|
|
||||||
// 监听 checkbox 变化
|
|
||||||
el.querySelectorAll('.fm-check').forEach(function(cb){cb.onchange=fmUpdateDelBtn});
|
|
||||||
fmUpdateDelBtn()
|
|
||||||
}).catch(function(){toast('加载文件列表失败',true)})
|
|
||||||
}
|
|
||||||
function fmUpdateDelBtn(){var cbs=document.querySelectorAll('.fm-check:checked');$('fm-del-btn').style.display=cbs.length?'':'none';$('fm-del-btn').textContent='删除选中 ('+cbs.length+')'}
|
|
||||||
function fmDeleteSelected(){
|
|
||||||
var cbs=document.querySelectorAll('.fm-check:checked');if(!cbs.length)return;
|
|
||||||
if(!confirm('确定删除 '+cbs.length+' 个项目?'))return;
|
|
||||||
var promises=[];
|
|
||||||
cbs.forEach(function(cb){
|
|
||||||
var p=cb.dataset.path;
|
|
||||||
promises.push(fetch('/api/files/'+fmCurrentDir+'/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})}).then(function(r){return r.json()}))
|
|
||||||
});
|
|
||||||
Promise.all(promises).then(function(results){
|
|
||||||
var ok=0,fail=0;results.forEach(function(r){if(r.status==='ok')ok++;else fail++});
|
|
||||||
toast('删除完成: '+ok+' 成功'+(fail?' / '+fail+' 失败':''),fail>0);fmRefresh()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function fmDownload(path){window.open('/api/files/'+fmCurrentDir+'/download?path='+encodeURIComponent(path),'_blank')}
|
|
||||||
function fmUpload(input){
|
|
||||||
if(!input.files.length)return;
|
|
||||||
var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);
|
|
||||||
var url='/api/files/'+fmCurrentDir+'/upload';if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);
|
|
||||||
fetch(url,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';fmRefresh()}).catch(function(){toast('上传失败',true)})
|
|
||||||
}
|
|
||||||
function fmMkdir(){
|
|
||||||
var name=prompt('输入新文件夹名称:');if(!name)return;
|
|
||||||
var path=fmCurrentPath?(fmCurrentPath+'/'+name):name;
|
|
||||||
fetch('/api/files/'+fmCurrentDir+'/mkdir',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');fmRefresh()}).catch(function(){toast('创建失败',true)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// WiFi
|
|
||||||
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(applyWifi).catch(function(){})}
|
|
||||||
function scanWifi(){$('wifi-list').innerHTML='<div class="item"><span class="name">扫描中...</span></div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item"><span class="name">未发现 WiFi</span></div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span class="name">'+esc(n.ssid||'隐藏网络')+'</span><span class="meta">'+esc(String(n.signal||0))+' / '+esc(n.security||'OPEN')+'</span><button class="btn-s" onclick="selectWifi(\''+js(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})}
|
|
||||||
function selectWifi(ssid){$('wifi-ssid-input').value=ssid}
|
|
||||||
function connectWifi(){var s=$('wifi-ssid-input').value,p=$('wifi-pass-input').value;if(!s){toast('请输入 WiFi 名称',true);return}if(wsReady)wsCmd({cmd:'connect',ssid:s,password:p});else api('POST','/api/wifi/connect',{ssid:s,password:p}).then(function(){setTimeout(loadWifiStatus,1500)})}
|
|
||||||
function startAP(){var s=$('ap-ssid').value||'showen',p=$('ap-pass').value||'12345678';if(p.length<8){toast('热点密码至少 8 位',true);return}if(wsReady)wsCmd({cmd:'ap_start',ssid:s,password:p});else api('POST','/api/wifi/ap/start',{ssid:s,password:p})}
|
|
||||||
function stopAP(){if(wsReady)wsCmd({cmd:'ap_stop'});else api('POST','/api/wifi/ap/stop')}
|
|
||||||
function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val ok'}else{el.textContent='未就绪';el.className='val warn'}}).catch(function(){})}
|
|
||||||
function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)}
|
|
||||||
function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)}
|
|
||||||
|
|
||||||
// Config switch
|
|
||||||
function loadAvailableConfigs(){fetch('/api/config/available').then(function(r){return r.json()}).then(function(d){var sel=$('cfg-select');sel.innerHTML='';d.configs.forEach(function(f){var opt=document.createElement('option');opt.value=f;opt.textContent=f;if(f===d.active)opt.selected=true;sel.appendChild(opt)})}).catch(function(){})}
|
|
||||||
function switchConfig(){var f=$('cfg-select').value;if(!f){toast('请选择配置',true);return}if(!confirm('确定切换到 '+f+' ?'))return;api('POST','/api/config/switch',{filename:f}).then(function(){loadConfig();loadAvailableConfigs()})}
|
|
||||||
|
|
||||||
// Config
|
|
||||||
function loadConfig(){fetch('/api/config').then(function(r){return r.json()}).then(function(cfg){cachedConfig=cfg;$('cfg-editor').value=JSON.stringify(cfg,null,2);renderDisplay(cfg.display)}).catch(function(){toast('加载配置失败',true)})}
|
|
||||||
function formatConfig(){try{$('cfg-editor').value=JSON.stringify(JSON.parse($('cfg-editor').value),null,2)}catch(_){toast('JSON 格式错误',true)}}
|
|
||||||
function saveConfig(){var raw=$('cfg-editor').value;try{JSON.parse(raw)}catch(_){toast('JSON 格式错误',true);return}api('POST','/api/config',raw).then(loadConfig)}
|
|
||||||
function renderDisplay(d){if(!d)return;var h='';h+='<label class="checkbox-label"><input id="d-fullscreen" type="checkbox" '+(d.fullscreen?'checked':'')+'> 全屏模式</label>';h+='<label>窗口标题</label><input id="d-title" type="text" value="'+ea(d.window_title||'')+'">';h+='<label>旋转角度</label><input id="d-rotation" type="number" value="'+ea(String(d.rotation||0))+'">';h+='<label>渲染宽度</label><input id="d-render-width" type="number" value="'+ea(String(d.render_width||1024))+'">';h+='<label>渲染高度</label><input id="d-render-height" type="number" value="'+ea(String(d.render_height||1024))+'">';h+='<label>色键下限 (H,S,V)</label><input id="d-ck-min" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_min?d.chroma_key.hsv_min.join(','):'0,0,200'))+'">';h+='<label>色键上限 (H,S,V)</label><input id="d-ck-max" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_max?d.chroma_key.hsv_max.join(','):'180,30,255'))+'">';h+='<label>透视校正点 (JSON)</label><input id="d-points" type="text" value="'+ea(JSON.stringify((d.perspective_correction&&d.perspective_correction.points)||[]))+'">';$('display-form').innerHTML=h}
|
|
||||||
function saveDisplay(){if(!cachedConfig){loadConfig();return}var n=JSON.parse(JSON.stringify(cachedConfig));n.display.fullscreen=$('d-fullscreen').checked;n.display.window_title=$('d-title').value;n.display.rotation=parseInt($('d-rotation').value||'0',10);n.display.render_width=parseInt($('d-render-width').value||'1024',10);n.display.render_height=parseInt($('d-render-height').value||'1024',10);n.display.chroma_key=n.display.chroma_key||{};n.display.chroma_key.hsv_min=$('d-ck-min').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.chroma_key.hsv_max=$('d-ck-max').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.perspective_correction=n.display.perspective_correction||{};try{n.display.perspective_correction.points=JSON.parse($('d-points').value)}catch(_){toast('透视点 JSON 无效',true);return}cachedConfig=n;$('cfg-editor').value=JSON.stringify(n,null,2);saveConfig()}
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
function esc(v){return String(v).replace(/[&<>"]/g,function(c){return({'&':'&','<':'<','>':'>','"':'"'})[c]})}
|
|
||||||
function ea(v){return esc(v).replace(/'/g,''')}
|
|
||||||
function js(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
|
|
||||||
connectWS();refreshStatus();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"##;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user