style: refresh rgb led panel ui
This commit is contained in:
+59
-35
@@ -27,33 +27,52 @@ CONTROLLABLE_COLORS = tuple(c.strip().lower() for c in os.environ.get("LED_COLOR
|
||||
NAME_RE = re.compile(r"^[A-Za-z0-9_.:+@=-]+$")
|
||||
|
||||
CSS = r"""
|
||||
:root { color-scheme: dark; --bg:#0b1020; --card:#121a2f; --muted:#8ea0c0; --text:#e8eefc; --line:#263551; --accent:#5eead4; --danger:#fb7185; --warn:#fbbf24; }
|
||||
:root { color-scheme: dark; --bg:#070b16; --card:#101827; --muted:#8ea0c0; --text:#eef5ff; --line:#25324a; --accent:#5eead4; --danger:#fb7185; --warn:#fbbf24; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; background: radial-gradient(circle at top left,#172554 0,#0b1020 34rem); color:var(--text); }
|
||||
.container { max-width: 1180px; margin: 0 auto; padding: 24px; }
|
||||
.header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:20px; }
|
||||
h1 { margin:0 0 6px; font-size:28px; }
|
||||
.sub { color:var(--muted); font-size:14px; }
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(310px,1fr)); gap:16px; }
|
||||
.card { background: rgba(18,26,47,.92); border:1px solid var(--line); border-radius:18px; padding:16px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
|
||||
.name { font-weight:700; font-size:18px; display:flex; align-items:center; gap:8px; word-break:break-all; }
|
||||
.dot { width:12px; height:12px; border-radius:50%; background:#64748b; box-shadow:0 0 12px currentColor; }
|
||||
.meta { margin-top:8px; color:var(--muted); font-size:13px; line-height:1.5; }
|
||||
body { margin:0; min-height:100vh; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; background: radial-gradient(circle at top left,rgba(59,130,246,.26),transparent 28rem), radial-gradient(circle at top right,rgba(20,184,166,.18),transparent 24rem), linear-gradient(135deg,#070b16 0%,#0b1020 58%,#111827 100%); color:var(--text); }
|
||||
.container { max-width: 1180px; margin: 0 auto; padding: 22px; }
|
||||
.header { display:grid; grid-template-columns: minmax(0,1fr) auto; gap:16px; align-items:start; margin-bottom:18px; }
|
||||
.hero { border:1px solid rgba(148,163,184,.18); background:rgba(15,23,42,.62); backdrop-filter: blur(16px); border-radius:24px; padding:18px 20px; box-shadow:0 18px 48px rgba(0,0,0,.28); }
|
||||
h1 { margin:0 0 8px; font-size:30px; letter-spacing:.2px; }
|
||||
.sub { color:var(--muted); font-size:14px; line-height:1.6; }
|
||||
.toolbar { display:flex; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.summary { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; }
|
||||
.pill { border:1px solid rgba(148,163,184,.18); background:rgba(15,23,42,.72); color:var(--muted); padding:7px 10px; border-radius:999px; font-size:13px; }
|
||||
.pill b { color:var(--text); }
|
||||
.grid { display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap:16px; }
|
||||
.card { position:relative; overflow:hidden; background: linear-gradient(180deg,rgba(18,26,47,.96),rgba(10,15,28,.96)); border:1px solid var(--line); border-radius:24px; padding:18px; box-shadow: 0 14px 42px rgba(0,0,0,.28); }
|
||||
.card::before { content:''; position:absolute; inset:-40% -30% auto auto; width:180px; height:180px; border-radius:50%; background:radial-gradient(circle,var(--led-color),transparent 68%); opacity:.16; pointer-events:none; }
|
||||
.card.on { border-color: color-mix(in srgb, var(--led-color) 45%, var(--line)); }
|
||||
.card.off { opacity:.82; }
|
||||
.card-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.name { font-weight:800; font-size:19px; display:flex; align-items:center; gap:10px; word-break:break-all; }
|
||||
.dot { width:14px; height:14px; border-radius:50%; background:var(--led-color); color:var(--led-color); box-shadow:0 0 18px currentColor; }
|
||||
.bulb-wrap { display:grid; place-items:center; margin:18px 0 12px; }
|
||||
.bulb { width:118px; height:118px; border-radius:999px; display:grid; place-items:center; color:#020617; font-size:28px; font-weight:900; background:radial-gradient(circle at 34% 30%,#fff 0 4%,var(--led-color) 10%,rgba(71,85,105,.24) 72%); box-shadow:0 0 calc(12px + var(--level)*42px) color-mix(in srgb, var(--led-color) 72%, transparent), inset 0 0 28px rgba(255,255,255,.12); filter:saturate(calc(.55 + var(--level))); }
|
||||
.card.off .bulb { background:#1e293b; color:#64748b; box-shadow:inset 0 0 24px rgba(0,0,0,.35); }
|
||||
.meta { margin-top:8px; color:var(--muted); font-size:13px; line-height:1.55; }
|
||||
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:12px; }
|
||||
button, select, input[type=number], input[type=text] { border:1px solid var(--line); background:#0f172a; color:var(--text); border-radius:10px; padding:9px 10px; font-size:14px; }
|
||||
button { cursor:pointer; transition:.15s; }
|
||||
.stack { display:grid; gap:10px; margin-top:14px; }
|
||||
button, select, input[type=number], input[type=text] { border:1px solid var(--line); background:#0f172a; color:var(--text); border-radius:12px; padding:10px 12px; font-size:14px; }
|
||||
button { cursor:pointer; transition:.15s ease; }
|
||||
button:hover { border-color:var(--accent); transform: translateY(-1px); }
|
||||
button.primary { background:#0f766e; border-color:#14b8a6; }
|
||||
button.off { background:#3f1d2a; border-color:#7f1d1d; }
|
||||
button.warn { background:#422006; border-color:#92400e; }
|
||||
input[type=range] { width:100%; accent-color: var(--accent); }
|
||||
.range-line { display:grid; grid-template-columns: 1fr 64px; gap:10px; align-items:center; margin-top:14px; }
|
||||
.badge { color:#0b1020; background:var(--accent); padding:2px 7px; border-radius:999px; font-size:12px; font-weight:700; }
|
||||
.err { background:#450a0a; border:1px solid #7f1d1d; color:#fecaca; padding:12px; border-radius:12px; margin-bottom:16px; }
|
||||
.toast { position:fixed; right:16px; bottom:16px; max-width:520px; padding:12px 14px; border-radius:12px; background:#052e2b; border:1px solid #14b8a6; display:none; }
|
||||
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.actions button { width:100%; }
|
||||
.sub-actions button { padding:8px 10px; font-size:13px; }
|
||||
input[type=range] { width:100%; accent-color: var(--led-color); }
|
||||
.range-line { display:grid; grid-template-columns: 1fr 66px; gap:10px; align-items:center; margin-top:12px; }
|
||||
.badge { color:#07111f; background:var(--accent); padding:3px 8px; border-radius:999px; font-size:12px; font-weight:800; }
|
||||
.badge.saved { background:#fbbf24; }
|
||||
.err { background:#450a0a; border:1px solid #7f1d1d; color:#fecaca; padding:12px; border-radius:14px; margin-bottom:16px; }
|
||||
.toast { position:fixed; right:16px; bottom:16px; max-width:520px; padding:12px 14px; border-radius:14px; background:#052e2b; border:1px solid #14b8a6; box-shadow:0 18px 40px rgba(0,0,0,.35); display:none; z-index:20; }
|
||||
.small { font-size:12px; color:var(--muted); }
|
||||
.path { word-break:break-all; }
|
||||
a { color:var(--accent); }
|
||||
@media (max-width: 640px) { .container{padding:14px;} .header{display:block;} }
|
||||
@media (max-width: 900px) { .grid{grid-template-columns:1fr;} .header{grid-template-columns:1fr;} .toolbar{justify-content:flex-start;} }
|
||||
@media (max-width: 640px) { .container{padding:14px;} h1{font-size:25px;} .hero{padding:16px;} .bulb{width:96px;height:96px;} .actions{grid-template-columns:1fr;} }
|
||||
"""
|
||||
|
||||
JS = r"""
|
||||
@@ -65,33 +84,38 @@ async function api(path, body) {
|
||||
return data;
|
||||
}
|
||||
function toast(msg, bad=false){ const t=document.getElementById('toast'); t.textContent=msg; t.style.display='block'; t.style.background=bad?'#450a0a':'#052e2b'; t.style.borderColor=bad?'#fb7185':'#14b8a6'; setTimeout(()=>t.style.display='none',2600); }
|
||||
async function setBrightness(name, value){ try{ const r=await api('/api/brightness',{name,value:Number(value)}); toast(name+' brightness='+(r.brightness ?? value)+(r.warning?';'+r.warning:''), !!r.warning); setTimeout(load,300);}catch(e){toast(e.message,true);} }
|
||||
async function setTrigger(name, trigger){ try{ const r=await api('/api/trigger',{name,trigger}); toast(name+' trigger='+(r.active_trigger || trigger)); setTimeout(load,300);}catch(e){toast(e.message,true);} }
|
||||
async function setDelay(name, field, value){ try{ await api('/api/delay',{name,field,value:Number(value)}); toast(name+' '+field+'='+value); setTimeout(load,300);}catch(e){toast(e.message,true);} }
|
||||
async function identify(name){ try{ await api('/api/identify',{name}); toast('Blink '+name); setTimeout(load,3200);}catch(e){toast(e.message,true);} }
|
||||
async function saveLed(name){ try{ await api('/api/save',{name}); toast('已保存 '+name+',重启后自动恢复'); setTimeout(load,300);}catch(e){toast(e.message,true);} }
|
||||
async function deleteSaved(name){ try{ await api('/api/delete-saved',{name}); toast('已取消持久化 '+name); setTimeout(load,300);}catch(e){toast(e.message,true);} }
|
||||
async function applySaved(){ try{ const r=await api('/api/apply-saved',{}); toast('已应用持久化配置:'+(r.applied||0)+' 个'); setTimeout(load,300);}catch(e){toast(e.message,true);} }
|
||||
async function turnOffAll(){ try{ const r=await api('/api/all-off',{}); const failed=(r.results||[]).filter(x=>!x.ok); toast(failed.length ? ('已关闭 '+(r.changed||0)+' 个,失败 '+failed.length+' 个') : ('已关闭全部 LED:'+(r.changed||0)+' 个'), failed.length>0); setTimeout(load,300);}catch(e){toast(e.message,true);} }
|
||||
async function setBrightness(name, value){ try{ const r=await api('/api/brightness',{name,value:Number(value)}); toast(name+' 亮度='+(r.brightness ?? value)+(r.warning?';'+r.warning:''), !!r.warning); setTimeout(load,260);}catch(e){toast(e.message,true);} }
|
||||
async function setTrigger(name, trigger){ try{ const r=await api('/api/trigger',{name,trigger}); toast(name+' trigger='+(r.active_trigger || trigger)); setTimeout(load,260);}catch(e){toast(e.message,true);} }
|
||||
async function setDelay(name, field, value){ try{ await api('/api/delay',{name,field,value:Number(value)}); toast(name+' '+field+'='+value); setTimeout(load,260);}catch(e){toast(e.message,true);} }
|
||||
async function identify(name){ try{ await api('/api/identify',{name}); toast('正在识别 '+name); setTimeout(load,3200);}catch(e){toast(e.message,true);} }
|
||||
async function saveLed(name){ try{ await api('/api/save',{name}); toast('已保存 '+name+',重启后自动恢复'); setTimeout(load,260);}catch(e){toast(e.message,true);} }
|
||||
async function deleteSaved(name){ try{ await api('/api/delete-saved',{name}); toast('已取消持久化 '+name); setTimeout(load,260);}catch(e){toast(e.message,true);} }
|
||||
async function applySaved(){ try{ const r=await api('/api/apply-saved',{}); toast('已应用持久化配置:'+(r.applied||0)+' 个'); setTimeout(load,260);}catch(e){toast(e.message,true);} }
|
||||
async function turnOffAll(){ try{ const r=await api('/api/all-off',{}); const failed=(r.results||[]).filter(x=>!x.ok); toast(failed.length ? ('已关闭 '+(r.changed||0)+' 个,失败 '+failed.length+' 个') : ('已关闭全部 LED:'+(r.changed||0)+' 个'), failed.length>0); setTimeout(load,260);}catch(e){toast(e.message,true);} }
|
||||
function colorOf(n,b){ const s=n.toLowerCase(); if(Number(b)<=0) return '#475569'; if(s.includes('red')) return '#ef4444'; if(s.includes('green')) return '#22c55e'; if(s.includes('blue')) return '#3b82f6'; if(s.includes('wlan')) return '#22c55e'; if(s.includes('wan')) return '#3b82f6'; if(s.includes('power')) return '#ef4444'; if(s.includes('sim')) return '#f59e0b'; return '#5eead4'; }
|
||||
function labelOf(n){ const s=n.toLowerCase(); if(s.includes('red')) return '红灯'; if(s.includes('green') || s.includes('wlan')) return '绿灯'; if(s.includes('blue') || s.includes('wan')) return '蓝灯'; return n; }
|
||||
function esc(s){ return String(s).replace(/[&<>'"]/g, c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
||||
function attr(s){ return esc(s); }
|
||||
function card(led){
|
||||
const pct = led.max_brightness>0 ? Math.round(led.brightness*100/led.max_brightness) : 0;
|
||||
const level = Math.max(0, Math.min(1, led.max_brightness>0 ? led.brightness/led.max_brightness : 0));
|
||||
const on = Number(led.brightness)>0;
|
||||
const triggers = led.triggers || [];
|
||||
const n = attr(led.name);
|
||||
const color = colorOf(led.name, led.brightness);
|
||||
const triggerSelect = triggers.length ? `<select data-led="${n}" onchange="setTrigger(this.dataset.led, this.value)">${triggers.map(t=>`<option value="${attr(t.name)}" ${t.active?'selected':''}>${esc(t.name)}</option>`).join('')}</select>` : '<span class="small">无 trigger 文件</span>';
|
||||
const delay = (led.delay_on !== null || led.delay_off !== null) ? `<div class="row"><span class="small">timer:</span><input data-led="${n}" type="number" min="0" value="${led.delay_on ?? 500}" onchange="setDelay(this.dataset.led,'delay_on',this.value)" title="delay_on ms"><input data-led="${n}" type="number" min="0" value="${led.delay_off ?? 500}" onchange="setDelay(this.dataset.led,'delay_off',this.value)" title="delay_off ms"></div>` : '';
|
||||
return `<div class="card">
|
||||
<div class="name"><span class="dot" style="background:${colorOf(led.name,led.brightness)};color:${colorOf(led.name,led.brightness)}"></span>${esc(led.name)} ${led.writable?'<span class="badge">可写</span>':'<span class="small">只读/权限不足</span>'} ${led.saved?'<span class="badge">已持久化</span>':''}</div>
|
||||
<div class="meta">brightness: <b>${led.brightness}</b> / ${led.max_brightness} (${pct}%)<br>trigger: <b>${esc(led.active_trigger || '-')}</b><br>path: ${esc(led.path)}</div>
|
||||
const delay = (led.delay_on !== null || led.delay_off !== null) ? `<div class="row"><span class="small">闪烁间隔</span><input data-led="${n}" type="number" min="0" value="${led.delay_on ?? 500}" onchange="setDelay(this.dataset.led,'delay_on',this.value)" title="亮起毫秒"><input data-led="${n}" type="number" min="0" value="${led.delay_off ?? 500}" onchange="setDelay(this.dataset.led,'delay_off',this.value)" title="熄灭毫秒"></div>` : '';
|
||||
return `<div class="card ${on?'on':'off'}" style="--led-color:${color};--level:${level}">
|
||||
<div class="card-head"><div class="name"><span class="dot"></span><span>${esc(labelOf(led.name))}</span></div><div>${led.saved?'<span class="badge saved">已保存</span>':(led.writable?'<span class="badge">可写</span>':'<span class="small">只读</span>')}</div></div>
|
||||
<div class="bulb-wrap"><div class="bulb">${pct}%</div></div>
|
||||
<div class="range-line"><input data-led="${n}" type="range" min="0" max="${led.max_brightness || 1}" value="${led.brightness}" oninput="this.nextElementSibling.value=this.value" onchange="setBrightness(this.dataset.led, this.value)"><input data-led="${n}" type="number" min="0" max="${led.max_brightness || 1}" value="${led.brightness}" onchange="setBrightness(this.dataset.led, this.value)"></div>
|
||||
<div class="row"><button class="primary" data-led="${n}" data-max="${led.max_brightness || 1}" onclick="setBrightness(this.dataset.led, this.dataset.max)">打开</button><button class="off" data-led="${n}" onclick="setBrightness(this.dataset.led, 0)">关闭</button><button class="warn" data-led="${n}" onclick="setTrigger(this.dataset.led, 'timer')">闪烁</button><button data-led="${n}" onclick="identify(this.dataset.led)">识别3秒</button><button data-led="${n}" onclick="saveLed(this.dataset.led)">保存持久化</button><button data-led="${n}" onclick="deleteSaved(this.dataset.led)">取消持久化</button></div>
|
||||
<div class="row"><span class="small">trigger</span>${triggerSelect}</div>${delay}
|
||||
<div class="actions"><button class="primary" data-led="${n}" data-max="${led.max_brightness || 1}" onclick="setBrightness(this.dataset.led, this.dataset.max)">打开</button><button class="off" data-led="${n}" onclick="setBrightness(this.dataset.led, 0)">关闭</button><button class="warn" data-led="${n}" onclick="setTrigger(this.dataset.led, 'timer')">闪烁</button><button data-led="${n}" onclick="identify(this.dataset.led)">识别 3 秒</button></div>
|
||||
<div class="stack"><div class="row"><span class="small">模式</span>${triggerSelect}</div>${delay}<div class="row sub-actions"><button data-led="${n}" onclick="saveLed(this.dataset.led)">保存开机状态</button><button data-led="${n}" onclick="deleteSaved(this.dataset.led)">取消保存</button></div></div>
|
||||
<div class="meta">名称:<b>${esc(led.name)}</b><br>trigger:<b>${esc(led.active_trigger || '-')}</b><br><span class="path">${esc(led.path)}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
async function load(){
|
||||
try { const data = await api('/api/leds'); document.getElementById('root').innerHTML = data.leds.map(card).join('') || '<div class="err">没有发现 LED:/sys/class/leds 为空</div>'; document.getElementById('count').textContent=data.leds.length; }
|
||||
try { const data = await api('/api/leds'); const leds=data.leds||[]; document.getElementById('root').innerHTML = leds.map(card).join('') || '<div class="err">没有发现可控制的红绿蓝 LED</div>'; document.getElementById('count').textContent=leds.length; document.getElementById('oncount').textContent=leds.filter(x=>Number(x.brightness)>0).length; document.getElementById('savedcount').textContent=leds.filter(x=>x.saved).length; }
|
||||
catch(e){ document.getElementById('root').innerHTML='<div class="err">'+esc(e.message)+'</div>'; }
|
||||
}
|
||||
window.addEventListener('load', ()=>{ load(); setInterval(load, 5000); });
|
||||
@@ -371,7 +395,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path == "/":
|
||||
body = f"""<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>LED Manager</title><style>{CSS}</style></head><body><div class='container'><div class='header'><div><h1>LED Manager Panel</h1><div class='sub'>管理 <code>{html.escape(str(LED_ROOT))}</code> 下的 LED;配置保存到 <code>{html.escape(str(CONFIG_PATH))}</code>;当前 <span id='count'>-</span> 个。页面每 5 秒自动刷新。</div></div><div class='row' style='margin-top:0'><button class='off' onclick='turnOffAll()'>关闭全部灯</button><button onclick='applySaved()'>应用已保存配置</button><button onclick='load()'>刷新</button></div></div><div id='root' class='grid'></div></div><div id='toast' class='toast'></div><script>{JS}</script></body></html>"""
|
||||
body = f"""<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>LED Manager</title><style>{CSS}</style></head><body><div class='container'><div class='header'><div class='hero'><h1>RGB LED 控制台</h1><div class='sub'>只显示实际可控的红 / 绿 / 蓝灯;配置保存到 <code>{html.escape(str(CONFIG_PATH))}</code>,页面每 5 秒自动刷新。</div><div class='summary'><span class='pill'>可控灯 <b id='count'>-</b></span><span class='pill'>已亮 <b id='oncount'>-</b></span><span class='pill'>已保存 <b id='savedcount'>-</b></span><span class='pill'>目录 <b>{html.escape(str(LED_ROOT))}</b></span></div></div><div class='toolbar'><button class='off' onclick='turnOffAll()'>关闭全部</button><button onclick='applySaved()'>应用保存</button><button onclick='load()'>刷新</button></div></div><div id='root' class='grid'></div></div><div id='toast' class='toast'></div><script>{JS}</script></body></html>"""
|
||||
data = body.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -53,6 +53,9 @@ def main():
|
||||
t = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
import urllib.request as _urlreq
|
||||
html = _urlreq.urlopen(f'http://127.0.0.1:{port}/', timeout=5).read().decode()
|
||||
assert 'RGB LED 控制台' in html and '关闭全部' in html and 'oncount' in html
|
||||
leds = req(port, '/api/leds')
|
||||
names = [led['name'] for led in leds['leds']]
|
||||
assert leds['ok'] and names == ['blue:wan', 'green:wlan', 'red:power']
|
||||
|
||||
Reference in New Issue
Block a user