feat(web): acid redesign + hero mouse parallax

- 纯黑/冷白双主题 + 100% 饱和电光酸色(lime/cyan/magenta),强对比冲击
- 氛围层:网格 + 双色光晕 + 扫描线 + 噪点;HUD 边角 / 跑马灯 / 信号条 / 滚动入场
- hero 鼠标视差:ghost 水印反向漂移 + 标题/deck/统计分层位移,rAF+lerp 平滑,reduced-motion 降级
- 修复整页刷新时房间列表鉴权竞态、推流页右栏拉伸空白
- vite dev 根路径服务 + /api /rtc /live 代理(build 仍落 /static/)
This commit is contained in:
noelorin 2026-08-20 16:15:54 +08:00
parent 16895dcdd8
commit ffc69d691c
40 changed files with 1607 additions and 913 deletions

View File

@ -0,0 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>t(n,e(i,{iconNode:r,name:`arrow-right`}));export{i as t};

View File

@ -1 +0,0 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>e(n,t(i,{iconNode:r,name:`arrow-right`}));export{i as t};

View File

@ -1 +0,0 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>e(n,t(i,{iconNode:r,name:`cloud`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{E as e,F as t,U as n,V as r,b as i,c as a,f as o,h as s,i as c,j as l,k as u,m as d,p as f,r as p,t as m,u as h,x as g,y as _}from"./ui-W1Pm8dqI.js";import{i as v,n as y}from"./index-CFU6bGFM.js";var b=[[`path`,{d:`m10 17 5-5-5-5`,key:`1bsop3`}],[`path`,{d:`M15 12H3`,key:`6jk70r`}],[`path`,{d:`M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4`,key:`u53s6r`}]],x=e=>t(s,n(e,{iconNode:b,name:`log-in`})),S=e(`<form class=space-y-4><div class=space-y-1.5><label class=label for=login-username>用户名</label></div><div class=space-y-1.5><label class=label for=login-password>密码</label></div><div class=space-y-1.5><label class=label>角色`),C=e(`<div class="mx-auto flex w-full max-w-md flex-col gap-4 py-4"><div class="flex flex-col items-center gap-3 text-center"><span class="flex size-10 items-center justify-center rounded-lg border border-acid-lime/30 bg-primary text-primary-foreground shadow-sm"></span><div><h1 class="text-xl font-semibold">登录分流控制台</h1><p class="mt-1 text-sm text-muted-foreground">发布、订阅与房间管理需要登录</p></div></div><div class="rounded-lg border bg-card p-4"><p class="label mb-2">演示账号</p><div class="grid gap-2 sm:grid-cols-3">`),w=e(`<button type=button class="flex flex-col items-start gap-0.5 rounded-md border bg-background/60 px-3 py-2 text-left transition-colors hover:bg-accent"><span class="text-sm font-medium"></span><span class="text-xs text-muted-foreground"></span><span class="font-mono text-[10px] text-muted-foreground/80">`),T=[{name:`admin`,role:`管理员`,pass:`Admin123!`},{name:`publisher`,role:`发布者`,pass:`Publisher123!`},{name:`viewer`,role:`观看者`,pass:`Viewer123!`}],E=()=>{let e=v(),[n,s]=r(``),[_,b]=r(``),[E,D]=r(`viewer`),[O,k]=r(``),[A,j]=r(!1),M=async t=>{t.preventDefault(),k(``),j(!0);try{await d.login(n(),_()),await f(),await o.refresh(),e({to:`/`})}catch(e){k(String(e?.message||e))}finally{j(!1)}};return(()=>{var e=C(),r=e.firstChild,o=r.firstChild,d=r.nextSibling,f=d.firstChild.nextSibling;return i(o,t(y,{class:`size-5`})),i(e,t(c,{class:`p-6`,get children(){return[(()=>{var e=S(),r=e.firstChild;r.firstChild;var o=r.nextSibling;o.firstChild;var c=o.nextSibling;return c.firstChild,e.addEventListener(`submit`,M),i(r,t(h,{id:`login-username`,get value(){return n()},onInput:s,placeholder:`admin`,autoComplete:`username`,class:`h-9`}),null),i(o,t(h,{id:`login-password`,type:`password`,get value(){return _()},onInput:b,placeholder:`••••••••`,autoComplete:`current-password`,class:`h-9`}),null),i(c,t(a,{get value(){return E()},onChange:e=>D(e),options:[{value:`viewer`,label:`观看者 viewer`},{value:`publisher`,label:`发布者 publisher`}]}),null),i(e,t(p,{type:`submit`,get disabled(){return A()},class:`w-full`,get children(){return[t(x,{class:`size-4`}),g(()=>A()?`登录中…`:`登录`)]}}),null),e})(),t(l,{get when(){return O()},get children(){return t(m,{tone:`error`,class:`mt-4`,get children(){return O()}})}})]}}),d),i(f,t(u,{each:T,children:e=>(()=>{var t=w(),n=t.firstChild,r=n.nextSibling,a=r.nextSibling;return t.$$click=()=>{s(e.name),b(e.pass),D(e.role===`管理员`?`publisher`:`viewer`)},i(n,()=>e.name),i(r,()=>e.role),i(a,()=>e.pass),t})()})),e})()};_([`click`]);export{E as Login};

View File

@ -0,0 +1 @@
import{C as e,G as t,L as n,N as r,O as i,S as a,U as o,_ as s,a as c,d as l,f as u,g as d,h as f,j as p,l as m,m as h,r as g,t as _,x as v}from"./ui-BEZm0Fjb.js";import{t as y}from"./radio-tower-CN-xjSIh.js";import{t as b}from"./index-BjOqv38F.js";var x=[[`path`,{d:`m10 17 5-5-5-5`,key:`1bsop3`}],[`path`,{d:`M15 12H3`,key:`6jk70r`}],[`path`,{d:`M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4`,key:`u53s6r`}]],S=e=>n(h,t(e,{iconNode:x,name:`log-in`})),C=i(`<div class="relative border border-line bg-card/70 p-6 backdrop-blur-sm sm:p-8"><div class="flex items-center gap-3"><span class="flex size-10 shrink-0 items-center justify-center border border-acid-lime/70 bg-acid-lime/15 text-acid-lime"></span><div><h1 class="display text-3xl">登录</h1><p class="micro-label mt-1.5 text-muted-foreground">fan-out console / auth</p></div></div><form class="mt-7 space-y-5"><div class=space-y-2><label class="micro-label flex items-center justify-between text-muted-foreground"for=login-username>用户名 <span class=opacity-50>username</span></label></div><div class=space-y-2><label class="micro-label flex items-center justify-between text-muted-foreground"for=login-password>密码 <span class=opacity-50>password</span></label></div><div class=space-y-2><label class="micro-label flex items-center justify-between text-muted-foreground">角色 <span class=opacity-50>role`),w=i(`<div class="border border-line/80 bg-card/50 p-4"><p class="micro-label text-muted-foreground">demo accounts · 演示账号</p><div class="mt-3 grid gap-2 sm:grid-cols-3">`),T=i(`<div class="relative flex min-h-[calc(100vh-3rem)] items-center justify-center overflow-hidden px-4 py-10"><div class="ghost-text display pointer-events-none absolute -bottom-[7vw] left-1/2 -translate-x-1/2 text-[36vw] leading-none"aria-hidden=true>SFU</div><div class="relative w-full max-w-md">`),E=i(`<button type=button class="group border border-line/80 bg-background/50 px-3 py-2.5 text-left transition-colors hover:border-acid-lime/60 hover:bg-background"><span class="flex items-baseline justify-between gap-2"><span class="font-mono text-xs font-medium"></span><span class="size-1 shrink-0 rotate-45 bg-line transition-colors group-hover:bg-acid-lime"></span></span><span class="mt-0.5 block text-[11px] text-muted-foreground"></span><span class="mt-1 block font-mono text-[10px] text-muted-foreground/70">`),D=[{name:`admin`,role:`管理员`,pass:`Admin123!`},{name:`publisher`,role:`发布者`,pass:`Publisher123!`},{name:`viewer`,role:`观看者`,pass:`Viewer123!`}],O=()=>{let t=b(),[i,h]=o(``),[v,x]=o(``),[O,k]=o(`viewer`),[A,j]=o(``),[M,N]=o(!1),P=async e=>{e.preventDefault(),j(``),N(!0);try{await s.login(i(),v()),await d(),await f.refresh(),t({to:`/`})}catch(e){j(String(e?.message||e))}finally{N(!1)}};return(()=>{var t=T(),o=t.firstChild.nextSibling;return a(o,n(m,{get children(){var t=C(),o=t.firstChild,s=o.firstChild,d=o.nextSibling,f=d.firstChild;f.firstChild;var p=f.nextSibling;p.firstChild;var m=p.nextSibling;return m.firstChild,a(t,n(c,{}),o),a(s,n(y,{class:`size-5`})),d.addEventListener(`submit`,P),a(f,n(u,{id:`login-username`,get value(){return i()},onInput:h,placeholder:`admin`,autoComplete:`username`,class:`h-10`}),null),a(p,n(u,{id:`login-password`,type:`password`,get value(){return v()},onInput:x,placeholder:`••••••••`,autoComplete:`current-password`,class:`h-10`}),null),a(m,n(l,{get value(){return O()},onChange:e=>k(e),options:[{value:`viewer`,label:`观看者 viewer`},{value:`publisher`,label:`发布者 publisher`}],class:`w-full`}),null),a(d,n(g,{type:`submit`,get disabled(){return M()},class:`h-10 w-full`,get children(){return[n(S,{class:`size-4`}),e(()=>M()?`登录中…`:`登录`)]}}),null),a(t,n(r,{get when(){return A()},get children(){return n(_,{tone:`error`,class:`mt-4`,get children(){return A()}})}}),null),t}}),null),a(o,n(m,{delay:1,class:`mt-4`,get children(){var e=w(),t=e.firstChild.nextSibling;return a(t,n(p,{each:D,children:e=>(()=>{var t=E(),n=t.firstChild,r=n.firstChild,i=n.nextSibling,o=i.nextSibling;return t.$$click=()=>{h(e.name),x(e.pass),k(e.role===`管理员`?`publisher`:`viewer`)},a(r,()=>e.name),a(i,()=>e.role),a(o,()=>e.pass),t})()})),e}}),null),t})()};v([`click`]);export{O as Login};

View File

@ -0,0 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],i=i=>t(n,e(i,{iconNode:r,name:`monitor-play`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`path`,{d:`M16.247 7.761a6 6 0 0 1 0 8.478`,key:`1fwjs5`}],[`path`,{d:`M19.075 4.933a10 10 0 0 1 0 14.134`,key:`ehdyv1`}],[`path`,{d:`M4.925 19.067a10 10 0 0 1 0-14.134`,key:`1q22gi`}],[`path`,{d:`M7.753 16.239a6 6 0 0 1 0-8.478`,key:`r2q7qm`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}]],i=i=>e(n,t(i,{iconNode:r,name:`radio`}));export{i as t};

View File

@ -0,0 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>t(n,e(i,{iconNode:r,name:`radio-tower`}));export{i as t};

View File

@ -0,0 +1 @@
import{G as e,L as t,N as n,O as r,S as i,U as a,V as o,_ as s,a as c,h as l,j as u,l as d,m as f,o as p,r as m,x as h,z as g}from"./ui-BEZm0Fjb.js";import{t as _}from"./arrow-right-CYxcOjf4.js";import{t as v}from"./index-BjOqv38F.js";var y=[[`path`,{d:`M11 20H2`,key:`nlcfvz`}],[`path`,{d:`M11 4.562v16.157a1 1 0 0 0 1.242.97L19 20V5.562a2 2 0 0 0-1.515-1.94l-4-1A2 2 0 0 0 11 4.561z`,key:`au4z13`}],[`path`,{d:`M11 4H8a2 2 0 0 0-2 2v14`,key:`74r1mk`}],[`path`,{d:`M14 12h.01`,key:`1jfl7z`}],[`path`,{d:`M22 20h-3`,key:`vhrsz`}]],b=n=>t(f,e(n,{iconNode:y,name:`door-open`})),x=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],S=n=>t(f,e(n,{iconNode:x,name:`plus`})),C=r(`<div><p class="mono-label text-muted-foreground">rooms / registry</p><h1 class="display mt-2 text-4xl sm:text-5xl">房间</h1><p class="mt-3 max-w-lg text-sm text-muted-foreground">创建或进入房间,房间内可观看、发弹幕与查看分发状态。`),w=r(`<div class="relative max-w-2xl border border-line bg-card/60 p-3 sm:p-4"><div class="flex flex-col gap-2 sm:flex-row"><div class="flex h-10 flex-1 items-center gap-2.5 border border-line bg-background/70 px-3 transition-colors focus-within:border-ring"><input class="field-mono h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"placeholder="房间名,如 demo"aria-label=房间名>`),T=r(`<div class="flex items-center justify-between gap-3 border-b border-line pb-3"><h2 class=font-semibold>房间列表</h2><span class="mono-label text-muted-foreground"> rooms`),E=r(`<div>`),D=r(`<div class="mx-auto max-w-[1400px] space-y-10 px-4 py-8 sm:px-6 sm:py-10"><section class=space-y-3>`),O=r(`<div class="flex flex-col items-center gap-3 border border-dashed border-line px-6 py-12 text-center"><span class="flex size-10 items-center justify-center border border-line bg-card text-muted-foreground"></span><div><p class=font-medium>暂无房间</p><p class="mt-1 text-sm text-muted-foreground">创建一个房间后,分发拓扑会在这里出现。`),k=r(`<div class="group -mx-3 grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-line/80 py-4 transition-colors hover:bg-accent/40 sm:mx-0 sm:-mx-4 sm:grid-cols-[3rem_auto_minmax(0,1fr)_auto] sm:gap-4 sm:px-4"><span class="hidden font-mono text-xs text-muted-foreground/70 sm:block"></span><div class=min-w-0><p class="display truncate text-xl transition-colors sm:text-2xl dark:group-hover:text-primary"></p><p class="mono-label mt-1 text-muted-foreground"> 路分发 · </p></div><div class="col-span-2 sm:col-span-1">`),A=()=>{let e=v(),[r,f]=a([]),[h,y]=a(``),[x,A]=a(!1),j=()=>s.listRooms().then(e=>f(e.rooms||[])).catch(()=>{});g(()=>{l.isAuthed()&&j()});let M=async()=>{let t=h().trim();if(t){A(!0);try{await s.createRoom(t),e({to:`/room`,search:{room:t}})}catch(e){alert(String(e?.message||e))}finally{A(!1)}}},N=t=>e({to:`/room`,search:{room:t}});return(()=>{var e=D(),a=e.firstChild;return i(e,t(d,{get children(){return C()}}),a),i(e,t(d,{delay:1,get children(){var e=w(),n=e.firstChild,r=n.firstChild,a=r.firstChild;return i(e,t(c,{}),n),i(r,t(S,{class:`size-4 shrink-0 text-muted-foreground`}),a),a.$$keydown=e=>{e.key===`Enter`&&M()},a.$$input=e=>y(e.currentTarget.value),i(n,t(m,{class:`h-10`,onClick:M,get disabled(){return x()},get children(){return[t(_,{class:`size-4`}),`创建并进入`]}}),null),o(()=>a.value=h()),e}}),a),i(a,t(d,{get children(){var e=T(),t=e.firstChild.nextSibling,n=t.firstChild;return i(t,()=>r().length,n),e}}),null),i(a,t(n,{get when(){return r().length>0},get fallback(){return(()=>{var e=O(),n=e.firstChild;return i(n,t(b,{class:`size-5`})),e})()},get children(){var e=E();return i(e,t(u,{get each(){return r()},children:(e,n)=>t(d,{get delay(){return Math.min(n()+1,5)},get children(){var r=k(),a=r.firstChild,o=a.nextSibling,s=o.firstChild,c=s.nextSibling,l=c.firstChild,u=o.nextSibling;return i(a,()=>String(n()+1).padStart(2,`0`)),i(r,t(p,{get active(){return(e.targets?.length??0)>0}}),o),i(s,()=>e.name),i(c,()=>e.targets?.length??0,l),i(c,()=>(e.targets?.length??0)>0?`live`:`idle`,null),i(u,t(m,{variant:`outline`,class:`h-9 w-full sm:w-auto`,onClick:()=>N(e.name),get children(){return[`进入房间`,t(_,{class:`size-4`})]}})),r}})})),e}}),null),e})()};h([`input`,`keydown`]);export{A as Rooms};

View File

@ -1 +0,0 @@
import{E as e,F as t,L as n,U as r,V as i,b as a,f as o,h as s,i as c,j as l,k as u,m as d,o as f,r as p,u as m}from"./ui-W1Pm8dqI.js";import{t as h}from"./arrow-right-DNPSxlbJ.js";import{t as g}from"./radio-CvpQ__fB.js";import{i as _}from"./index-CFU6bGFM.js";var v=[[`path`,{d:`M11 20H2`,key:`nlcfvz`}],[`path`,{d:`M11 4.562v16.157a1 1 0 0 0 1.242.97L19 20V5.562a2 2 0 0 0-1.515-1.94l-4-1A2 2 0 0 0 11 4.561z`,key:`au4z13`}],[`path`,{d:`M11 4H8a2 2 0 0 0-2 2v14`,key:`74r1mk`}],[`path`,{d:`M14 12h.01`,key:`1jfl7z`}],[`path`,{d:`M22 20h-3`,key:`vhrsz`}]],y=e=>t(s,r(e,{iconNode:v,name:`door-open`})),b=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],x=e=>t(s,r(e,{iconNode:b,name:`plus`})),S=e(`<div class="flex items-center gap-2"><h3 class=font-semibold>创建房间`),C=e(`<div class="flex flex-col gap-2 sm:flex-row">`),w=e(`<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">`),T=e(`<div class=space-y-6><div class=space-y-1.5><h1 class="text-2xl font-semibold">房间</h1><p class="text-sm text-muted-foreground">创建或进入房间,房间内可观看、发弹幕与查看分发状态。</p></div><section class=space-y-3><div class="flex items-center gap-2"><h2 class="text-lg font-semibold">房间列表`),E=e(`<div class="flex flex-col items-center gap-3 rounded-lg border bg-card px-6 py-10 text-center"><span class="flex size-10 items-center justify-center rounded-full bg-secondary text-muted-foreground"></span><div><p class=font-medium>暂无房间</p><p class="mt-1 text-sm text-muted-foreground">创建一个房间后,分发拓扑会在这里出现。`),D=e(`<div class="flex items-center justify-between gap-2"><div class="flex min-w-0 items-center gap-2"><span class="truncate font-medium"></span></div><span class="shrink-0 font-mono text-xs text-muted-foreground"> 路分发`),O=()=>{let e=_(),[r,s]=i([]),[v,b]=i(``),[O,k]=i(!1),A=()=>d.listRooms().then(e=>s(e.rooms||[])).catch(()=>{});n(()=>{o.isAuthed()&&A()});let j=async()=>{let t=v().trim();if(t){k(!0);try{await d.createRoom(t),e({to:`/room`,search:{room:t}})}catch(e){alert(String(e?.message||e))}finally{k(!1)}}},M=t=>e({to:`/room`,search:{room:t}});return(()=>{var e=T(),n=e.firstChild.nextSibling,i=n.firstChild,o=i.firstChild;return a(e,t(c,{class:`space-y-3 p-5`,get children(){return[(()=>{var e=S(),n=e.firstChild;return a(e,t(x,{class:`size-4 text-primary`}),n),e})(),(()=>{var e=C();return a(e,t(m,{get value(){return v()},onInput:b,placeholder:`房间名,如 demo`,class:`sm:max-w-xs`,onKeyDown:e=>{e.key===`Enter`&&j()}}),null),a(e,t(p,{onClick:j,get disabled(){return O()},get children(){return[t(h,{class:`size-4`}),`创建并进入`]}}),null),e})()]}}),n),a(i,t(g,{class:`size-4 text-primary`}),o),a(n,t(l,{get when(){return r().length>0},get fallback(){return(()=>{var e=E(),n=e.firstChild;return a(n,t(y,{class:`size-5`})),e})()},get children(){var e=w();return a(e,t(u,{get each(){return r()},children:e=>t(c,{class:`flex flex-col gap-3 p-4`,get children(){return[(()=>{var n=D(),r=n.firstChild,i=r.firstChild,o=r.nextSibling,s=o.firstChild;return a(r,t(f,{get active(){return(e.targets?.length??0)>0}}),i),a(i,()=>e.name),a(o,()=>e.targets?.length??0,s),n})(),t(p,{variant:`outline`,class:`w-full`,onClick:()=>M(e.name),get children(){return[`进入房间`,t(h,{class:`size-4`})]}})]}})})),e}}),null),e})()};export{O as Rooms};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]],i=i=>e(n,t(i,{iconNode:r,name:`server`}));export{i as t}; import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]],i=i=>t(n,e(i,{iconNode:r,name:`server`}));export{i as t};

View File

@ -1 +1 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-check`}));export{i as t}; import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>t(n,e(i,{iconNode:r,name:`shield-check`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{E as e,F as t,L as n,U as r,V as i,b as a,c as o,f as s,h as c,i as l,j as u,k as d,m as f,n as p,t as m,v as h,x as g,z as _}from"./ui-W1Pm8dqI.js";import{t as v}from"./shield-check-D8IU7TWB.js";var y=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],b=e=>t(c,r(e,{iconNode:y,name:`shield-alert`})),x=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],S=e=>t(c,r(e,{iconNode:x,name:`user-round`})),C=e(`<div class="hidden grid-cols-[1fr_auto_auto] gap-4 border-b px-5 py-3 text-xs font-medium text-muted-foreground sm:grid"><span>用户名</span><span class=w-28>当前角色</span><span class="w-32 text-right">操作`),w=e(`<div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground">暂无用户数据`),T=e(`<div class=space-y-6><div class=space-y-1.5><h1 class="text-2xl font-semibold">用户与角色</h1><p class="text-sm text-muted-foreground">基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。`),E=e(`<span class="flex items-center gap-2">无权限:需要 user:list`),D=e(`<div><div class="flex min-w-0 items-center gap-3"><span></span><div class=min-w-0><p class="truncate text-sm font-medium"></p><p class="font-mono text-[11px] text-muted-foreground"></p></div></div><div class="flex items-center justify-end gap-2">`),O=[`viewer`,`publisher`,`admin`],k={admin:{label:`管理员`,badge:`border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta`,avatar:`border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta`},publisher:{label:`发布者`,badge:`border-warning/30 bg-warning/10 text-warning`,avatar:`border-warning/30 bg-warning/10 text-warning`},viewer:{label:`观看者`,badge:`border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan`,avatar:`border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan`},guest:{label:`访客`,badge:`border-transparent bg-muted text-muted-foreground`,avatar:`border-transparent bg-muted text-muted-foreground`}},A=()=>{let[e,r]=i([]),[c,y]=i(``);n(()=>{s.can(`user:list`)&&f.listUsers().then(e=>r(e.users||[])).catch(e=>y(String(e?.message||e)))});let x=async(e,t)=>{try{await f.updateUserRole(e,t),r(n=>n.map(n=>n.username===e?{...n,role:t}:n))}catch(e){y(String(e?.message||e))}};return(()=>{var n=T();return n.firstChild,a(n,t(u,{get when(){return s.can(`user:list`)},get fallback(){return t(m,{tone:`warning`,get children(){var e=E(),n=e.firstChild;return a(e,t(b,{class:`size-4`}),n),e}})},get children(){return t(l,{get children(){return[t(u,{get when(){return c()},get children(){return t(m,{tone:`error`,class:`rounded-none border-x-0 border-t-0`,get children(){return c()}})}}),C(),t(d,{get each(){return e()},children:(n,r)=>(()=>{var i=D(),c=i.firstChild,l=c.firstChild,d=l.nextSibling.firstChild,f=d.nextSibling,m=c.nextSibling;return a(l,()=>(n.username[0]??`?`).toUpperCase()),a(d,()=>n.username),a(f,()=>k[n.role].label),a(i,t(p,{get class(){return k[n.role].badge},get children(){return n.role}}),m),a(m,t(u,{get when(){return s.can(`user:manage`)},get fallback(){return t(S,{class:`size-4 text-muted-foreground/60`})},get children(){return t(o,{class:`w-32`,get value(){return n.role},get"aria-label"(){return`调整 ${n.username} 的角色`},onChange:e=>x(n.username,e),get options(){return O.map(e=>({value:e,label:e}))}})}})),_(t=>{var a=`grid grid-cols-1 items-center gap-3 px-5 py-3 sm:grid-cols-[1fr_auto_auto] sm:gap-4 ${r()<e().length-1?`border-b`:``}`,o=`flex size-8 shrink-0 items-center justify-center rounded-md border text-xs font-bold ${k[n.role].avatar}`;return a!==t.e&&h(i,t.e=a),o!==t.t&&h(l,t.t=o),t},{e:void 0,t:void 0}),i})()}),t(u,{get when(){return g(()=>e().length===0)()&&!c()},get children(){var e=w(),n=e.firstChild;return a(e,t(v,{class:`size-4 text-success`}),n),e}})]}})}}),null),n})()};export{A as Users};

View File

@ -0,0 +1 @@
import{C as e,G as t,L as n,N as r,O as i,S as a,U as o,V as s,_ as c,b as l,d as u,h as d,j as f,l as p,m,n as h,t as g,z as _}from"./ui-BEZm0Fjb.js";import{t as v}from"./shield-check-DH7IfQwC.js";var y=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],b=e=>n(m,t(e,{iconNode:y,name:`shield-alert`})),x=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],S=e=>n(m,t(e,{iconNode:x,name:`user-round`})),C=i(`<div><p class="mono-label text-muted-foreground">users / rbac</p><h1 class="display mt-2 text-4xl sm:text-5xl">用户与角色</h1><p class="mt-3 max-w-lg text-sm text-muted-foreground">基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。`),w=i(`<div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground">暂无用户数据`),T=i(`<div class="border border-line bg-card/50"><div class="hidden grid-cols-[minmax(0,1fr)_8rem_10rem] gap-4 border-b border-line/80 px-5 py-3 sm:grid"><span class="micro-label text-muted-foreground">username</span><span class="micro-label text-muted-foreground">role</span><span class="micro-label text-right text-muted-foreground">action`),E=i(`<div class="mx-auto max-w-[1400px] space-y-8 px-4 py-8 sm:px-6 sm:py-10">`),D=i(`<span class="flex items-center gap-2">无权限:需要 user:list`),O=i(`<div><div class="flex min-w-0 items-center gap-3"><span></span><div class=min-w-0><p class="truncate font-mono text-sm font-medium"></p><p class="micro-label mt-1 text-muted-foreground"></p></div></div><div class="flex items-center justify-end">`),k=[`viewer`,`publisher`,`admin`],A={admin:{label:`管理员`,badge:`border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta`,avatar:`border-acid-magenta/40 bg-acid-magenta/10 text-acid-magenta`},publisher:{label:`发布者`,badge:`border-warning/30 bg-warning/10 text-warning`,avatar:`border-warning/40 bg-warning/10 text-warning`},viewer:{label:`观看者`,badge:`border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan`,avatar:`border-acid-cyan/40 bg-acid-cyan/10 text-acid-cyan`},guest:{label:`访客`,badge:`border-line bg-transparent text-muted-foreground`,avatar:`border-line bg-muted text-muted-foreground`}},j=()=>{let[t,i]=o([]),[m,y]=o(``);_(()=>{d.can(`user:list`)&&c.listUsers().then(e=>i(e.users||[])).catch(e=>y(String(e?.message||e)))});let x=async(e,t)=>{try{await c.updateUserRole(e,t),i(n=>n.map(n=>n.username===e?{...n,role:t}:n))}catch(e){y(String(e?.message||e))}};return(()=>{var i=E();return a(i,n(p,{get children(){return C()}}),null),a(i,n(r,{get when(){return d.can(`user:list`)},get fallback(){return n(g,{tone:`warning`,get children(){var e=D(),t=e.firstChild;return a(e,n(b,{class:`size-4`}),t),e}})},get children(){return n(p,{delay:1,get children(){var i=T(),o=i.firstChild;return a(i,n(r,{get when(){return m()},get children(){return n(g,{tone:`error`,class:`border-x-0 border-t-0`,get children(){return m()}})}}),o),a(i,n(f,{get each(){return t()},children:(e,i)=>(()=>{var o=O(),c=o.firstChild,f=c.firstChild,p=f.nextSibling.firstChild,m=p.nextSibling,g=c.nextSibling;return a(f,()=>(e.username[0]??`?`).toUpperCase()),a(p,()=>e.username),a(m,()=>A[e.role].label),a(o,n(h,{get class(){return A[e.role].badge},get children(){return e.role}}),g),a(g,n(r,{get when(){return d.can(`user:manage`)},get fallback(){return n(S,{class:`size-4 text-muted-foreground/50`})},get children(){return n(u,{class:`w-32`,get value(){return e.role},get"aria-label"(){return`调整 ${e.username} 的角色`},onChange:t=>x(e.username,t),get options(){return k.map(e=>({value:e,label:e}))}})}})),s(n=>{var r=`grid grid-cols-1 items-center gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_8rem_10rem] sm:gap-4 ${i()<t().length-1?`border-b border-line/70`:``}`,a=`flex size-8 shrink-0 items-center justify-center border font-mono text-xs font-bold ${A[e.role].avatar}`;return r!==n.e&&l(o,n.e=r),a!==n.t&&l(f,n.t=a),n},{e:void 0,t:void 0}),o})()}),null),a(i,n(r,{get when(){return e(()=>t().length===0)()&&!m()},get children(){var e=w(),t=e.firstChild;return a(e,n(v,{class:`size-4 text-success`}),t),e}}),null),i}})}}),null),i})()};export{j as Users};

View File

@ -0,0 +1 @@
import{G as e,L as t,m as n}from"./ui-BEZm0Fjb.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>t(n,e(i,{iconNode:r,name:`cloud`})),a=[[`path`,{d:`m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5`,key:`ftymec`}],[`rect`,{x:`2`,y:`6`,width:`14`,height:`12`,rx:`2`,key:`158x01`}]],o=r=>t(n,e(r,{iconNode:a,name:`video`}));export{i as n,o as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,9 +19,9 @@
} }
})() })()
</script> </script>
<script type="module" crossorigin src="/static/assets/index-CFU6bGFM.js"></script> <script type="module" crossorigin src="/static/assets/index-BjOqv38F.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-W1Pm8dqI.js"> <link rel="modulepreload" crossorigin href="/static/assets/ui-BEZm0Fjb.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-Djwy0HhI.css"> <link rel="stylesheet" crossorigin href="/static/assets/index-iV_C3-n9.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,8 +1,9 @@
import { Component, createSignal, onMount, Show, For } from 'solid-js' import { Component, createSignal, createEffect, For, onCleanup, onMount, Show } from 'solid-js'
import { useNavigate } from '@tanstack/solid-router' import { Link, useNavigate } from '@tanstack/solid-router'
import { import {
ArrowRight, ArrowRight,
Cloud, Cloud,
Hash,
MonitorPlay, MonitorPlay,
RadioTower, RadioTower,
Server, Server,
@ -11,199 +12,366 @@ import {
} from 'lucide-solid' } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Badge, Button, Card, LiveDot, Separator, TextInput } from '../../components/ui' import {
import type { BackendInfo, BackendKind, Room } from '../../lib/types' Badge,
Button,
Corners,
LiveDot,
Marquee,
Meter,
Reveal,
} from '../../components/ui'
import type { BackendInfo, Room } from '../../lib/types'
const PROTOCOLS = ['WHIP', 'WHEP', 'HLS', 'FLV']
function backendIcon(kind: string) { function backendIcon(kind: string) {
if (kind === 'BACKEND_KIND_CLOUDFLARE') return Cloud return kind === 'BACKEND_KIND_CLOUDFLARE' ? Cloud : Server
return Server
} }
function roomTargets(r: Room) { function targetsOf(r: Room) {
return r.targets?.length ?? 0 return r.targets?.length ?? 0
} }
function EmptyRooms(props: { authed: boolean }) {
return (
<div class="mt-6 flex flex-col items-center gap-3 border border-dashed border-line px-6 py-14 text-center">
<MonitorPlay class="size-6 text-muted-foreground/60" />
<div>
<p class="font-medium">{props.authed ? '暂无在线房间' : '房间列表需登录'}</p>
<p class="mt-1 text-sm text-muted-foreground">
{props.authed
? '创建一个房间后,分发拓扑会出现在这里。'
: '登录后可查看与管理房间分发拓扑。'}
</p>
</div>
<Show when={!props.authed}>
<Link to="/login" class="btn btn-outline btn-sm mt-1">
去登录
</Link>
</Show>
</div>
)
}
export const Home: Component = () => { export const Home: Component = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [backends, setBackends] = createSignal<BackendInfo[]>([]) const [backends, setBackends] = createSignal<BackendInfo[]>([])
const [rooms, setRooms] = createSignal<Room[]>([]) const [rooms, setRooms] = createSignal<Room[]>([])
const [room, setRoom] = createSignal('demo') const [room, setRoom] = createSignal('demo')
let heroEl: HTMLElement | undefined
onMount(async () => { onMount(() => {
try { api
const c = await api.getConfig() .getConfig()
setBackends(c.backends || []) .then((c) => setBackends(c.backends || []))
} catch {} .catch(() => {})
if (permissions.isAuthed()) {
try { const el = heroEl
const r = await api.listRooms() if (el && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setRooms(r.rooms || []) // 鼠标视差:各层按 data-parallax 深度位移,rAF + lerp 平滑;聚光灯同步跟随
} catch {} const layers = Array.from(el.querySelectorAll<HTMLElement>('[data-parallax]'))
let tx = 0
let ty = 0
let cx = 0
let cy = 0
let raf = 0
const onMove = (e: MouseEvent) => {
const r = el.getBoundingClientRect()
tx = (e.clientX - r.left) / r.width - 0.5
ty = (e.clientY - r.top) / r.height - 0.5
el.style.setProperty('--mx', `${e.clientX - r.left}px`)
el.style.setProperty('--my', `${e.clientY - r.top}px`)
}
const onLeave = () => {
tx = 0
ty = 0
}
const tick = () => {
cx += (tx - cx) * 0.08
cy += (ty - cy) * 0.08
for (const layer of layers) {
const d = Number(layer.dataset.parallax || 0)
layer.style.transform = `translate3d(${(cx * d).toFixed(2)}px, ${(cy * d).toFixed(2)}px, 0)`
}
raf = requestAnimationFrame(tick)
}
el.addEventListener('mousemove', onMove)
el.addEventListener('mouseleave', onLeave)
raf = requestAnimationFrame(tick)
onCleanup(() => {
el.removeEventListener('mousemove', onMove)
el.removeEventListener('mouseleave', onLeave)
cancelAnimationFrame(raf)
})
} }
}) })
// 登录态就绪后再拉房间(整页刷新时鉴权接口晚于组件挂载,需响应式等待)
createEffect(() => {
if (!permissions.check()) return
if (!permissions.isAuthed()) return
api
.listRooms()
.then((r) => setRooms(r.rooms || []))
.catch(() => {})
})
const go = (to: string) => navigate({ to, search: { room: room() } }) const go = (to: string) => navigate({ to, search: { room: room() } })
return ( return (
<div class="space-y-8"> <div class="relative">
<section class="flex flex-col gap-6 lg:flex-row lg:items-start"> {/* ===== HERO ===== */}
<div class="min-w-0 flex-1 space-y-5"> <section ref={(n) => (heroEl = n)} class="relative overflow-hidden">
<div class="space-y-3"> <div class="spotlight" aria-hidden="true" />
<div class="inline-flex items-center gap-2 font-mono text-xs text-muted-foreground"> <div
<span class="size-1.5 rounded-full bg-success shadow-[0_0_0_3px_hsl(var(--success)/0.14)]" /> class="ghost-text display pointer-events-none absolute -top-8 right-0 hidden text-[24vw] leading-none will-change-transform xl:block"
live-sfu / fan-out console data-parallax="-40"
aria-hidden="true"
>
SFU
</div>
<div class="relative mx-auto max-w-[1400px] px-4 pt-14 pb-12 sm:px-6 sm:pt-20">
<Reveal>
<div class="flex items-center gap-2.5">
<span class="size-2 bg-success pulse" />
<span class="mono-label text-muted-foreground">live-sfu / fan-out console</span>
</div> </div>
<h1 class="text-3xl leading-tight font-semibold sm:text-4xl"> </Reveal>
一路推流,<span class="acid-text">多端扇出</span>
<Reveal delay={1}>
<h1 class="display mt-6 text-[clamp(3.4rem,9vw,7.75rem)] will-change-transform" data-parallax="10">
<span class="block">一路推流,</span>
<span class="block">
<span class="text-acid-lime">多端扇出</span>
<span class="mono-label ml-3 align-top text-acid-lime sm:ml-5">/ fan-out</span>
</span>
</h1> </h1>
<p class="max-w-xl leading-relaxed text-muted-foreground"> </Reveal>
<Reveal delay={2}>
<p class="mt-6 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
Cloudflare Realtime 与 SRS 双 SFU 分发,浏览器经服务端反代直连,凭证不出服务端。 Cloudflare Realtime 与 SRS 双 SFU 分发,浏览器经服务端反代直连,凭证不出服务端。
</p> </p>
</div> </Reveal>
<Card class="space-y-4 p-5"> <Reveal delay={3} class="mt-10">
<div class="flex items-center gap-2"> <div class="relative max-w-3xl border border-line bg-card/60 p-3 backdrop-blur-sm will-change-transform sm:p-4" data-parallax="6">
<MonitorPlay class="size-4 text-primary" /> <Corners />
<h2 class="font-semibold">进入房间</h2> <div class="flex flex-col gap-2 sm:flex-row">
</div> <div class="flex h-11 flex-1 items-center gap-2.5 border border-line bg-background/70 px-3 transition-colors focus-within:border-ring">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center"> <Hash class="size-4 shrink-0 text-muted-foreground" />
<TextInput value={room()} onInput={setRoom} placeholder="房间名,如 demo" class="sm:max-w-xs" /> <input
<div class="flex flex-wrap gap-2"> class="field-mono h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
<Button onClick={() => go('/room')}> value={room()}
onInput={(e) => setRoom((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Enter') go('/room')
}}
placeholder="房间名,如 demo"
aria-label="房间名"
/>
</div>
<Button class="h-11 sm:w-auto" onClick={() => go('/room')}>
进入房间 进入房间
<ArrowRight class="size-4" /> <ArrowRight class="size-4" />
</Button> </Button>
<Button variant="outline" onClick={() => go('/publish')}> <Button variant="outline" class="h-11" onClick={() => go('/publish')}>
<Video class="size-4" /> <Video class="size-4" />
开始直播 开始直播
</Button> </Button>
</div> </div>
<Show when={permissions.isAuthed()}>
<div class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-line/70 pt-3">
<span class="mono-label flex items-center gap-1.5 text-muted-foreground">
role <span class="text-acid-cyan">{permissions.role()}</span>
</span>
<span class="mono-label text-muted-foreground">
publish ·{' '}
<span class={permissions.can('room:publish') ? 'text-success' : 'opacity-50'}>
{permissions.can('room:publish') ? 'ON' : 'OFF'}
</span>
</span>
<span class="mono-label text-muted-foreground">
chat ·{' '}
<span class={permissions.can('room:chat') ? 'text-success' : 'opacity-50'}>
{permissions.can('room:chat') ? 'ON' : 'OFF'}
</span>
</span>
</div>
</Show>
</div> </div>
<Show when={permissions.isAuthed()}> </Reveal>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t pt-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1.5"> <Reveal delay={4} class="mt-12">
当前角色 <div class="grid max-w-3xl grid-cols-3 divide-x divide-line/80 border border-line bg-card/40 will-change-transform" data-parallax="4">
<Badge class="border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan">{permissions.role()}</Badge> <div class="px-4 py-3 sm:px-5">
</span> <p class="display text-3xl sm:text-4xl dark:text-primary">{backends().length || '–'}</p>
<span> <p class="mono-label mt-1.5 text-muted-foreground">sfu backends</p>
发布: </div>
<b class={permissions.can('room:publish') ? 'text-success' : 'text-muted-foreground'}> <div class="px-4 py-3 sm:px-5">
{permissions.can('room:publish') ? '可' : '不可'} <p class="display text-3xl sm:text-4xl dark:text-primary">
</b> {permissions.isAuthed() ? rooms().length : '–'}
</span> </p>
<span> <p class="mono-label mt-1.5 text-muted-foreground">online rooms</p>
发弹幕: </div>
<b class={permissions.can('room:chat') ? 'text-success' : 'text-muted-foreground'}> <div class="px-4 py-3 sm:px-5">
{permissions.can('room:chat') ? '可' : '不可'} <p class="display text-3xl sm:text-4xl dark:text-primary">4</p>
</b> <p class="mono-label mt-1.5 text-muted-foreground">protocols</p>
</span>
</div> </div>
</Show>
</Card>
</div>
<Card class="w-full shrink-0 space-y-4 self-start p-5 lg:w-80 xl:w-[22rem]">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<RadioTower class="size-4 text-primary" />
<h2 class="font-semibold">后端状态</h2>
</div> </div>
<Badge variant="secondary">{backends().length} 路</Badge> </Reveal>
</div> </div>
</section>
<div class="space-y-2"> <Marquee
<Show items={[
when={backends().length > 0} 'WHIP → SFU',
fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>} 'SRS FAN-OUT',
> 'WHEP / HLS / FLV',
<For each={backends()}> 'CLOUDFLARE REALTIME',
{(b) => { 'WEBRTC LOW-LATENCY',
const Icon = backendIcon(b.kind) '一路推流 · 多端扇出',
return ( 'FAN-OUT CONSOLE',
<div class="flex items-center justify-between gap-3 rounded-md border p-3"> ]}
<div class="flex min-w-0 items-center gap-3"> />
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-secondary text-foreground/70">
<Icon class="size-4" /> {/* ===== 在线房间 + 信号栈 ===== */}
</span> <section class="mx-auto grid max-w-[1400px] gap-10 px-4 py-14 sm:px-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-medium">{b.name}</p> <Reveal>
<p class="text-xs text-muted-foreground">{b.primary ? '主 SFU' : '对照后端'}</p> <div class="flex items-end justify-between gap-4 border-b border-line pb-4">
</div> <div>
<p class="mono-label text-muted-foreground">online rooms</p>
<h2 class="display mt-1.5 text-3xl sm:text-4xl">在线房间</h2>
</div>
<Button
variant="ghost"
size="sm"
class="hidden sm:inline-flex"
onClick={() => navigate({ to: '/rooms' })}
>
全部
<ArrowRight class="size-3.5" />
</Button>
</div>
</Reveal>
<Show when={rooms().length > 0} fallback={<EmptyRooms authed={permissions.isAuthed()} />}>
<div class="mt-2">
<For each={rooms()}>
{(r, i) => (
<Reveal delay={Math.min(i() + 1, 5)}>
<div class="group -mx-3 grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-line/80 py-4 transition-colors hover:bg-accent/40 sm:mx-0 sm:-mx-4 sm:grid-cols-[3rem_auto_minmax(0,1fr)_auto] sm:gap-4 sm:px-4">
<span class="hidden font-mono text-xs text-muted-foreground/70 sm:block">
{String(i() + 1).padStart(2, '0')}
</span>
<LiveDot active={targetsOf(r) > 0} />
<div class="min-w-0">
<p class="display truncate text-xl transition-colors sm:text-2xl dark:group-hover:text-primary">
{r.name}
</p>
<p class="mono-label mt-1 text-muted-foreground">
{targetsOf(r)} targets · {targetsOf(r) > 0 ? 'live' : 'idle'}
</p>
</div>
<div class="col-span-2 flex gap-2 sm:col-span-1">
<Button
size="sm"
variant="outline"
class="flex-1 sm:flex-none"
onClick={() => navigate({ to: '/room', search: { room: r.name } })}
>
进入
</Button>
<Button
size="sm"
class="flex-1 sm:flex-none"
onClick={() => navigate({ to: '/publish', search: { room: r.name } })}
>
直播
</Button>
</div> </div>
<Badge variant={b.configured ? 'success' : 'warning'}>
{b.configured ? '已配置' : '未配置'}
</Badge>
</div> </div>
) </Reveal>
}}
</For>
</Show>
</div>
<Separator />
<div>
<p class="label mb-2">分发协议</p>
<div class="flex flex-wrap gap-1.5">
<For each={['WHIP', 'WHEP', 'HLS', 'FLV']}>
{(p) => (
<Badge variant="outline" class="font-mono">
{p}
</Badge>
)} )}
</For> </For>
</div> </div>
</div> </Show>
</div>
<div class="flex gap-2 rounded-md border bg-muted/50 p-3 text-xs leading-relaxed text-muted-foreground"> <aside class="min-w-0">
<ShieldCheck class="mt-px size-4 shrink-0 text-success" /> <Reveal>
<span>凭据由服务端代理注入,浏览器不持有 SFU 密钥。</span> <div class="relative border border-line bg-card/50 p-4 sm:p-5">
</div> <Corners />
</Card> <div class="flex items-center justify-between gap-2">
</section> <div class="flex items-center gap-2">
<RadioTower class="size-4 text-acid-lime" />
<h2 class="font-semibold">Signal Stack</h2>
</div>
<Badge variant="muted">{backends().length} 路</Badge>
</div>
<Show when={rooms().length > 0}> <div class="mt-4 space-y-2.5">
<section class="space-y-3"> <Show
<div class="flex items-end justify-between gap-3"> when={backends().length > 0}
<div> fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}
<h2 class="text-lg font-semibold">在线房间</h2> >
<p class="text-sm text-muted-foreground">当前已恢复的分发拓扑</p> <For each={backends()}>
{(b) => {
const Icon = backendIcon(b.kind)
return (
<div
class={`flex items-center justify-between gap-3 border p-3 transition-colors ${
b.configured
? 'border-line bg-background/50'
: 'border-line/60 bg-background/20 opacity-70'
}`}
>
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-9 shrink-0 items-center justify-center border border-line bg-background text-foreground/80">
<Icon class="size-4" />
</span>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{b.name}</p>
<p class="micro-label mt-0.5 text-muted-foreground">
{b.primary ? 'primary sfu' : 'standby'}
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2.5">
<Meter active={b.configured} />
<Badge variant={b.configured ? 'success' : 'muted'}>
{b.configured ? '在线' : '未配置'}
</Badge>
</div>
</div>
)
}}
</For>
</Show>
</div>
<div class="mt-4 border-t border-line/70 pt-4">
<p class="micro-label text-muted-foreground">distribution protocols</p>
<div class="mt-2.5 flex flex-wrap gap-1.5">
<For each={PROTOCOLS}>
{(p) => <Badge variant="outline" class="font-mono">{p}</Badge>}
</For>
</div>
</div>
<div class="mt-4 flex items-start gap-2.5 border border-line/70 bg-background/40 p-3">
<ShieldCheck class="mt-px size-4 shrink-0 text-success" />
<p class="text-xs leading-relaxed text-muted-foreground">
凭据由服务端代理注入,浏览器不持有 SFU 密钥。
</p>
</div>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/rooms' })}> </Reveal>
房间列表 </aside>
<ArrowRight class="size-4" /> </section>
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<For each={rooms()}>
{(r) => (
<Card class="space-y-3 p-4">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<LiveDot active={roomTargets(r) > 0} />
<span class="truncate font-medium">{r.name}</span>
</div>
<span class="shrink-0 font-mono text-xs text-muted-foreground">{roomTargets(r)} 路</span>
</div>
<div class="flex gap-2">
<Button size="sm" class="flex-1" onClick={() => navigate({ to: '/room', search: { room: r.name } })}>
进入
</Button>
<Button
variant="outline"
size="sm"
class="flex-1"
onClick={() => navigate({ to: '/publish', search: { room: r.name } })}
>
直播
</Button>
</div>
</Card>
)}
</For>
</div>
</section>
</Show>
</div> </div>
) )
} }

View File

@ -1,41 +1,27 @@
import { Component, For, onMount, Show } from 'solid-js' import { Component, For, onMount, Show } from 'solid-js'
import { Link, Outlet, useLocation, useNavigate } from '@tanstack/solid-router' import { Link, Outlet, useLocation } from '@tanstack/solid-router'
import { import { RadioTower } from 'lucide-solid'
CirclePlay,
Home,
MonitorPlay,
RadioTower,
Users,
Video,
} from 'lucide-solid'
import { refreshMe } from '../../lib/auth' import { refreshMe } from '../../lib/auth'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Badge, ThemeToggle } from '../../components/ui' import { ThemeToggle } from '../../components/ui'
interface NavItem { interface NavItem {
to: string to: string
idx: string
label: string label: string
icon: (p: { class?: string }) => any
activePaths?: string[] activePaths?: string[]
adminOnly?: boolean adminOnly?: boolean
} }
const NAV: NavItem[] = [ const NAV: NavItem[] = [
{ to: '/', label: '概览', icon: Home }, { to: '/', idx: '01', label: '概览' },
{ to: '/rooms', label: '房间', icon: Video }, { to: '/rooms', idx: '02', label: '房间' },
{ to: '/watch', label: '观看', icon: MonitorPlay, activePaths: ['/watch', '/room'] }, { to: '/watch', idx: '03', label: '观看', activePaths: ['/watch', '/room'] },
{ to: '/publish', label: '推流', icon: CirclePlay }, { to: '/publish', idx: '04', label: '推流' },
{ to: '/users', label: '用户', icon: Users, adminOnly: true }, { to: '/users', idx: '05', label: '用户', adminOnly: true },
] ]
function roleBadge(role: string) {
if (role === 'admin') return 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta'
if (role === 'publisher') return 'border-warning/30 bg-warning/10 text-warning'
return 'border-transparent bg-muted text-muted-foreground'
}
export const Layout: Component = () => { export const Layout: Component = () => {
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
onMount(async () => { onMount(async () => {
@ -43,75 +29,87 @@ export const Layout: Component = () => {
await permissions.refresh() await permissions.refresh()
}) })
const isActive = (item: NavItem) => { const isActive = (item: NavItem) => (item.activePaths ?? [item.to]).includes(location().pathname)
const paths = item.activePaths ?? [item.to]
return paths.includes(location().pathname) const navLinks = (h: string) => (
} <>
<For each={NAV}>
{(item) => (
<Show when={!item.adminOnly || permissions.can('user:manage')}>
<Link
to={item.to}
class={`nav-link flex ${h} shrink-0 items-center gap-1.5 font-mono text-[11px] tracking-[0.16em] uppercase whitespace-nowrap transition-colors ${
isActive(item)
? 'is-active text-foreground dark:text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span class="text-[9px] opacity-50">{item.idx}</span>
{item.label}
</Link>
</Show>
)}
</For>
</>
)
return ( return (
<div class="flex min-h-screen flex-col"> <div class="relative min-h-screen">
<header class="header-line sticky top-0 z-40 border-b bg-background/85 backdrop-blur-xl"> <div class="atmo-grid" aria-hidden="true" />
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3 px-4"> <div class="atmo-glow atmo-glow-a" aria-hidden="true" />
<div class="atmo-glow atmo-glow-b" aria-hidden="true" />
<div class="scanline-sweep" aria-hidden="true" />
<div class="noise" aria-hidden="true" />
<header class="sticky top-0 z-40 border-b border-line/80 bg-background/85 backdrop-blur-md">
<div
class="absolute inset-x-0 top-0 h-[3px]"
style="background: linear-gradient(90deg, hsl(var(--acid-lime)), hsl(var(--acid-cyan)) 38%, hsl(var(--acid-violet)) 70%, hsl(var(--acid-magenta)))"
/>
<div class="mx-auto flex h-12 max-w-[1400px] items-center gap-4 px-4 sm:px-6">
<Link to="/" class="flex min-w-0 items-center gap-2.5" aria-label="首页"> <Link to="/" class="flex min-w-0 items-center gap-2.5" aria-label="首页">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-acid-lime/30 bg-primary text-primary-foreground shadow-sm"> <span class="size-2 shrink-0 bg-acid-lime pulse-acid" />
<RadioTower class="size-4" /> <span class="display text-base whitespace-nowrap">LIVE·SFU</span>
</span> <span class="micro-label hidden text-muted-foreground md:inline">fan-out console</span>
<span class="truncate text-sm font-semibold sm:text-base">
直播 SFU <span class="acid-text">分流台</span>
</span>
</Link> </Link>
<div class="flex shrink-0 items-center gap-1.5 sm:gap-2"> <nav class="ml-auto hidden items-center gap-5 md:flex" aria-label="主导航">
{navLinks('h-6')}
</nav>
<div class="ml-auto flex shrink-0 items-center gap-2 md:ml-0">
<Show when={permissions.isAuthed()}> <Show when={permissions.isAuthed()}>
<span class="hidden items-center gap-2 rounded-md border bg-card py-1 pr-1 pl-1 sm:flex"> <span class="hidden items-center gap-1.5 border border-line bg-card/60 px-2 py-1 sm:flex">
<span class="flex size-5 items-center justify-center rounded-sm bg-primary/10 text-[11px] font-bold text-primary"> <span class="size-1.5 bg-acid-cyan" />
{(permissions.role()[0] ?? '').toUpperCase()} <span class="font-mono text-[10px] tracking-[0.14em] uppercase text-muted-foreground">
{permissions.role()}
</span> </span>
<Badge class={roleBadge(permissions.role())}>{permissions.role()}</Badge>
</span> </span>
</Show> </Show>
<ThemeToggle /> <ThemeToggle />
<Show when={!permissions.isAuthed()}> <Show when={!permissions.isAuthed()}>
<Link to="/login" class="btn btn-outline btn-sm"> <Link to="/login" class="btn btn-primary btn-sm">
登录 登录
</Link> </Link>
</Show> </Show>
</div> </div>
</div> </div>
<nav class="mx-auto max-w-6xl overflow-x-auto px-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <nav class="overflow-x-auto border-t border-line/60 [scrollbar-width:none] md:hidden [&::-webkit-scrollbar]:hidden" aria-label="主导航(移动端)">
<div class="flex items-center gap-1"> <div class="mx-auto flex max-w-[1400px] items-center gap-4 px-4">
<For each={NAV}> {navLinks('h-10')}
{(item) => (
<Show when={!item.adminOnly || permissions.can('user:manage')}>
<Link
to={item.to}
class={`flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors ${
isActive(item)
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<item.icon class="size-4" />
{item.label}
</Link>
</Show>
)}
</For>
</div> </div>
</nav> </nav>
</header> </header>
<main class="flex-1"> <main class="relative z-10">
<div class="mx-auto max-w-6xl px-4 py-6 sm:py-8"> <Outlet />
<Outlet />
</div>
</main> </main>
<footer class="border-t bg-background/70"> <footer class="relative z-10 mt-16 border-t border-line/80 bg-background/70 backdrop-blur-sm">
<div class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-2 px-4 py-4 text-xs text-muted-foreground sm:flex-row"> <div class="mx-auto flex max-w-[1400px] flex-col gap-1.5 px-4 py-4 font-mono text-[10px] tracking-[0.18em] uppercase text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span>Cloudflare Realtime · SRS · WHIP / WHEP / HLS</span> <span>Cloudflare Realtime · SRS · WHIP / WHEP / HLS / FLV</span>
<span class="font-mono">live-sfu fan-out console</span> <span>live-sfu fan-out console — 一路推流 多端扇出</span>
</div> </div>
</footer> </footer>
</div> </div>

View File

@ -4,7 +4,7 @@ import { LogIn, RadioTower } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { refreshMe } from '../../lib/auth' import { refreshMe } from '../../lib/auth'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Alert, Button, Card, Select, TextInput } from '../../components/ui' import { Alert, Button, Corners, Reveal, Select, TextInput } from '../../components/ui'
const DEMO_USERS = [ const DEMO_USERS = [
{ name: 'admin', role: '管理员', pass: 'Admin123!' }, { name: 'admin', role: '管理员', pass: 'Admin123!' },
@ -37,90 +37,111 @@ export const Login: Component = () => {
} }
return ( return (
<div class="mx-auto flex w-full max-w-md flex-col gap-4 py-4"> <div class="relative flex min-h-[calc(100vh-3rem)] items-center justify-center overflow-hidden px-4 py-10">
<div class="flex flex-col items-center gap-3 text-center"> <div
<span class="flex size-10 items-center justify-center rounded-lg border border-acid-lime/30 bg-primary text-primary-foreground shadow-sm"> class="ghost-text display pointer-events-none absolute -bottom-[7vw] left-1/2 -translate-x-1/2 text-[36vw] leading-none"
<RadioTower class="size-5" /> aria-hidden="true"
</span> >
<div> SFU
<h1 class="text-xl font-semibold">登录分流控制台</h1>
<p class="mt-1 text-sm text-muted-foreground">发布、订阅与房间管理需要登录</p>
</div>
</div> </div>
<Card class="p-6"> <div class="relative w-full max-w-md">
<form class="space-y-4" onSubmit={submit}> <Reveal>
<div class="space-y-1.5"> <div class="relative border border-line bg-card/70 p-6 backdrop-blur-sm sm:p-8">
<label class="label" for="login-username"> <Corners />
用户名 <div class="flex items-center gap-3">
</label> <span class="flex size-10 shrink-0 items-center justify-center border border-acid-lime/70 bg-acid-lime/15 text-acid-lime">
<TextInput <RadioTower class="size-5" />
id="login-username" </span>
value={username()} <div>
onInput={setUsername} <h1 class="display text-3xl">登录</h1>
placeholder="admin" <p class="micro-label mt-1.5 text-muted-foreground">fan-out console / auth</p>
autoComplete="username" </div>
class="h-9" </div>
/>
</div>
<div class="space-y-1.5">
<label class="label" for="login-password">
密码
</label>
<TextInput
id="login-password"
type="password"
value={password()}
onInput={setPassword}
placeholder="••••••••"
autoComplete="current-password"
class="h-9"
/>
</div>
<div class="space-y-1.5">
<label class="label">角色</label>
<Select
value={role()}
onChange={(v) => setRole(v as 'viewer' | 'publisher')}
options={[
{ value: 'viewer', label: '观看者 viewer' },
{ value: 'publisher', label: '发布者 publisher' },
]}
/>
</div>
<Button type="submit" disabled={busy()} class="w-full">
<LogIn class="size-4" />
{busy() ? '登录中…' : '登录'}
</Button>
</form>
<Show when={err()}>
<Alert tone="error" class="mt-4">
{err()}
</Alert>
</Show>
</Card>
<div class="rounded-lg border bg-card p-4"> <form class="mt-7 space-y-5" onSubmit={submit}>
<p class="label mb-2">演示账号</p> <div class="space-y-2">
<div class="grid gap-2 sm:grid-cols-3"> <label class="micro-label flex items-center justify-between text-muted-foreground" for="login-username">
<For each={DEMO_USERS}> 用户名 <span class="opacity-50">username</span>
{(u) => ( </label>
<button <TextInput
type="button" id="login-username"
class="flex flex-col items-start gap-0.5 rounded-md border bg-background/60 px-3 py-2 text-left transition-colors hover:bg-accent" value={username()}
onClick={() => { onInput={setUsername}
setUsername(u.name) placeholder="admin"
setPassword(u.pass) autoComplete="username"
setRole(u.role === '管理员' ? 'publisher' : 'viewer') class="h-10"
}} />
> </div>
<span class="text-sm font-medium">{u.name}</span> <div class="space-y-2">
<span class="text-xs text-muted-foreground">{u.role}</span> <label class="micro-label flex items-center justify-between text-muted-foreground" for="login-password">
<span class="font-mono text-[10px] text-muted-foreground/80">{u.pass}</span> 密码 <span class="opacity-50">password</span>
</button> </label>
)} <TextInput
</For> id="login-password"
</div> type="password"
value={password()}
onInput={setPassword}
placeholder="••••••••"
autoComplete="current-password"
class="h-10"
/>
</div>
<div class="space-y-2">
<label class="micro-label flex items-center justify-between text-muted-foreground">
角色 <span class="opacity-50">role</span>
</label>
<Select
value={role()}
onChange={(v) => setRole(v as 'viewer' | 'publisher')}
options={[
{ value: 'viewer', label: '观看者 viewer' },
{ value: 'publisher', label: '发布者 publisher' },
]}
class="w-full"
/>
</div>
<Button type="submit" disabled={busy()} class="h-10 w-full">
<LogIn class="size-4" />
{busy() ? '登录中…' : '登录'}
</Button>
</form>
<Show when={err()}>
<Alert tone="error" class="mt-4">
{err()}
</Alert>
</Show>
</div>
</Reveal>
<Reveal delay={1} class="mt-4">
<div class="border border-line/80 bg-card/50 p-4">
<p class="micro-label text-muted-foreground">demo accounts · 演示账号</p>
<div class="mt-3 grid gap-2 sm:grid-cols-3">
<For each={DEMO_USERS}>
{(u) => (
<button
type="button"
class="group border border-line/80 bg-background/50 px-3 py-2.5 text-left transition-colors hover:border-acid-lime/60 hover:bg-background"
onClick={() => {
setUsername(u.name)
setPassword(u.pass)
setRole(u.role === '管理员' ? 'publisher' : 'viewer')
}}
>
<span class="flex items-baseline justify-between gap-2">
<span class="font-mono text-xs font-medium">{u.name}</span>
<span class="size-1 shrink-0 rotate-45 bg-line transition-colors group-hover:bg-acid-lime" />
</span>
<span class="mt-0.5 block text-[11px] text-muted-foreground">{u.role}</span>
<span class="mt-1 block font-mono text-[10px] text-muted-foreground/70">{u.pass}</span>
</button>
)}
</For>
</div>
</div>
</Reveal>
</div> </div>
</div> </div>
) )

View File

@ -4,17 +4,16 @@ import {
CircleStop, CircleStop,
Cloud, Cloud,
FileVideo, FileVideo,
Link2,
MessageSquare, MessageSquare,
Server, Server,
Video, Video,
} from 'lucide-solid' } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Alert, Badge, Button, Card, CheckboxRow, LiveDot } from '../../components/ui' import { Alert, Badge, Button, CheckboxRow, Corners, LiveDot, Meter, Reveal } from '../../components/ui'
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku' import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
import { subscribeRoom } from '../../lib/sse' import { subscribeRoom } from '../../lib/sse'
import { subscribeChat, sendChat, broadcastChat, type ChatMessage } from '../../lib/chat' import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat'
import { whipPublish, cfPublish, toRTC } from '../../lib/webrtc' import { whipPublish, cfPublish, toRTC } from '../../lib/webrtc'
import type { BackendInfo, RoomEvent, StreamTarget } from '../../lib/types' import type { BackendInfo, RoomEvent, StreamTarget } from '../../lib/types'
@ -60,7 +59,7 @@ export const Publish: Component = () => {
}) })
const send = (text: string, color?: string) => { const send = (text: string, color?: string) => {
broadcastChat(room(), text, color).catch(() => {}) sendChat(room(), text, color).catch(() => {})
} }
const toggle = (kind: string) => setSelected((s) => ({ ...s, [kind]: !s[kind] })) const toggle = (kind: string) => setSelected((s) => ({ ...s, [kind]: !s[kind] }))
@ -126,19 +125,23 @@ export const Publish: Component = () => {
onCleanup(stopAll) onCleanup(stopAll)
return ( return (
<div class="space-y-6"> <div class="mx-auto max-w-[1400px] space-y-5 px-4 py-8 sm:px-6 sm:py-10">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <Reveal>
<div class="space-y-1.5"> <div class="flex flex-wrap items-end justify-between gap-4">
<h1 class="text-2xl font-semibold">开始直播</h1> <div>
<p class="text-sm text-muted-foreground"> <p class="mono-label text-muted-foreground">publish / {room()}</p>
房间 <span class="font-mono text-foreground">{room()}</span> · 选择分发后端,一路推流将扇出到所选 SFU。 <h1 class="display mt-2 text-4xl sm:text-5xl">开始直播</h1>
</p> <p class="mt-3 max-w-lg text-sm text-muted-foreground">
选择分发后端,一路推流将扇出到所选 SFU。
</p>
</div>
<div class="flex items-center gap-3 border border-line bg-card/60 px-4 py-2.5">
<LiveDot active={running()} />
<span class="mono-label">{running() ? '推流中 / live' : '待机 / standby'}</span>
<Meter active={running()} />
</div>
</div> </div>
<Badge variant={running() ? 'success' : 'outline'}> </Reveal>
<LiveDot active={running()} />
{running() ? '推流中' : '待机'}
</Badge>
</div>
<Show when={!permissions.can('room:publish')}> <Show when={!permissions.can('room:publish')}>
<Alert tone="warning"> <Alert tone="warning">
@ -150,145 +153,187 @@ export const Publish: Component = () => {
<Alert tone="error">{error()}</Alert> <Alert tone="error">{error()}</Alert>
</Show> </Show>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<Card class="space-y-3 p-5"> <Reveal delay={1} class="min-w-0">
<div class="flex items-center justify-between"> <div class="relative h-full border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center gap-2"> <Corners />
<Video class="size-4 text-primary" /> <div class="flex flex-wrap items-center justify-between gap-2">
<h3 class="font-semibold">本地预览</h3> <div class="flex items-center gap-2">
</div> <Video class="size-4 text-acid-lime" />
<Button variant="ghost" size="sm" onClick={() => setShowDanmaku((v) => !v)}> <h3 class="font-semibold">本地预览</h3>
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'} <span class="micro-label text-muted-foreground">preview</span>
</Button> </div>
<span class="font-mono text-xs text-muted-foreground"> <div class="flex items-center gap-3">
{stream() ? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频` : 'off'} <span class="font-mono text-[11px] text-muted-foreground">
</span> {stream()
</div> ? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频`
<div class="acid-frame relative aspect-video overflow-hidden rounded-md border bg-black"> : 'tracks · off'}
<video ref={(el) => (videoEl = el)} autoplay playsinline muted class="size-full" /> </span>
<Show when={showDanmaku()}> <Button variant="ghost" size="sm" onClick={() => setShowDanmaku((v) => !v)}>
<DanmakuOverlay messages={msgs} /> {showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
</Show> </Button>
<Show when={!stream()}>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 text-white/60">
<span class="flex size-12 items-center justify-center rounded-full border border-white/20 bg-white/5">
<Video class="size-5" />
</span>
<span class="text-xs">摄像头未开启</span>
</div> </div>
</Show>
</div>
<div class="flex flex-wrap gap-2">
<Button onClick={start} disabled={running() || !permissions.can('room:publish')}>
<Video class="size-4" />
开始推流
</Button>
<Button variant="danger" onClick={stopAll} disabled={!running()}>
<CircleStop class="size-4" />
停止推流
</Button>
</div>
</Card>
<Card class="space-y-3 p-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Server class="size-4 text-primary" />
<h3 class="font-semibold">分发后端</h3>
</div> </div>
<Badge variant="secondary">{backends().length} 路</Badge>
<div class="relative mt-4 aspect-video overflow-hidden border border-line/70 bg-black">
<video ref={(el) => (videoEl = el)} autoplay playsinline muted class="size-full" />
<Show when={showDanmaku()}>
<DanmakuOverlay messages={msgs} />
</Show>
<div class="pointer-events-none absolute top-3 left-3 flex flex-wrap items-center gap-1.5">
<Badge variant={running() ? 'success' : 'outline'} class="bg-black/50 shadow-sm backdrop-blur-sm">
<LiveDot active={running()} />
{running() ? 'LIVE' : 'STANDBY'}
</Badge>
</div>
<Show when={!stream()}>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2.5 text-white/60">
<span class="flex size-12 items-center justify-center border border-white/20 bg-white/5">
<Video class="size-5" />
</span>
<span class="micro-label text-white/50">camera off</span>
</div>
</Show>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<Button class="h-10" onClick={start} disabled={running() || !permissions.can('room:publish')}>
<Video class="size-4" />
开始推流
</Button>
<Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
<CircleStop class="size-4" />
停止推流
</Button>
</div>
</div> </div>
<div class="space-y-2"> </Reveal>
<Show when={backends().length > 0} fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}>
<For each={backends()}> <Reveal delay={2} class="min-w-0 self-start">
{(b) => { <div class="relative border border-line bg-card/50 p-4 sm:p-5">
const Icon = backendIcon(b.kind) <div class="flex items-center justify-between gap-2">
return ( <div class="flex items-center gap-2">
<div class="flex items-center justify-between gap-3 rounded-md border p-3"> <Server class="size-4 text-acid-lime" />
<div class="flex min-w-0 items-center gap-3"> <h3 class="font-semibold">分发后端</h3>
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-secondary text-foreground/70"> <span class="micro-label text-muted-foreground">routing</span>
<Icon class="size-4" /> </div>
</span> <Badge variant="muted">{backends().length} 路</Badge>
<div class="min-w-0"> </div>
<CheckboxRow
label={b.name} <div class="mt-4 space-y-2.5">
checked={!!selected()[b.kind]} <Show
onChange={() => toggle(b.kind)} when={backends().length > 0}
disabled={running()} fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}
/> >
<p class="mt-1 text-xs text-muted-foreground"> <For each={backends()}>
{backendLabel(b.kind)} · {b.primary ? '主 SFU' : '对照后端'} {(b) => {
</p> const Icon = backendIcon(b.kind)
return (
<div
class={`flex items-center justify-between gap-3 border p-3 transition-colors ${
b.configured ? 'border-line bg-background/50' : 'border-line/60 bg-background/20 opacity-70'
}`}
>
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-9 shrink-0 items-center justify-center border border-line bg-background text-foreground/80">
<Icon class="size-4" />
</span>
<div class="min-w-0">
<CheckboxRow
label={b.name}
checked={!!selected()[b.kind]}
onChange={() => toggle(b.kind)}
disabled={running()}
/>
<p class="micro-label mt-1.5 text-muted-foreground">
{backendLabel(b.kind)} · {b.primary ? '主 SFU' : '对照后端'}
</p>
</div>
</div> </div>
<Badge variant={b.configured ? 'success' : 'muted'}>
{b.configured ? '在线' : '未配置'}
</Badge>
</div> </div>
<Badge variant={b.configured ? 'success' : 'warning'}>{b.configured ? '已配置' : '未配置'}</Badge> )
</div> }}
) </For>
}} </Show>
</For> </div>
</Show>
<p class="mt-4 border-t border-line/70 pt-4 text-xs leading-relaxed text-muted-foreground">
推流凭证仅在服务端会话中生效,浏览器端不会出现 SFU 密钥。
</p>
</div> </div>
<p class="text-xs leading-relaxed text-muted-foreground"> </Reveal>
推流凭证仅在服务端会话中生效,浏览器端不会出现 SFU 密钥。
</p>
</Card>
</div> </div>
<Card class="space-y-3 p-5"> <Reveal delay={1}>
<div class="flex items-center gap-2"> <div class="relative border border-line bg-card/50 p-4 sm:p-5">
<Link2 class="size-4 text-primary" />
<h3 class="font-semibold">分发状态</h3>
</div>
<Show
when={targets().length > 0}
fallback={
<div class="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">尚未开始推流</div>
}
>
<div class="grid gap-2 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="space-y-2 rounded-md border p-3">
<div class="flex items-center justify-between gap-2">
<span class="flex min-w-0 items-center gap-2 text-sm font-medium">
{backendLabel(t.backend)}
</span>
<Badge variant="success">直播中</Badge>
</div>
<Show when={t.stream}>
<div class="flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs">
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.m3u8`} target="_blank">
<FileVideo class="size-3.5" />
HLS
</a>
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
<FileVideo class="size-3.5" />
FLV
</a>
</div>
</Show>
<Show when={t.session_id}>
<p class="truncate font-mono text-[11px] text-muted-foreground">session:{t.session_id}</p>
</Show>
</div>
)}
</For>
</div>
</Show>
</Card>
<Card class="flex flex-col gap-3 p-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<MessageSquare class="size-4 text-primary" /> <FileVideo class="size-4 text-acid-lime" />
<h3 class="font-semibold">房间弹幕 / 聊天</h3> <h3 class="font-semibold">分发状态</h3>
<span class="micro-label text-muted-foreground">fan-out status</span>
</div> </div>
<Badge variant="muted">{msgs().length}</Badge> <Show
when={targets().length > 0}
fallback={
<div class="mt-4 border border-dashed border-line px-5 py-8 text-center text-sm text-muted-foreground">
尚未开始推流
</div>
}
>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="relative border border-line/80 bg-background/40 p-4">
<span class="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-acid-lime" aria-hidden="true" />
<div class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-medium">{backendLabel(t.backend)}</span>
<Badge variant="success">
<LiveDot active />
直播中
</Badge>
</div>
<Show when={t.stream}>
<div class="mt-3 flex flex-wrap gap-2">
<a class="mono-chip" href={`/live/${t.stream}.m3u8`} target="_blank" rel="noreferrer">
<FileVideo class="size-3" />
HLS
</a>
<a class="mono-chip" href={`/live/${t.stream}.flv`} target="_blank" rel="noreferrer">
<FileVideo class="size-3" />
FLV
</a>
</div>
</Show>
<Show when={t.session_id}>
<p class="mt-2 truncate font-mono text-[10px] text-muted-foreground">
session · {t.session_id}
</p>
</Show>
</div>
)}
</For>
</div>
</Show>
</div> </div>
<div class="h-80 min-h-0"> </Reveal>
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
<Reveal delay={2}>
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<MessageSquare class="size-4 text-acid-lime" />
<h3 class="font-semibold">房间弹幕 / 聊天</h3>
<span class="micro-label text-muted-foreground">danmaku</span>
</div>
<Badge variant="muted">{msgs().length}</Badge>
</div>
<div class="mt-4 h-80 min-h-0">
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
</div>
</div> </div>
</Card> </Reveal>
</div> </div>
) )
} }

View File

@ -1,9 +1,9 @@
import { Component, createSignal, createEffect, For, Show } from 'solid-js' import { Component, createSignal, createEffect, For, Show } from 'solid-js'
import { useNavigate } from '@tanstack/solid-router' import { useNavigate } from '@tanstack/solid-router'
import { ArrowRight, DoorOpen, Plus, Radio } from 'lucide-solid' import { ArrowRight, DoorOpen, Plus } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Button, Card, LiveDot, TextInput } from '../../components/ui' import { Button, Corners, LiveDot, Reveal } from '../../components/ui'
import type { Room } from '../../lib/types' import type { Room } from '../../lib/types'
export const Rooms: Component = () => { export const Rooms: Component = () => {
@ -39,44 +39,55 @@ export const Rooms: Component = () => {
const enter = (room: string) => navigate({ to: '/room', search: { room } }) const enter = (room: string) => navigate({ to: '/room', search: { room } })
return ( return (
<div class="space-y-6"> <div class="mx-auto max-w-[1400px] space-y-10 px-4 py-8 sm:px-6 sm:py-10">
<div class="space-y-1.5"> <Reveal>
<h1 class="text-2xl font-semibold">房间</h1> <div>
<p class="text-sm text-muted-foreground">创建或进入房间,房间内可观看、发弹幕与查看分发状态。</p> <p class="mono-label text-muted-foreground">rooms / registry</p>
</div> <h1 class="display mt-2 text-4xl sm:text-5xl">房间</h1>
<p class="mt-3 max-w-lg text-sm text-muted-foreground">
创建或进入房间,房间内可观看、发弹幕与查看分发状态。
</p>
</div>
</Reveal>
<Card class="space-y-3 p-5"> <Reveal delay={1}>
<div class="flex items-center gap-2"> <div class="relative max-w-2xl border border-line bg-card/60 p-3 sm:p-4">
<Plus class="size-4 text-primary" /> <Corners />
<h3 class="font-semibold">创建房间</h3> <div class="flex flex-col gap-2 sm:flex-row">
<div class="flex h-10 flex-1 items-center gap-2.5 border border-line bg-background/70 px-3 transition-colors focus-within:border-ring">
<Plus class="size-4 shrink-0 text-muted-foreground" />
<input
class="field-mono h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
placeholder="房间名,如 demo"
value={name()}
onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Enter') create()
}}
aria-label="房间名"
/>
</div>
<Button class="h-10" onClick={create} disabled={busy()}>
<ArrowRight class="size-4" />
创建并进入
</Button>
</div>
</div> </div>
<div class="flex flex-col gap-2 sm:flex-row"> </Reveal>
<TextInput
value={name()}
onInput={setName}
placeholder="房间名,如 demo"
class="sm:max-w-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') create()
}}
/>
<Button onClick={create} disabled={busy()}>
<ArrowRight class="size-4" />
创建并进入
</Button>
</div>
</Card>
<section class="space-y-3"> <section class="space-y-3">
<div class="flex items-center gap-2"> <Reveal>
<Radio class="size-4 text-primary" /> <div class="flex items-center justify-between gap-3 border-b border-line pb-3">
<h2 class="text-lg font-semibold">房间列表</h2> <h2 class="font-semibold">房间列表</h2>
</div> <span class="mono-label text-muted-foreground">{rooms().length} rooms</span>
</div>
</Reveal>
<Show <Show
when={rooms().length > 0} when={rooms().length > 0}
fallback={ fallback={
<div class="flex flex-col items-center gap-3 rounded-lg border bg-card px-6 py-10 text-center"> <div class="flex flex-col items-center gap-3 border border-dashed border-line px-6 py-12 text-center">
<span class="flex size-10 items-center justify-center rounded-full bg-secondary text-muted-foreground"> <span class="flex size-10 items-center justify-center border border-line bg-card text-muted-foreground">
<DoorOpen class="size-5" /> <DoorOpen class="size-5" />
</span> </span>
<div> <div>
@ -86,24 +97,31 @@ export const Rooms: Component = () => {
</div> </div>
} }
> >
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div>
<For each={rooms()}> <For each={rooms()}>
{(r) => ( {(r, i) => (
<Card class="flex flex-col gap-3 p-4"> <Reveal delay={Math.min(i() + 1, 5)}>
<div class="flex items-center justify-between gap-2"> <div class="group -mx-3 grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-line/80 py-4 transition-colors hover:bg-accent/40 sm:mx-0 sm:-mx-4 sm:grid-cols-[3rem_auto_minmax(0,1fr)_auto] sm:gap-4 sm:px-4">
<div class="flex min-w-0 items-center gap-2"> <span class="hidden font-mono text-xs text-muted-foreground/70 sm:block">
<LiveDot active={(r.targets?.length ?? 0) > 0} /> {String(i() + 1).padStart(2, '0')}
<span class="truncate font-medium">{r.name}</span>
</div>
<span class="shrink-0 font-mono text-xs text-muted-foreground">
{r.targets?.length ?? 0} 路分发
</span> </span>
<LiveDot active={(r.targets?.length ?? 0) > 0} />
<div class="min-w-0">
<p class="display truncate text-xl transition-colors sm:text-2xl dark:group-hover:text-primary">
{r.name}
</p>
<p class="mono-label mt-1 text-muted-foreground">
{r.targets?.length ?? 0} 路分发 · {(r.targets?.length ?? 0) > 0 ? 'live' : 'idle'}
</p>
</div>
<div class="col-span-2 sm:col-span-1">
<Button variant="outline" class="h-9 w-full sm:w-auto" onClick={() => enter(r.name)}>
进入房间
<ArrowRight class="size-4" />
</Button>
</div>
</div> </div>
<Button variant="outline" class="w-full" onClick={() => enter(r.name)}> </Reveal>
进入房间
<ArrowRight class="size-4" />
</Button>
</Card>
)} )}
</For> </For>
</div> </div>

View File

@ -2,7 +2,7 @@ import { Component, createSignal, For, Show, createEffect } from 'solid-js'
import { ShieldAlert, ShieldCheck, UserRound } from 'lucide-solid' import { ShieldAlert, ShieldCheck, UserRound } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Alert, Badge, Card, Select } from '../../components/ui' import { Alert, Badge, Reveal, Select } from '../../components/ui'
import type { Role, UserInfo } from '../../lib/types' import type { Role, UserInfo } from '../../lib/types'
const ROLES: Role[] = ['viewer', 'publisher', 'admin'] const ROLES: Role[] = ['viewer', 'publisher', 'admin']
@ -11,22 +11,22 @@ const ROLE_META: Record<Role, { label: string; badge: string; avatar: string }>
admin: { admin: {
label: '管理员', label: '管理员',
badge: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta', badge: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta',
avatar: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta', avatar: 'border-acid-magenta/40 bg-acid-magenta/10 text-acid-magenta',
}, },
publisher: { publisher: {
label: '发布者', label: '发布者',
badge: 'border-warning/30 bg-warning/10 text-warning', badge: 'border-warning/30 bg-warning/10 text-warning',
avatar: 'border-warning/30 bg-warning/10 text-warning', avatar: 'border-warning/40 bg-warning/10 text-warning',
}, },
viewer: { viewer: {
label: '观看者', label: '观看者',
badge: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan', badge: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan',
avatar: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan', avatar: 'border-acid-cyan/40 bg-acid-cyan/10 text-acid-cyan',
}, },
guest: { guest: {
label: '访客', label: '访客',
badge: 'border-transparent bg-muted text-muted-foreground', badge: 'border-line bg-transparent text-muted-foreground',
avatar: 'border-transparent bg-muted text-muted-foreground', avatar: 'border-line bg-muted text-muted-foreground',
}, },
} }
@ -52,11 +52,16 @@ export const Users: Component = () => {
} }
return ( return (
<div class="space-y-6"> <div class="mx-auto max-w-[1400px] space-y-8 px-4 py-8 sm:px-6 sm:py-10">
<div class="space-y-1.5"> <Reveal>
<h1 class="text-2xl font-semibold">用户与角色</h1> <div>
<p class="text-sm text-muted-foreground">基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。</p> <p class="mono-label text-muted-foreground">users / rbac</p>
</div> <h1 class="display mt-2 text-4xl sm:text-5xl">用户与角色</h1>
<p class="mt-3 max-w-lg text-sm text-muted-foreground">
基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。
</p>
</div>
</Reveal>
<Show <Show
when={permissions.can('user:list')} when={permissions.can('user:list')}
@ -69,60 +74,62 @@ export const Users: Component = () => {
</Alert> </Alert>
} }
> >
<Card> <Reveal delay={1}>
<Show when={err()}> <div class="border border-line bg-card/50">
<Alert tone="error" class="rounded-none border-x-0 border-t-0"> <Show when={err()}>
{err()} <Alert tone="error" class="border-x-0 border-t-0">
</Alert> {err()}
</Show> </Alert>
<div class="hidden grid-cols-[1fr_auto_auto] gap-4 border-b px-5 py-3 text-xs font-medium text-muted-foreground sm:grid"> </Show>
<span>用户名</span> <div class="hidden grid-cols-[minmax(0,1fr)_8rem_10rem] gap-4 border-b border-line/80 px-5 py-3 sm:grid">
<span class="w-28">当前角色</span> <span class="micro-label text-muted-foreground">username</span>
<span class="w-32 text-right">操作</span> <span class="micro-label text-muted-foreground">role</span>
</div> <span class="micro-label text-right text-muted-foreground">action</span>
<For each={users()}> </div>
{(u, i) => ( <For each={users()}>
<div {(u, i) => (
class={`grid grid-cols-1 items-center gap-3 px-5 py-3 sm:grid-cols-[1fr_auto_auto] sm:gap-4 ${ <div
i() < users().length - 1 ? 'border-b' : '' class={`grid grid-cols-1 items-center gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_8rem_10rem] sm:gap-4 ${
}`} i() < users().length - 1 ? 'border-b border-line/70' : ''
> }`}
<div class="flex min-w-0 items-center gap-3"> >
<span <div class="flex min-w-0 items-center gap-3">
class={`flex size-8 shrink-0 items-center justify-center rounded-md border text-xs font-bold ${ROLE_META[u.role].avatar}`} <span
> class={`flex size-8 shrink-0 items-center justify-center border font-mono text-xs font-bold ${ROLE_META[u.role].avatar}`}
{(u.username[0] ?? '?').toUpperCase()} >
</span> {(u.username[0] ?? '?').toUpperCase()}
<div class="min-w-0"> </span>
<p class="truncate text-sm font-medium">{u.username}</p> <div class="min-w-0">
<p class="font-mono text-[11px] text-muted-foreground">{ROLE_META[u.role].label}</p> <p class="truncate font-mono text-sm font-medium">{u.username}</p>
<p class="micro-label mt-1 text-muted-foreground">{ROLE_META[u.role].label}</p>
</div>
</div>
<Badge class={ROLE_META[u.role].badge}>{u.role}</Badge>
<div class="flex items-center justify-end">
<Show
when={permissions.can('user:manage')}
fallback={<UserRound class="size-4 text-muted-foreground/50" />}
>
<Select
class="w-32"
value={u.role}
aria-label={`调整 ${u.username} 的角色`}
onChange={(v) => change(u.username, v as Role)}
options={ROLES.map((r) => ({ value: r, label: r }))}
/>
</Show>
</div> </div>
</div> </div>
<Badge class={ROLE_META[u.role].badge}>{u.role}</Badge> )}
<div class="flex items-center justify-end gap-2"> </For>
<Show <Show when={users().length === 0 && !err()}>
when={permissions.can('user:manage')} <div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground">
fallback={<UserRound class="size-4 text-muted-foreground/60" />} <ShieldCheck class="size-4 text-success" />
> 暂无用户数据
<Select
class="w-32"
value={u.role}
aria-label={`调整 ${u.username} 的角色`}
onChange={(v) => change(u.username, v as Role)}
options={ROLES.map((r) => ({ value: r, label: r }))}
/>
</Show>
</div>
</div> </div>
)} </Show>
</For> </div>
<Show when={users().length === 0 && !err()}> </Reveal>
<div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground">
<ShieldCheck class="size-4 text-success" />
暂无用户数据
</div>
</Show>
</Card>
</Show> </Show>
</div> </div>
) )

View File

@ -5,7 +5,6 @@ import {
Eye, Eye,
EyeOff, EyeOff,
FileVideo, FileVideo,
Link2,
MessageSquare, MessageSquare,
MonitorPlay, MonitorPlay,
Radio, Radio,
@ -14,7 +13,7 @@ import {
} from 'lucide-solid' } from 'lucide-solid'
import { api } from '../../lib/api' import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions' import { permissions } from '../../lib/permissions'
import { Alert, Badge, Button, Card, LiveDot, Segmented } from '../../components/ui' import { Alert, Badge, Button, Corners, LiveDot, Reveal, Segmented } from '../../components/ui'
import { subscribeRoom } from '../../lib/sse' import { subscribeRoom } from '../../lib/sse'
import { whepSubscribe, cfSubscribe, toRTC } from '../../lib/webrtc' import { whepSubscribe, cfSubscribe, toRTC } from '../../lib/webrtc'
import { playHLS } from '../../lib/hls' import { playHLS } from '../../lib/hls'
@ -108,133 +107,167 @@ function RoomHub(props: { room: string }) {
} }
return ( return (
<div class="space-y-6"> <div class="mx-auto max-w-[1400px] space-y-5 px-4 py-8 sm:px-6 sm:py-10">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> <Reveal>
<div class="space-y-1.5"> <div class="flex flex-wrap items-end justify-between gap-4">
<h1 class="text-2xl font-semibold">房间 <span class="font-mono">{room()}</span></h1> <div class="min-w-0">
<p class="text-sm text-muted-foreground">观看、发弹幕与查看分发状态。</p> <p class="mono-label text-muted-foreground">watch / room</p>
<h1 class="display mt-2 truncate text-4xl sm:text-5xl">{room()}</h1>
<p class="mt-3 text-sm text-muted-foreground">观看、发弹幕与查看分发状态。</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2.5 border border-line bg-card/60 px-3.5 py-2.5">
<LiveDot active={targets().length > 0} />
<span class="mono-label">
{targets().length} targets · {targets().length > 0 ? 'live' : 'idle'}
</span>
</div>
<Show when={permissions.can('room:publish')}>
<Button
variant="outline"
class="h-10"
onClick={() => navigate({ to: '/publish', search: { room: room() } })}
>
<Video class="size-4" />
开始直播到该房间
</Button>
</Show>
</div>
</div> </div>
<Show when={permissions.can('room:publish')}> </Reveal>
<Button variant="outline" onClick={() => navigate({ to: '/publish', search: { room: room() } })}>
<Video class="size-4" />
开始直播到该房间
</Button>
</Show>
</div>
<Show when={error()}> <Show when={error()}>
<Alert tone="error">{error()}</Alert> <Alert tone="error">{error()}</Alert>
</Show> </Show>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div class="grid gap-4 lg:grid-cols-3">
<Card class="space-y-3 p-4 lg:col-span-2 lg:p-5"> <Reveal delay={1} class="min-w-0 lg:col-span-2">
<div class="flex items-center justify-between gap-3"> <div class="relative h-full border border-line bg-card/50 p-4 sm:p-5">
<Segmented <Corners />
value={mode()} <div class="flex flex-wrap items-center justify-between gap-3">
onChange={(v) => setMode(v)} <Segmented
options={[ value={mode()}
{ value: 'cloudflare', label: 'Cloudflare', icon: <Cloud class="size-3.5" /> }, onChange={(v) => setMode(v)}
{ value: 'srs-whep', label: 'SRS WHEP', icon: <Radio class="size-3.5" /> }, options={[
{ value: 'srs-hls', label: 'SRS HLS', icon: <MonitorPlay class="size-3.5" /> }, { value: 'cloudflare', label: 'Cloudflare', icon: <Cloud class="size-3.5" /> },
]} { value: 'srs-whep', label: 'SRS WHEP', icon: <Radio class="size-3.5" /> },
/> { value: 'srs-hls', label: 'SRS HLS', icon: <MonitorPlay class="size-3.5" /> },
<span class="hidden font-mono text-[11px] text-muted-foreground sm:block">fan-out · {MODE_LABEL[mode()]}</span> ]}
</div> />
<span class="hidden font-mono text-[11px] text-muted-foreground sm:block">
<div class="acid-frame relative aspect-video overflow-hidden rounded-md border bg-black"> fan-out · {MODE_LABEL[mode()]}
<video ref={(el) => (videoEl = el)} autoplay playsinline class="size-full" /> </span>
<Show when={showDanmaku()}>
<DanmakuOverlay messages={msgs} />
</Show>
<div class="pointer-events-none absolute top-3 left-3 flex flex-wrap items-center gap-1.5">
<Badge variant={running() ? 'success' : 'outline'} class="shadow-sm">
<LiveDot active={running()} />
{running() ? '连接中' : '未连接'}
</Badge>
<Badge variant="secondary" class="font-mono shadow-sm">
{MODE_LABEL[mode()]}
</Badge>
</div> </div>
<Show when={!running()}>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 text-white/60"> <div class="relative mt-4 aspect-video overflow-hidden border border-line/70 bg-black">
<span class="flex size-12 items-center justify-center rounded-full border border-white/20 bg-white/5"> <video ref={(el) => (videoEl = el)} autoplay playsinline class="size-full" />
<MonitorPlay class="size-5" /> <Show when={showDanmaku()}>
</span> <DanmakuOverlay messages={msgs} />
<span class="text-xs">等待接入</span> </Show>
<div class="pointer-events-none absolute top-3 left-3 flex flex-wrap items-center gap-1.5">
<Badge variant={running() ? 'success' : 'outline'} class="bg-black/50 shadow-sm backdrop-blur-sm">
<LiveDot active={running()} />
{running() ? 'CONNECTING' : 'OFFLINE'}
</Badge>
<Badge variant="secondary" class="border-line bg-black/50 text-white/90 shadow-sm backdrop-blur-sm">
{MODE_LABEL[mode()]}
</Badge>
</div> </div>
</Show> <Show when={!running()}>
</div> <div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2.5 text-white/60">
<span class="flex size-12 items-center justify-center border border-white/20 bg-white/5">
<div class="flex flex-wrap gap-2"> <MonitorPlay class="size-5" />
<Button onClick={start} disabled={running()}> </span>
<PlayIcon /> <span class="micro-label text-white/50">standby · 等待接入</span>
开始观看 </div>
</Button> </Show>
<Button variant="danger" onClick={stopAll} disabled={!running()}> </div>
<Square class="size-4" />
停止 <div class="mt-4 flex flex-wrap gap-2">
</Button> <Button class="h-10" onClick={start} disabled={running()}>
<Button variant="ghost" onClick={() => setShowDanmaku((s) => !s)}> <PlayIcon />
{showDanmaku() ? <EyeOff class="size-4" /> : <Eye class="size-4" />} 开始观看
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'} </Button>
</Button> <Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
</div> <Square class="size-4" />
</Card> 停止
</Button>
<Card class="flex h-[28rem] flex-col space-y-3 p-4 lg:p-5"> <Button variant="ghost" class="h-10" onClick={() => setShowDanmaku((s) => !s)}>
<div class="flex items-center justify-between"> {showDanmaku() ? <EyeOff class="size-4" /> : <Eye class="size-4" />}
<div class="flex items-center gap-2"> {showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
<MessageSquare class="size-4 text-primary" /> </Button>
<h3 class="font-semibold">弹幕 / 聊天</h3>
</div> </div>
<Badge variant="secondary">{msgs().length}</Badge>
</div> </div>
<div class="min-h-0 flex-1"> </Reveal>
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
<Reveal delay={2} class="min-w-0">
<div class="relative flex h-full min-h-[24rem] flex-col border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<MessageSquare class="size-4 text-acid-lime" />
<h3 class="font-semibold">弹幕 / 聊天</h3>
<span class="micro-label hidden text-muted-foreground sm:inline">danmaku</span>
</div>
<Badge variant="muted">{msgs().length}</Badge>
</div>
<div class="mt-4 min-h-0 flex-1">
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
</div>
</div> </div>
</Card> </Reveal>
</div> </div>
<Card class="space-y-3 p-5"> <Reveal delay={1}>
<div class="flex items-center gap-2"> <div class="relative border border-line bg-card/50 p-4 sm:p-5">
<Link2 class="size-4 text-primary" /> <div class="flex items-center gap-2">
<h3 class="font-semibold">分发状态</h3> <FileVideo class="size-4 text-acid-lime" />
</div> <h3 class="font-semibold">分发状态</h3>
<Show <span class="micro-label text-muted-foreground">fan-out status</span>
when={targets().length > 0}
fallback={
<div class="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">房间暂无直播</div>
}
>
<div class="grid gap-2 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="space-y-2 rounded-md border p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">{backendText(t.backend)}</span>
<Badge variant="success">直播中</Badge>
</div>
<Show when={t.stream}>
<div class="flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs">
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.m3u8`} target="_blank">
<FileVideo class="size-3.5" />
HLS
</a>
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
<FileVideo class="size-3.5" />
FLV
</a>
</div>
</Show>
<Show when={t.session_id}>
<p class="truncate font-mono text-[11px] text-muted-foreground">session:{t.session_id}</p>
</Show>
</div>
)}
</For>
</div> </div>
</Show> <Show
</Card> when={targets().length > 0}
fallback={
<div class="mt-4 border border-dashed border-line px-5 py-8 text-center text-sm text-muted-foreground">
房间暂无直播
</div>
}
>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="relative border border-line/80 bg-background/40 p-4">
<span class="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-acid-lime" aria-hidden="true" />
<div class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-medium">{backendText(t.backend)}</span>
<Badge variant="success">
<LiveDot active />
直播中
</Badge>
</div>
<Show when={t.stream}>
<div class="mt-3 flex flex-wrap gap-2">
<a class="mono-chip" href={`/live/${t.stream}.m3u8`} target="_blank" rel="noreferrer">
<FileVideo class="size-3" />
HLS
</a>
<a class="mono-chip" href={`/live/${t.stream}.flv`} target="_blank" rel="noreferrer">
<FileVideo class="size-3" />
FLV
</a>
</div>
</Show>
<Show when={t.session_id}>
<p class="mt-2 truncate font-mono text-[10px] text-muted-foreground">
session · {t.session_id}
</p>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
</Reveal>
</div> </div>
) )
} }

View File

@ -27,7 +27,7 @@ export function DanmakuOverlay(props: { messages: () => ChatMessage[] }) {
const top = tops[lane % tops.length] const top = tops[lane % tops.length]
lane++ lane++
const dur = 7 + Math.random() * 4 const dur = 7 + Math.random() * 4
const item: Active = { key: ++seq, text: (last.host ? '主播:' : '') + last.message, color: last.color || '#f1f5f9', top, dur } const item: Active = { key: ++seq, text: last.message, color: last.color || '#f1f5f9', top, dur }
setItems((p) => { setItems((p) => {
const next = [...p, item] const next = [...p, item]
return next.length > 50 ? next.slice(next.length - 50) : next return next.length > 50 ? next.slice(next.length - 50) : next
@ -59,9 +59,9 @@ function measureTime(ts: number) {
} }
function roleColor(role: string) { function roleColor(role: string) {
if (role === 'admin') return 'text-acid-magenta border-acid-magenta/30 bg-acid-magenta/10' if (role === 'admin') return 'border-acid-magenta/40 bg-acid-magenta/10 text-acid-magenta'
if (role === 'publisher') return 'text-warning border-warning/30 bg-warning/10' if (role === 'publisher') return 'border-warning/40 bg-warning/10 text-warning'
return 'text-acid-cyan border-acid-cyan/30 bg-acid-cyan/10' return 'border-acid-cyan/40 bg-acid-cyan/10 text-acid-cyan'
} }
// 聊天/弹幕侧栏:消息列表 + 发送框(权限由 canChat 控制) // 聊天/弹幕侧栏:消息列表 + 发送框(权限由 canChat 控制)
@ -85,22 +85,19 @@ export function ChatPanel(props: {
<Show when={props.messages().length > 0}> <Show when={props.messages().length > 0}>
<For each={props.messages()}> <For each={props.messages()}>
{(m) => ( {(m) => (
<div class="flex items-start gap-2"> <div class="flex items-start gap-2.5">
<span <span
class={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-[11px] font-bold ${roleColor( class={`mt-0.5 flex size-6 shrink-0 items-center justify-center border font-mono text-[10px] font-bold ${roleColor(
String(m.role), String(m.role),
)}`} )}`}
> >
{(m.user[0] ?? '?').toUpperCase()} {(m.user[0] ?? '?').toUpperCase()}
</span> </span>
<div class="min-w-0"> <div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-1.5"> <div class="flex flex-wrap items-baseline gap-x-2">
<span class="text-xs font-medium">{m.user}</span> <span class="font-mono text-xs font-medium">{m.user}</span>
<span class="text-[11px] text-muted-foreground">{m.role}</span> <span class="micro-label text-[9px] text-muted-foreground">{String(m.role)}</span>
<Show when={m.host}> <span class="font-mono text-[9px] text-muted-foreground/60">{measureTime(m.ts)}</span>
<span class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">主播</span>
</Show>
<span class="font-mono text-[10px] text-muted-foreground/70">{measureTime(m.ts)}</span>
</div> </div>
<p class="mt-0.5 text-sm leading-snug break-words">{m.message}</p> <p class="mt-0.5 text-sm leading-snug break-words">{m.message}</p>
</div> </div>
@ -109,20 +106,21 @@ export function ChatPanel(props: {
</For> </For>
</Show> </Show>
<Show when={props.messages().length === 0}> <Show when={props.messages().length === 0}>
<div class="flex h-full min-h-24 flex-col items-center justify-center gap-2 text-center"> <div class="flex h-full min-h-24 flex-col items-center justify-center gap-2.5 text-center">
<MessageSquare class="size-5 text-muted-foreground/50" /> <MessageSquare class="size-5 text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">还没有弹幕</p> <p class="text-sm text-muted-foreground">还没有弹幕</p>
<p class="micro-label text-muted-foreground/50">danmaku channel idle</p>
</div> </div>
</Show> </Show>
</div> </div>
<Show <Show
when={props.canChat} when={props.canChat}
fallback={<p class="border-t p-3 text-xs text-muted-foreground">登录后可在房间内发送弹幕</p>} fallback={<p class="border-t border-line/80 p-3 text-xs text-muted-foreground">登录后可在房间内发送弹幕</p>}
> >
<div class="flex gap-2 border-t p-2"> <div class="flex gap-2 border-t border-line/80 p-2.5">
<input <input
class="field h-9 text-sm" class="field field-mono h-9 flex-1 text-sm"
placeholder="发条弹幕" placeholder="发条弹幕…"
value={text()} value={text()}
aria-label="弹幕内容" aria-label="弹幕内容"
onInput={(e) => setText((e.currentTarget as HTMLInputElement).value)} onInput={(e) => setText((e.currentTarget as HTMLInputElement).value)}

View File

@ -1,4 +1,4 @@
import { For, Show, type JSX } from 'solid-js' import { For, onCleanup, onMount, Show, type JSX } from 'solid-js'
import { import {
Check, Check,
ChevronDown, ChevronDown,
@ -134,7 +134,7 @@ export function Select(props: {
return ( return (
<div class={`relative ${props.class ?? ''}`}> <div class={`relative ${props.class ?? ''}`}>
<select <select
class="field appearance-none pr-8" class="field field-mono h-9 appearance-none pr-8 text-xs"
value={props.value} value={props.value}
disabled={props.disabled} disabled={props.disabled}
aria-label={props['aria-label']} aria-label={props['aria-label']}
@ -151,12 +151,12 @@ export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'wa
const BADGE_TONE: Record<BadgeVariant, string> = { const BADGE_TONE: Record<BadgeVariant, string> = {
default: 'border-transparent bg-primary text-primary-foreground', default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground', secondary: 'border-line bg-secondary text-secondary-foreground',
outline: 'text-foreground', outline: 'border-line text-foreground',
success: 'border-success/25 bg-success/10 text-success', success: 'border-success/30 bg-success/10 text-success',
warning: 'border-warning/25 bg-warning/10 text-warning', warning: 'border-warning/30 bg-warning/10 text-warning',
danger: 'border-destructive/25 bg-destructive/10 text-destructive', danger: 'border-destructive/30 bg-destructive/10 text-destructive',
muted: 'border-transparent bg-muted text-muted-foreground', muted: 'border-line bg-transparent text-muted-foreground',
} }
export function Badge(props: { variant?: BadgeVariant; class?: string; children: JSX.Element }) { export function Badge(props: { variant?: BadgeVariant; class?: string; children: JSX.Element }) {
@ -165,19 +165,19 @@ export function Badge(props: { variant?: BadgeVariant; class?: string; children:
const ALERT_TONE = { const ALERT_TONE = {
error: { error: {
box: 'border-destructive/30 bg-destructive/8 text-destructive', box: 'border-destructive/40 bg-destructive/10 text-destructive',
icon: CircleAlert, icon: CircleAlert,
}, },
warning: { warning: {
box: 'border-warning/30 bg-warning/10 text-warning', box: 'border-warning/40 bg-warning/10 text-warning',
icon: TriangleAlert, icon: TriangleAlert,
}, },
success: { success: {
box: 'border-success/30 bg-success/10 text-success', box: 'border-success/40 bg-success/10 text-success',
icon: CircleCheck, icon: CircleCheck,
}, },
info: { info: {
box: 'border-acid-cyan/30 bg-acid-cyan/8 text-accent-foreground', box: 'border-acid-cyan/40 bg-acid-cyan/10 text-accent-foreground',
icon: Info, icon: Info,
}, },
} }
@ -193,7 +193,7 @@ export function Alert(props: {
return ( return (
<div <div
role={tone === 'error' ? 'alert' : undefined} role={tone === 'error' ? 'alert' : undefined}
class={`flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm ${style.box} ${props.class ?? ''}`} class={`flex items-start gap-2.5 border px-3 py-2.5 text-sm ${style.box} ${props.class ?? ''}`}
> >
<Icon class="mt-px size-4 shrink-0" /> <Icon class="mt-px size-4 shrink-0" />
<div class="min-w-0 flex-1">{props.children}</div> <div class="min-w-0 flex-1">{props.children}</div>
@ -204,8 +204,8 @@ export function Alert(props: {
export function LiveDot(props: { active?: boolean; class?: string }) { export function LiveDot(props: { active?: boolean; class?: string }) {
return ( return (
<span <span
class={`relative inline-flex size-2 shrink-0 rounded-full ${ class={`inline-block size-2 shrink-0 ${
props.active ? 'bg-success shadow-[0_0_0_3px_hsl(var(--success)/0.14)]' : 'bg-muted-foreground/40' props.active ? 'bg-success pulse' : 'bg-muted-foreground/40'
} ${props.class ?? ''}`} } ${props.class ?? ''}`}
/> />
) )
@ -241,10 +241,10 @@ export function CheckboxRow(props: { label: string; checked: boolean; onChange:
class="group flex cursor-pointer items-center gap-2.5 select-none" class="group flex cursor-pointer items-center gap-2.5 select-none"
> >
<CheckboxControl <CheckboxControl
class={`flex size-4 shrink-0 items-center justify-center rounded-sm border shadow-sm transition-colors ${ class={`flex size-4 shrink-0 items-center justify-center border transition-colors ${
props.checked props.checked
? 'border-primary bg-primary text-primary-foreground' ? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-card text-transparent group-hover:border-primary/50' : 'border-input bg-card text-transparent group-hover:border-primary/60'
}`} }`}
> >
<CheckboxIndicator class="leading-none"> <CheckboxIndicator class="leading-none">
@ -297,22 +297,25 @@ export function Segmented<T extends string>(props: {
return ( return (
<div class={props.class ?? ''}> <div class={props.class ?? ''}>
<Show when={props.label}> <Show when={props.label}>
<span class="label mb-2 inline-block">{props.label}</span> <span class="micro-label mb-2 inline-block text-muted-foreground">{props.label}</span>
</Show> </Show>
<div class="inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-md border bg-muted p-1"> <div class="inline-flex max-w-full border border-line">
<For each={props.options}> <For each={props.options}>
{(o) => ( {(o) => (
<button <button
type="button" type="button"
class={`inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-sm px-3 text-xs font-medium whitespace-nowrap transition-colors focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none ${ class={`relative inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 border-r border-line px-3 font-mono text-[11px] tracking-[0.14em] uppercase whitespace-nowrap transition-colors last:border-r-0 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none ${
o.value === props.value o.value === props.value
? 'bg-card text-foreground shadow-sm' ? 'bg-acid-lime/15 text-foreground dark:bg-primary/10 dark:text-primary'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
}`} }`}
onClick={() => props.onChange(o.value)} onClick={() => props.onChange(o.value)}
> >
{o.icon} {o.icon}
{o.label} {o.label}
<Show when={o.value === props.value}>
<span class="absolute inset-x-0 top-0 h-0.5 bg-foreground dark:bg-primary" />
</Show>
</button> </button>
)} )}
</For> </For>
@ -320,3 +323,86 @@ export function Segmented<T extends string>(props: {
</div> </div>
) )
} }
/* ===== HUD 四角(父元素需 relative) ===== */
export function Corners(props: { class?: string }) {
const c = props.class ?? ''
return (
<>
<span class={`hud-corner hud-tl ${c}`} aria-hidden="true" />
<span class={`hud-corner hud-tr ${c}`} aria-hidden="true" />
<span class={`hud-corner hud-bl ${c}`} aria-hidden="true" />
<span class={`hud-corner hud-br ${c}`} aria-hidden="true" />
</>
)
}
/* ===== 信号电平条 ===== */
export function Meter(props: { active?: boolean; class?: string }) {
return (
<span class={`meter ${props.active ? '' : 'idle'} ${props.class ?? ''}`} aria-hidden="true">
<i />
<i />
<i />
<i />
<i />
</span>
)
}
/* ===== 滚动入场(IntersectionObserver) ===== */
export function Reveal(props: { class?: string; delay?: number; children: JSX.Element }) {
let el: HTMLDivElement | undefined
onMount(() => {
const node = el
if (!node) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
node.classList.add('is-in')
return
}
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
node.classList.add('is-in')
io.disconnect()
}
}
},
{ threshold: 0.08, rootMargin: '0px 0px -6% 0px' },
)
io.observe(node)
onCleanup(() => io.disconnect())
})
return (
<div ref={(n) => (el = n)} class={`reveal ${props.class ?? ''}`} style={{ '--i': String(props.delay ?? 0) } as any}>
{props.children}
</div>
)
}
/* ===== 跑马灯(内容复制两份实现无缝循环) ===== */
export function Marquee(props: { items: string[]; class?: string }) {
const row = (aria: boolean) => (
<div class="flex shrink-0 items-center" aria-hidden={aria || undefined}>
<For each={props.items}>
{(t) => (
<>
<span class="px-5 font-mono text-[11px] tracking-[0.22em] uppercase text-muted-foreground sm:px-7">
{t}
</span>
<span class="size-1.5 shrink-0 rotate-45 bg-acid-lime" />
</>
)}
</For>
</div>
)
return (
<div class={`marquee border-y border-line/80 py-3 ${props.class ?? ''}`}>
<div class="marquee-track">
{row(false)}
{row(true)}
</div>
</div>
)
}

View File

@ -4,75 +4,75 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* ===== shadcn/ui tokens · Acid(克制版) ===== */ /* ===== design tokens · ACID —— 电光酸性 / 强对比 ===== */
:root { :root {
--radius: 0.5rem; --radius: 0.25rem;
--background: 56 42% 96%; --background: 0 0% 97%;
--foreground: 248 20% 9%; --foreground: 0 0% 5%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 248 20% 9%; --card-foreground: 0 0% 5%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 248 20% 9%; --popover-foreground: 0 0% 5%;
--primary: 108 60% 30%; --primary: 78 100% 42%;
--primary-foreground: 60 40% 98%; --primary-foreground: 0 0% 4%;
--secondary: 55 30% 91%; --secondary: 0 0% 93%;
--secondary-foreground: 248 18% 13%; --secondary-foreground: 0 0% 8%;
--muted: 54 26% 91%; --muted: 0 0% 93%;
--muted-foreground: 248 9% 42%; --muted-foreground: 0 0% 38%;
--accent: 188 44% 90%; --accent: 0 0% 94%;
--accent-foreground: 201 50% 16%; --accent-foreground: 0 0% 5%;
--destructive: 2 70% 46%; --destructive: 0 85% 45%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--success: 143 58% 34%; --success: 142 80% 26%;
--warning: 34 88% 38%; --warning: 40 100% 32%;
--border: 55 16% 84%; --border: 0 0% 14%;
--input: 55 16% 79%; --input: 0 0% 20%;
--ring: 108 60% 36%; --ring: 78 100% 38%;
--acid-lime: 105 72% 42%; --acid-lime: 78 100% 30%;
--acid-cyan: 187 90% 38%; --acid-cyan: 187 100% 26%;
--acid-magenta: 322 82% 44%; --acid-magenta: 318 100% 38%;
--acid-violet: 258 76% 48%; --acid-violet: 268 100% 40%;
color-scheme: light; color-scheme: light;
} }
.dark { .dark {
--background: 248 22% 5%; --background: 0 0% 3%;
--foreground: 56 26% 92%; --foreground: 0 0% 96%;
--card: 247 18% 7%; --card: 0 0% 7%;
--card-foreground: 56 26% 92%; --card-foreground: 0 0% 96%;
--popover: 247 18% 7%; --popover: 0 0% 8%;
--popover-foreground: 56 26% 92%; --popover-foreground: 0 0% 96%;
--primary: 91 58% 44%; --primary: 78 100% 55%;
--primary-foreground: 240 30% 7%; --primary-foreground: 0 0% 4%;
--secondary: 247 16% 12%; --secondary: 0 0% 12%;
--secondary-foreground: 55 20% 88%; --secondary-foreground: 0 0% 88%;
--muted: 247 16% 12%; --muted: 0 0% 12%;
--muted-foreground: 246 10% 62%; --muted-foreground: 0 0% 62%;
--accent: 251 22% 14%; --accent: 0 0% 14%;
--accent-foreground: 190 72% 70%; --accent-foreground: 0 0% 92%;
--destructive: 3 68% 52%; --destructive: 0 100% 58%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--success: 143 62% 55%; --success: 142 100% 45%;
--warning: 38 92% 55%; --warning: 48 100% 55%;
--border: 248 14% 18%; --border: 0 0% 20%;
--input: 248 13% 24%; --input: 0 0% 26%;
--ring: 91 58% 50%; --ring: 78 100% 55%;
--acid-lime: 90 66% 54%; --acid-lime: 78 100% 55%;
--acid-cyan: 188 82% 58%; --acid-cyan: 187 100% 50%;
--acid-magenta: 325 80% 62%; --acid-magenta: 318 100% 55%;
--acid-violet: 262 86% 68%; --acid-violet: 268 100% 66%;
color-scheme: dark; color-scheme: dark;
} }
@ -97,6 +97,7 @@
--color-success: hsl(var(--success)); --color-success: hsl(var(--success));
--color-warning: hsl(var(--warning)); --color-warning: hsl(var(--warning));
--color-border: hsl(var(--border)); --color-border: hsl(var(--border));
--color-line: hsl(var(--border));
--color-input: hsl(var(--input)); --color-input: hsl(var(--input));
--color-ring: hsl(var(--ring)); --color-ring: hsl(var(--ring));
@ -105,10 +106,10 @@
--color-acid-magenta: hsl(var(--acid-magenta)); --color-acid-magenta: hsl(var(--acid-magenta));
--color-acid-violet: hsl(var(--acid-violet)); --color-acid-violet: hsl(var(--acid-violet));
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 2px);
--radius-md: calc(var(--radius) - 2px); --radius-md: var(--radius);
--radius-lg: var(--radius); --radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 6px);
--font-sans: "Space Grotesk Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif; --font-sans: "Space Grotesk Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, monospace; --font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, monospace;
@ -134,79 +135,60 @@
} }
::selection { ::selection {
background: hsl(var(--acid-lime) / 0.28); background: hsl(var(--acid-lime) / 0.9);
color: hsl(var(--foreground)); color: hsl(0 0% 4%);
} }
body::before { ::-webkit-scrollbar {
content: ""; width: 10px;
position: fixed; height: 8px;
inset: 0;
z-index: -1;
pointer-events: none;
background:
linear-gradient(115deg, hsl(var(--acid-lime) / 0.1), transparent 34%),
linear-gradient(238deg, hsl(var(--acid-cyan) / 0.09), transparent 44%),
linear-gradient(325deg, hsl(var(--acid-magenta) / 0.07), transparent 42%);
} }
::-webkit-scrollbar-track {
body::after { background: transparent;
content: ""; }
position: fixed; ::-webkit-scrollbar-thumb {
inset: 0; background: hsl(var(--border));
z-index: -1; border: 2px solid transparent;
pointer-events: none; background-clip: padding-box;
background-image:
linear-gradient(to right, hsl(var(--foreground) / 0.035) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--foreground) / 0.035) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: linear-gradient(to bottom, black, transparent 76%);
} }
} }
@layer components { @layer components {
.card { /* ===== 字体系统 ===== */
@apply rounded-lg border bg-card text-card-foreground shadow-sm; .display {
font-family: var(--font-sans);
font-weight: 650;
letter-spacing: -0.03em;
line-height: 0.98;
} }
.btn { .mono-label {
@apply inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md px-4 text-sm font-medium whitespace-nowrap transition-colors select-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50; font-family: var(--font-mono);
} font-size: 11px;
.btn-primary { letter-spacing: 0.16em;
@apply bg-primary text-primary-foreground shadow-sm hover:bg-primary/90; text-transform: uppercase;
}
.btn-secondary {
@apply bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80;
}
.btn-outline {
@apply border bg-card shadow-sm hover:bg-accent hover:text-accent-foreground;
}
.btn-ghost {
@apply text-foreground/70 hover:bg-accent hover:text-accent-foreground;
}
.btn-destructive {
@apply bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90;
}
.btn-sm {
@apply h-8 px-3 text-xs;
}
.btn-lg {
@apply h-10 px-6 text-[0.95rem];
}
.btn-icon {
@apply size-9 p-0;
} }
.field { .micro-label {
@apply flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50; font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
} }
.label { .field-mono {
@apply text-sm leading-none font-medium; font-family: var(--font-mono);
} }
.badge { .outline-text {
@apply inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-semibold whitespace-nowrap transition-colors; -webkit-text-stroke: 2px hsl(var(--acid-lime));
color: transparent;
}
.ghost-text {
-webkit-text-stroke: 1.5px hsl(var(--foreground) / 0.16);
color: transparent;
user-select: none;
} }
.acid-text { .acid-text {
@ -222,59 +204,311 @@
color: transparent; color: transparent;
} }
.panel { /* ===== 面板与容器 ===== */
@apply rounded-lg border bg-background/70; .card {
@apply rounded-sm border bg-card text-card-foreground;
}
.badge {
@apply inline-flex items-center gap-1 rounded-[3px] border px-1.5 py-0.5 font-mono text-[10px] font-medium tracking-[0.14em] uppercase whitespace-nowrap transition-colors;
}
.mono-chip {
@apply inline-flex cursor-pointer items-center gap-1.5 border border-line bg-card/60 px-2 py-1 font-mono text-[10px] tracking-[0.12em] uppercase text-muted-foreground transition-colors;
}
.mono-chip:hover {
border-color: hsl(var(--foreground) / 0.7);
color: hsl(var(--foreground));
}
.dark .mono-chip:hover {
border-color: hsl(var(--acid-lime) / 0.7);
color: hsl(var(--acid-lime));
}
/* ===== 按钮 ===== */
.btn {
@apply inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-[3px] px-4 text-sm font-medium whitespace-nowrap select-none transition-all duration-150 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.btn:active:not(:disabled) {
transform: translateY(0);
}
.btn-primary {
@apply bg-primary text-primary-foreground;
}
.dark .btn-primary {
box-shadow: 0 0 32px -6px hsl(var(--acid-lime) / 0.7);
}
.btn-primary:hover:not(:disabled) {
@apply bg-primary/85;
}
.btn-secondary {
@apply bg-secondary text-secondary-foreground;
}
.btn-secondary:hover:not(:disabled) {
@apply bg-secondary/80;
}
.btn-outline {
@apply border border-line bg-transparent text-foreground;
}
.btn-outline:hover:not(:disabled) {
border-color: hsl(var(--foreground) / 0.8);
background: hsl(var(--acid-lime) / 0.12);
color: hsl(var(--foreground));
}
.dark .btn-outline:hover:not(:disabled) {
border-color: hsl(var(--acid-lime) / 0.8);
background: hsl(var(--acid-lime) / 0.08);
color: hsl(var(--acid-lime));
}
.btn-ghost {
@apply text-muted-foreground;
}
.btn-ghost:hover:not(:disabled) {
@apply bg-accent text-foreground;
}
.btn-destructive {
@apply border border-destructive/50 bg-destructive/10 text-destructive;
}
.btn-destructive:hover:not(:disabled) {
@apply bg-destructive text-destructive-foreground;
}
.btn-sm {
@apply h-8 px-3 text-xs;
}
.btn-lg {
@apply h-11 px-6 text-sm;
}
.btn-icon {
@apply size-9 p-0;
}
/* ===== 表单 ===== */
.field {
@apply h-9 w-full rounded-[3px] border border-input bg-background/60 px-3 py-1 text-sm transition-colors placeholder:text-muted-foreground/50 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50;
}
.label {
@apply text-sm leading-none font-medium;
}
/* ===== HUD 边角 ===== */
.hud-corner {
position: absolute;
z-index: 2;
width: 13px;
height: 13px;
pointer-events: none;
border-color: hsl(var(--acid-lime));
}
.hud-tl {
top: -1px;
left: -1px;
border-top: 2px solid;
border-left: 2px solid;
}
.hud-tr {
top: -1px;
right: -1px;
border-top: 2px solid;
border-right: 2px solid;
}
.hud-bl {
bottom: -1px;
left: -1px;
border-bottom: 2px solid;
border-left: 2px solid;
}
.hud-br {
bottom: -1px;
right: -1px;
border-bottom: 2px solid;
border-right: 2px solid;
}
/* ===== 氛围层 ===== */
.atmo-grid {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.55;
background-image:
linear-gradient(to right, hsl(var(--foreground) / 0.06) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--foreground) / 0.06) 1px, transparent 1px);
background-size: 72px 72px;
-webkit-mask-image: radial-gradient(130% 90% at 50% -10%, #000 25%, transparent 78%);
mask-image: radial-gradient(130% 90% at 50% -10%, #000 25%, transparent 78%);
}
.atmo-glow {
position: fixed;
z-index: 0;
pointer-events: none;
width: 46vmax;
height: 46vmax;
border-radius: 9999px;
filter: blur(80px);
opacity: 0.1;
}
.atmo-glow-a {
top: -24vmax;
left: -12vmax;
background: hsl(var(--acid-lime) / 0.6);
animation: drift-a 26s ease-in-out infinite alternate;
}
.atmo-glow-b {
right: -16vmax;
bottom: -26vmax;
background: hsl(var(--acid-cyan) / 0.55);
animation: drift-b 32s ease-in-out infinite alternate;
}
.dark .atmo-glow {
opacity: 0.3;
}
.dark .atmo-glow-b {
opacity: 0.22;
}
.scanline-sweep {
position: fixed;
left: 0;
right: 0;
top: 0;
height: 24%;
z-index: 0;
pointer-events: none;
background: linear-gradient(to bottom, transparent, hsl(var(--acid-lime) / 0.06), transparent);
animation: sweep 10s linear infinite;
}
.dark .scanline-sweep {
background: linear-gradient(to bottom, transparent, hsl(var(--acid-lime) / 0.09), transparent);
}
.noise {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.04;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
}
.dark .noise {
opacity: 0.06;
}
.spotlight {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
640px circle at var(--mx, 72%) var(--my, 24%),
hsl(var(--acid-lime) / 0.1),
transparent 65%
);
}
.dark .spotlight {
background: radial-gradient(
640px circle at var(--mx, 72%) var(--my, 24%),
hsl(var(--acid-lime) / 0.14),
transparent 65%
);
}
/* ===== 跑马灯 ===== */
.marquee {
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.marquee-track {
display: flex;
width: max-content;
animation: marquee 32s linear infinite;
}
.marquee:hover .marquee-track {
animation-play-state: paused;
}
/* ===== 信号条 / 呼吸点 ===== */
.meter {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 12px;
}
.meter i {
width: 2px;
height: 100%;
background: hsl(var(--acid-lime));
transform-origin: bottom;
animation: meter-bar 1.1s ease-in-out infinite;
}
.meter i:nth-child(2) {
animation-delay: 0.12s;
}
.meter i:nth-child(3) {
animation-delay: 0.24s;
}
.meter i:nth-child(4) {
animation-delay: 0.36s;
}
.meter i:nth-child(5) {
animation-delay: 0.48s;
}
.meter.idle i {
animation: none;
transform: scaleY(0.22);
opacity: 0.35;
}
.pulse {
animation: pulse-ring 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.pulse-acid {
animation: pulse-ring-acid 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* ===== 滚动入场 ===== */
.reveal {
opacity: 0;
transform: translateY(22px);
transition:
opacity 0.65s cubic-bezier(0.22, 0.61, 0.36, 1),
transform 0.65s cubic-bezier(0.22, 0.61, 0.36, 1);
transition-delay: calc(var(--i, 0) * 80ms);
}
.reveal.is-in {
opacity: 1;
transform: none;
}
/* ===== 导航下划线 ===== */
.nav-link {
position: relative;
}
.nav-link::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 2px;
background: hsl(var(--acid-lime));
transform: scaleX(0);
transform-origin: left;
transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.nav-link:hover::after,
.nav-link.is-active::after {
transform: scaleX(1);
} }
.focus-ring { .focus-ring {
@apply focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none; @apply focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none;
} }
.acid-frame {
position: relative;
}
.acid-frame::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
hsl(var(--acid-lime) / 0.75),
hsl(var(--acid-cyan) / 0.45) 38%,
hsl(var(--acid-violet) / 0.18) 68%,
hsl(var(--acid-magenta) / 0.5)
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
}
.header-line::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
opacity: 0.55;
pointer-events: none;
background: linear-gradient(
90deg,
hsl(var(--acid-lime)),
hsl(var(--acid-cyan)) 40%,
hsl(var(--acid-violet)) 72%,
hsl(var(--acid-magenta))
);
}
} }
/* ===== 弹幕 ===== */ /* ===== 弹幕 ===== */
@ -282,9 +516,9 @@
position: absolute; position: absolute;
left: 100%; left: 100%;
white-space: nowrap; white-space: nowrap;
font-weight: 600; font-weight: 700;
font-size: 1rem; font-size: 1rem;
letter-spacing: 0; letter-spacing: 0.02em;
text-shadow: 0 1px 2px rgb(0 0 0 / 0.9), 0 0 14px rgb(0 0 0 / 0.5); text-shadow: 0 1px 2px rgb(0 0 0 / 0.9), 0 0 14px rgb(0 0 0 / 0.5);
animation-name: danmaku-move; animation-name: danmaku-move;
animation-timing-function: linear; animation-timing-function: linear;
@ -292,6 +526,82 @@
will-change: left; will-change: left;
} }
@keyframes danmaku-move { @keyframes danmaku-move {
from { left: 100%; } from {
to { left: -100%; } left: 100%;
}
to {
left: -100%;
}
}
@keyframes marquee {
to {
transform: translateX(-50%);
}
}
@keyframes meter-bar {
0%,
100% {
transform: scaleY(0.3);
}
50% {
transform: scaleY(1);
}
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 hsl(var(--success) / 0.5);
}
70%,
100% {
box-shadow: 0 0 0 7px hsl(var(--success) / 0);
}
}
@keyframes pulse-ring-acid {
0% {
box-shadow: 0 0 0 0 hsl(var(--acid-lime) / 0.6);
}
70%,
100% {
box-shadow: 0 0 0 7px hsl(var(--acid-lime) / 0);
}
}
@keyframes drift-a {
to {
transform: translate(8vmax, 6vmax);
}
}
@keyframes drift-b {
to {
transform: translate(-7vmax, -5vmax);
}
}
@keyframes sweep {
from {
transform: translateY(-30vh);
}
to {
transform: translateY(130vh);
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.marquee-track,
.meter i,
.pulse,
.pulse-acid,
.atmo-glow-a,
.atmo-glow-b,
.scanline-sweep,
.danmaku-item {
animation: none !important;
}
.reveal {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
} }

View File

@ -5,12 +5,21 @@ import tailwindcss from '@tailwindcss/vite'
// 构建产物直接落到 Go 的 //go:embed 目录,保持单体仓库发布形态。 // 构建产物直接落到 Go 的 //go:embed 目录,保持单体仓库发布形态。
// base 设为 /static/ 让 Vite 资源路径命中 Go 的 /static/ 文件服务。 // base 设为 /static/ 让 Vite 资源路径命中 Go 的 /static/ 文件服务。
export default defineConfig({ export default defineConfig(({ mode }) => ({
plugins: [solid(), tailwindcss()], plugins: [solid(), tailwindcss()],
base: '/static/', // dev 在根路径服务(与 TanStack Router 的路由路径对齐);build 落到 /static/ 供 Go embed
base: mode === 'development' ? '/' : '/static/',
build: { build: {
outDir: fileURLToPath(new URL('../internal/server/static', import.meta.url)), outDir: fileURLToPath(new URL('../internal/server/static', import.meta.url)),
emptyOutDir: true, emptyOutDir: true,
}, },
server: { port: 5173 }, server: {
}) port: 5173,
// 开发态把控制面 / RTC / 直播流反代到本地 Go 服务(不影响构建产物)
proxy: {
'/api': 'http://localhost:8088',
'/rtc': 'http://localhost:8088',
'/live': 'http://localhost:8088',
},
},
}))