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:
parent
ef13dbbabc
commit
0c0412e199
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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
|
|
@ -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};
|
||||
|
|
@ -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
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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
File diff suppressed because one or more lines are too long
|
|
@ -19,9 +19,9 @@
|
|||
}
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/static/assets/index-CFU6bGFM.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/static/assets/ui-W1Pm8dqI.js">
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-Djwy0HhI.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-BjOqv38F.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/static/assets/ui-BEZm0Fjb.js">
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-iV_C3-n9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Component, createSignal, onMount, Show, For } from 'solid-js'
|
||||
import { useNavigate } from '@tanstack/solid-router'
|
||||
import { Component, createSignal, createEffect, For, onCleanup, onMount, Show } from 'solid-js'
|
||||
import { Link, useNavigate } from '@tanstack/solid-router'
|
||||
import {
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
Hash,
|
||||
MonitorPlay,
|
||||
RadioTower,
|
||||
Server,
|
||||
|
|
@ -11,107 +12,308 @@ import {
|
|||
} from 'lucide-solid'
|
||||
import { api } from '../../lib/api'
|
||||
import { permissions } from '../../lib/permissions'
|
||||
import { Badge, Button, Card, LiveDot, Separator, TextInput } from '../../components/ui'
|
||||
import type { BackendInfo, BackendKind, Room } from '../../lib/types'
|
||||
import {
|
||||
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) {
|
||||
if (kind === 'BACKEND_KIND_CLOUDFLARE') return Cloud
|
||||
return Server
|
||||
return kind === 'BACKEND_KIND_CLOUDFLARE' ? Cloud : Server
|
||||
}
|
||||
|
||||
function roomTargets(r: Room) {
|
||||
function targetsOf(r: Room) {
|
||||
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 = () => {
|
||||
const navigate = useNavigate()
|
||||
const [backends, setBackends] = createSignal<BackendInfo[]>([])
|
||||
const [rooms, setRooms] = createSignal<Room[]>([])
|
||||
const [room, setRoom] = createSignal('demo')
|
||||
let heroEl: HTMLElement | undefined
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const c = await api.getConfig()
|
||||
setBackends(c.backends || [])
|
||||
} catch {}
|
||||
if (permissions.isAuthed()) {
|
||||
try {
|
||||
const r = await api.listRooms()
|
||||
setRooms(r.rooms || [])
|
||||
} catch {}
|
||||
onMount(() => {
|
||||
api
|
||||
.getConfig()
|
||||
.then((c) => setBackends(c.backends || []))
|
||||
.catch(() => {})
|
||||
|
||||
const el = heroEl
|
||||
if (el && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
// 鼠标视差:各层按 data-parallax 深度位移,rAF + lerp 平滑;聚光灯同步跟随
|
||||
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() } })
|
||||
|
||||
return (
|
||||
<div class="space-y-8">
|
||||
<section class="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<div class="min-w-0 flex-1 space-y-5">
|
||||
<div class="space-y-3">
|
||||
<div class="inline-flex items-center gap-2 font-mono text-xs text-muted-foreground">
|
||||
<span class="size-1.5 rounded-full bg-success shadow-[0_0_0_3px_hsl(var(--success)/0.14)]" />
|
||||
live-sfu / fan-out console
|
||||
</div>
|
||||
<h1 class="text-3xl leading-tight font-semibold sm:text-4xl">
|
||||
一路推流,<span class="acid-text">多端扇出</span>
|
||||
</h1>
|
||||
<p class="max-w-xl leading-relaxed text-muted-foreground">
|
||||
Cloudflare Realtime 与 SRS 双 SFU 分发,浏览器经服务端反代直连,凭证不出服务端。
|
||||
</p>
|
||||
<div class="relative">
|
||||
{/* ===== HERO ===== */}
|
||||
<section ref={(n) => (heroEl = n)} class="relative overflow-hidden">
|
||||
<div class="spotlight" aria-hidden="true" />
|
||||
<div
|
||||
class="ghost-text display pointer-events-none absolute -top-8 right-0 hidden text-[24vw] leading-none will-change-transform xl:block"
|
||||
data-parallax="-40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
SFU
|
||||
</div>
|
||||
|
||||
<Card class="space-y-4 p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<MonitorPlay class="size-4 text-primary" />
|
||||
<h2 class="font-semibold">进入房间</h2>
|
||||
<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 class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<TextInput value={room()} onInput={setRoom} placeholder="房间名,如 demo" class="sm:max-w-xs" />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button onClick={() => go('/room')}>
|
||||
</Reveal>
|
||||
|
||||
<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>
|
||||
</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 分发,浏览器经服务端反代直连,凭证不出服务端。
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3} class="mt-10">
|
||||
<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">
|
||||
<Corners />
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<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">
|
||||
<Hash 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"
|
||||
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" />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => go('/publish')}>
|
||||
<Button variant="outline" class="h-11" onClick={() => go('/publish')}>
|
||||
<Video class="size-4" />
|
||||
开始直播
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={permissions.isAuthed()}>
|
||||
<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">
|
||||
当前角色
|
||||
<Badge class="border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan">{permissions.role()}</Badge>
|
||||
<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>
|
||||
发布:
|
||||
<b class={permissions.can('room:publish') ? 'text-success' : 'text-muted-foreground'}>
|
||||
{permissions.can('room:publish') ? '可' : '不可'}
|
||||
</b>
|
||||
<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>
|
||||
发弹幕:
|
||||
<b class={permissions.can('room:chat') ? 'text-success' : 'text-muted-foreground'}>
|
||||
{permissions.can('room:chat') ? '可' : '不可'}
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<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">
|
||||
<div class="px-4 py-3 sm:px-5">
|
||||
<p class="display text-3xl sm:text-4xl dark:text-primary">{backends().length || '–'}</p>
|
||||
<p class="mono-label mt-1.5 text-muted-foreground">sfu backends</p>
|
||||
</div>
|
||||
<div class="px-4 py-3 sm:px-5">
|
||||
<p class="display text-3xl sm:text-4xl dark:text-primary">
|
||||
{permissions.isAuthed() ? rooms().length : '–'}
|
||||
</p>
|
||||
<p class="mono-label mt-1.5 text-muted-foreground">online rooms</p>
|
||||
</div>
|
||||
<div class="px-4 py-3 sm:px-5">
|
||||
<p class="display text-3xl sm:text-4xl dark:text-primary">4</p>
|
||||
<p class="mono-label mt-1.5 text-muted-foreground">protocols</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Marquee
|
||||
items={[
|
||||
'WHIP → SFU',
|
||||
'SRS FAN-OUT',
|
||||
'WHEP / HLS / FLV',
|
||||
'CLOUDFLARE REALTIME',
|
||||
'WEBRTC LOW-LATENCY',
|
||||
'一路推流 · 多端扇出',
|
||||
'FAN-OUT CONSOLE',
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ===== 在线房间 + 信号栈 ===== */}
|
||||
<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">
|
||||
<Reveal>
|
||||
<div class="flex items-end justify-between gap-4 border-b border-line pb-4">
|
||||
<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>
|
||||
</Reveal>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</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">
|
||||
<aside class="min-w-0">
|
||||
<Reveal>
|
||||
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
|
||||
<Corners />
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioTower class="size-4 text-primary" />
|
||||
<h2 class="font-semibold">后端状态</h2>
|
||||
<RadioTower class="size-4 text-acid-lime" />
|
||||
<h2 class="font-semibold">Signal Stack</h2>
|
||||
</div>
|
||||
<Badge variant="secondary">{backends().length} 路</Badge>
|
||||
<Badge variant="muted">{backends().length} 路</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="mt-4 space-y-2.5">
|
||||
<Show
|
||||
when={backends().length > 0}
|
||||
fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}
|
||||
|
|
@ -120,90 +322,56 @@ export const Home: Component = () => {
|
|||
{(b) => {
|
||||
const Icon = backendIcon(b.kind)
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border p-3">
|
||||
<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-8 shrink-0 items-center justify-center rounded-md bg-secondary text-foreground/70">
|
||||
<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="text-xs text-muted-foreground">{b.primary ? '主 SFU' : '对照后端'}</p>
|
||||
<p class="micro-label mt-0.5 text-muted-foreground">
|
||||
{b.primary ? 'primary sfu' : 'standby'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={b.configured ? 'success' : 'warning'}>
|
||||
{b.configured ? '已配置' : '未配置'}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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="flex gap-2 rounded-md border bg-muted/50 p-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<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" />
|
||||
<span>凭据由服务端代理注入,浏览器不持有 SFU 密钥。</span>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
凭据由服务端代理注入,浏览器不持有 SFU 密钥。
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<Show when={rooms().length > 0}>
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">在线房间</h2>
|
||||
<p class="text-sm text-muted-foreground">当前已恢复的分发拓扑</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/rooms' })}>
|
||||
房间列表
|
||||
<ArrowRight class="size-4" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,27 @@
|
|||
import { Component, For, onMount, Show } from 'solid-js'
|
||||
import { Link, Outlet, useLocation, useNavigate } from '@tanstack/solid-router'
|
||||
import {
|
||||
CirclePlay,
|
||||
Home,
|
||||
MonitorPlay,
|
||||
RadioTower,
|
||||
Users,
|
||||
Video,
|
||||
} from 'lucide-solid'
|
||||
import { Link, Outlet, useLocation } from '@tanstack/solid-router'
|
||||
import { RadioTower } from 'lucide-solid'
|
||||
import { refreshMe } from '../../lib/auth'
|
||||
import { permissions } from '../../lib/permissions'
|
||||
import { Badge, ThemeToggle } from '../../components/ui'
|
||||
import { ThemeToggle } from '../../components/ui'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
idx: string
|
||||
label: string
|
||||
icon: (p: { class?: string }) => any
|
||||
activePaths?: string[]
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: '/', label: '概览', icon: Home },
|
||||
{ to: '/rooms', label: '房间', icon: Video },
|
||||
{ to: '/watch', label: '观看', icon: MonitorPlay, activePaths: ['/watch', '/room'] },
|
||||
{ to: '/publish', label: '推流', icon: CirclePlay },
|
||||
{ to: '/users', label: '用户', icon: Users, adminOnly: true },
|
||||
{ to: '/', idx: '01', label: '概览' },
|
||||
{ to: '/rooms', idx: '02', label: '房间' },
|
||||
{ to: '/watch', idx: '03', label: '观看', activePaths: ['/watch', '/room'] },
|
||||
{ to: '/publish', idx: '04', label: '推流' },
|
||||
{ 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 = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
onMount(async () => {
|
||||
|
|
@ -43,75 +29,87 @@ export const Layout: Component = () => {
|
|||
await permissions.refresh()
|
||||
})
|
||||
|
||||
const isActive = (item: NavItem) => {
|
||||
const paths = item.activePaths ?? [item.to]
|
||||
return paths.includes(location().pathname)
|
||||
}
|
||||
const isActive = (item: NavItem) => (item.activePaths ?? [item.to]).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 (
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<header class="header-line sticky top-0 z-40 border-b bg-background/85 backdrop-blur-xl">
|
||||
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3 px-4">
|
||||
<div class="relative min-h-screen">
|
||||
<div class="atmo-grid" aria-hidden="true" />
|
||||
<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="首页">
|
||||
<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">
|
||||
<RadioTower class="size-4" />
|
||||
</span>
|
||||
<span class="truncate text-sm font-semibold sm:text-base">
|
||||
直播 SFU <span class="acid-text">分流台</span>
|
||||
</span>
|
||||
<span class="size-2 shrink-0 bg-acid-lime pulse-acid" />
|
||||
<span class="display text-base whitespace-nowrap">LIVE·SFU</span>
|
||||
<span class="micro-label hidden text-muted-foreground md:inline">fan-out console</span>
|
||||
</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()}>
|
||||
<span class="hidden items-center gap-2 rounded-md border bg-card py-1 pr-1 pl-1 sm:flex">
|
||||
<span class="flex size-5 items-center justify-center rounded-sm bg-primary/10 text-[11px] font-bold text-primary">
|
||||
{(permissions.role()[0] ?? '').toUpperCase()}
|
||||
<span class="hidden items-center gap-1.5 border border-line bg-card/60 px-2 py-1 sm:flex">
|
||||
<span class="size-1.5 bg-acid-cyan" />
|
||||
<span class="font-mono text-[10px] tracking-[0.14em] uppercase text-muted-foreground">
|
||||
{permissions.role()}
|
||||
</span>
|
||||
<Badge class={roleBadge(permissions.role())}>{permissions.role()}</Badge>
|
||||
</span>
|
||||
</Show>
|
||||
<ThemeToggle />
|
||||
<Show when={!permissions.isAuthed()}>
|
||||
<Link to="/login" class="btn btn-outline btn-sm">
|
||||
<Link to="/login" class="btn btn-primary btn-sm">
|
||||
登录
|
||||
</Link>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="mx-auto max-w-6xl overflow-x-auto px-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="flex items-center gap-1">
|
||||
<For each={NAV}>
|
||||
{(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>
|
||||
<nav class="overflow-x-auto border-t border-line/60 [scrollbar-width:none] md:hidden [&::-webkit-scrollbar]:hidden" aria-label="主导航(移动端)">
|
||||
<div class="mx-auto flex max-w-[1400px] items-center gap-4 px-4">
|
||||
{navLinks('h-10')}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
<div class="mx-auto max-w-6xl px-4 py-6 sm:py-8">
|
||||
<main class="relative z-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="border-t bg-background/70">
|
||||
<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">
|
||||
<span>Cloudflare Realtime · SRS · WHIP / WHEP / HLS</span>
|
||||
<span class="font-mono">live-sfu fan-out console</span>
|
||||
<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-[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 / FLV</span>
|
||||
<span>live-sfu fan-out console — 一路推流 多端扇出</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { LogIn, RadioTower } from 'lucide-solid'
|
|||
import { api } from '../../lib/api'
|
||||
import { refreshMe } from '../../lib/auth'
|
||||
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 = [
|
||||
{ name: 'admin', role: '管理员', pass: 'Admin123!' },
|
||||
|
|
@ -37,22 +37,32 @@ export const Login: Component = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<Reveal>
|
||||
<div class="relative border border-line bg-card/70 p-6 backdrop-blur-sm sm:p-8">
|
||||
<Corners />
|
||||
<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">
|
||||
<RadioTower class="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">登录分流控制台</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">发布、订阅与房间管理需要登录</p>
|
||||
<h1 class="display text-3xl">登录</h1>
|
||||
<p class="micro-label mt-1.5 text-muted-foreground">fan-out console / auth</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="p-6">
|
||||
<form class="space-y-4" onSubmit={submit}>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label" for="login-username">
|
||||
用户名
|
||||
<form class="mt-7 space-y-5" onSubmit={submit}>
|
||||
<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>
|
||||
<TextInput
|
||||
id="login-username"
|
||||
|
|
@ -60,12 +70,12 @@ export const Login: Component = () => {
|
|||
onInput={setUsername}
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
class="h-9"
|
||||
class="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label" for="login-password">
|
||||
密码
|
||||
<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>
|
||||
<TextInput
|
||||
id="login-password"
|
||||
|
|
@ -74,11 +84,13 @@ export const Login: Component = () => {
|
|||
onInput={setPassword}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
class="h-9"
|
||||
class="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">角色</label>
|
||||
<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')}
|
||||
|
|
@ -86,42 +98,51 @@ export const Login: Component = () => {
|
|||
{ value: 'viewer', label: '观看者 viewer' },
|
||||
{ value: 'publisher', label: '发布者 publisher' },
|
||||
]}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={busy()} class="w-full">
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div class="rounded-lg border bg-card p-4">
|
||||
<p class="label mb-2">演示账号</p>
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
<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="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"
|
||||
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="text-sm font-medium">{u.name}</span>
|
||||
<span class="text-xs text-muted-foreground">{u.role}</span>
|
||||
<span class="font-mono text-[10px] text-muted-foreground/80">{u.pass}</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,16 @@ import {
|
|||
CircleStop,
|
||||
Cloud,
|
||||
FileVideo,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
Server,
|
||||
Video,
|
||||
} from 'lucide-solid'
|
||||
import { api } from '../../lib/api'
|
||||
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 { 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 type { BackendInfo, RoomEvent, StreamTarget } from '../../lib/types'
|
||||
|
||||
|
|
@ -60,7 +59,7 @@ export const Publish: Component = () => {
|
|||
})
|
||||
|
||||
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] }))
|
||||
|
|
@ -126,19 +125,23 @@ export const Publish: Component = () => {
|
|||
onCleanup(stopAll)
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="space-y-1.5">
|
||||
<h1 class="text-2xl font-semibold">开始直播</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
房间 <span class="font-mono text-foreground">{room()}</span> · 选择分发后端,一路推流将扇出到所选 SFU。
|
||||
<div class="mx-auto max-w-[1400px] space-y-5 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Reveal>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="mono-label text-muted-foreground">publish / {room()}</p>
|
||||
<h1 class="display mt-2 text-4xl sm:text-5xl">开始直播</h1>
|
||||
<p class="mt-3 max-w-lg text-sm text-muted-foreground">
|
||||
选择分发后端,一路推流将扇出到所选 SFU。
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={running() ? 'success' : 'outline'}>
|
||||
<div class="flex items-center gap-3 border border-line bg-card/60 px-4 py-2.5">
|
||||
<LiveDot active={running()} />
|
||||
{running() ? '推流中' : '待机'}
|
||||
</Badge>
|
||||
<span class="mono-label">{running() ? '推流中 / live' : '待机 / standby'}</span>
|
||||
<Meter active={running()} />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Show when={!permissions.can('room:publish')}>
|
||||
<Alert tone="warning">
|
||||
|
|
@ -150,63 +153,89 @@ export const Publish: Component = () => {
|
|||
<Alert tone="error">{error()}</Alert>
|
||||
</Show>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card class="space-y-3 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<Reveal delay={1} class="min-w-0">
|
||||
<div class="relative h-full border border-line bg-card/50 p-4 sm:p-5">
|
||||
<Corners />
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Video class="size-4 text-primary" />
|
||||
<Video class="size-4 text-acid-lime" />
|
||||
<h3 class="font-semibold">本地预览</h3>
|
||||
<span class="micro-label text-muted-foreground">preview</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono text-[11px] text-muted-foreground">
|
||||
{stream()
|
||||
? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频`
|
||||
: 'tracks · off'}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDanmaku((v) => !v)}>
|
||||
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
|
||||
</Button>
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{stream() ? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频` : 'off'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="acid-frame relative aspect-video overflow-hidden rounded-md border bg-black">
|
||||
</div>
|
||||
|
||||
<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 text-white/60">
|
||||
<span class="flex size-12 items-center justify-center rounded-full border border-white/20 bg-white/5">
|
||||
<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="text-xs">摄像头未开启</span>
|
||||
<span class="micro-label text-white/50">camera off</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button onClick={start} disabled={running() || !permissions.can('room:publish')}>
|
||||
|
||||
<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" onClick={stopAll} disabled={!running()}>
|
||||
<Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
|
||||
<CircleStop class="size-4" />
|
||||
停止推流
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Card class="space-y-3 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Reveal delay={2} class="min-w-0 self-start">
|
||||
<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">
|
||||
<Server class="size-4 text-primary" />
|
||||
<Server class="size-4 text-acid-lime" />
|
||||
<h3 class="font-semibold">分发后端</h3>
|
||||
<span class="micro-label text-muted-foreground">routing</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{backends().length} 路</Badge>
|
||||
<Badge variant="muted">{backends().length} 路</Badge>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Show when={backends().length > 0} fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}>
|
||||
|
||||
<div class="mt-4 space-y-2.5">
|
||||
<Show
|
||||
when={backends().length > 0}
|
||||
fallback={<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 rounded-md border p-3">
|
||||
<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-8 shrink-0 items-center justify-center rounded-md bg-secondary text-foreground/70">
|
||||
<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">
|
||||
|
|
@ -216,79 +245,95 @@ export const Publish: Component = () => {
|
|||
onChange={() => toggle(b.kind)}
|
||||
disabled={running()}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
<p class="micro-label mt-1.5 text-muted-foreground">
|
||||
{backendLabel(b.kind)} · {b.primary ? '主 SFU' : '对照后端'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={b.configured ? 'success' : 'warning'}>{b.configured ? '已配置' : '未配置'}</Badge>
|
||||
<Badge variant={b.configured ? 'success' : 'muted'}>
|
||||
{b.configured ? '在线' : '未配置'}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
|
||||
<p class="mt-4 border-t border-line/70 pt-4 text-xs leading-relaxed text-muted-foreground">
|
||||
推流凭证仅在服务端会话中生效,浏览器端不会出现 SFU 密钥。
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Card class="space-y-3 p-5">
|
||||
<Reveal delay={1}>
|
||||
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Link2 class="size-4 text-primary" />
|
||||
<FileVideo class="size-4 text-acid-lime" />
|
||||
<h3 class="font-semibold">分发状态</h3>
|
||||
<span class="micro-label text-muted-foreground">fan-out status</span>
|
||||
</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="mt-4 border border-dashed border-line px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
尚未开始推流
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<For each={targets()}>
|
||||
{(t) => (
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<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="flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||
{backendLabel(t.backend)}
|
||||
</span>
|
||||
<Badge variant="success">直播中</Badge>
|
||||
<span class="truncate text-sm font-medium">{backendLabel(t.backend)}</span>
|
||||
<Badge variant="success">
|
||||
<LiveDot active />
|
||||
直播中
|
||||
</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" />
|
||||
<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="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
|
||||
<FileVideo class="size-3.5" />
|
||||
<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="truncate font-mono text-[11px] text-muted-foreground">session:{t.session_id}</p>
|
||||
<p class="mt-2 truncate font-mono text-[10px] text-muted-foreground">
|
||||
session · {t.session_id}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Card class="flex flex-col gap-3 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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-primary" />
|
||||
<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="h-80 min-h-0">
|
||||
<div class="mt-4 h-80 min-h-0">
|
||||
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Component, createSignal, createEffect, For, Show } from 'solid-js'
|
||||
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 { 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'
|
||||
|
||||
export const Rooms: Component = () => {
|
||||
|
|
@ -39,44 +39,55 @@ export const Rooms: Component = () => {
|
|||
const enter = (room: string) => navigate({ to: '/room', search: { room } })
|
||||
|
||||
return (
|
||||
<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 class="mx-auto max-w-[1400px] space-y-10 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Reveal>
|
||||
<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">
|
||||
创建或进入房间,房间内可观看、发弹幕与查看分发状态。
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Card class="space-y-3 p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Plus class="size-4 text-primary" />
|
||||
<h3 class="font-semibold">创建房间</h3>
|
||||
</div>
|
||||
<Reveal delay={1}>
|
||||
<div class="relative max-w-2xl border border-line bg-card/60 p-3 sm:p-4">
|
||||
<Corners />
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<TextInput
|
||||
value={name()}
|
||||
onInput={setName}
|
||||
<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"
|
||||
class="sm:max-w-xs"
|
||||
value={name()}
|
||||
onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') create()
|
||||
}}
|
||||
aria-label="房间名"
|
||||
/>
|
||||
<Button onClick={create} disabled={busy()}>
|
||||
</div>
|
||||
<Button class="h-10" onClick={create} disabled={busy()}>
|
||||
<ArrowRight class="size-4" />
|
||||
创建并进入
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Radio class="size-4 text-primary" />
|
||||
<h2 class="text-lg font-semibold">房间列表</h2>
|
||||
<Reveal>
|
||||
<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().length} rooms</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Show
|
||||
when={rooms().length > 0}
|
||||
fallback={
|
||||
<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">
|
||||
<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">
|
||||
<DoorOpen class="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
|
|
@ -86,24 +97,31 @@ export const Rooms: Component = () => {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<For each={rooms()}>
|
||||
{(r) => (
|
||||
<Card class="flex flex-col gap-3 p-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<LiveDot active={(r.targets?.length ?? 0) > 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} 路分发
|
||||
{(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={(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>
|
||||
<Button variant="outline" class="w-full" onClick={() => enter(r.name)}>
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Component, createSignal, For, Show, createEffect } from 'solid-js'
|
|||
import { ShieldAlert, ShieldCheck, UserRound } from 'lucide-solid'
|
||||
import { api } from '../../lib/api'
|
||||
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'
|
||||
|
||||
const ROLES: Role[] = ['viewer', 'publisher', 'admin']
|
||||
|
|
@ -11,22 +11,22 @@ const ROLE_META: Record<Role, { label: string; badge: string; avatar: string }>
|
|||
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',
|
||||
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/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/30 bg-acid-cyan/10 text-acid-cyan',
|
||||
avatar: 'border-acid-cyan/40 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',
|
||||
badge: 'border-line bg-transparent text-muted-foreground',
|
||||
avatar: 'border-line bg-muted text-muted-foreground',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -52,11 +52,16 @@ export const Users: Component = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<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 的管理员可调整角色。</p>
|
||||
<div class="mx-auto max-w-[1400px] space-y-8 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Reveal>
|
||||
<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 的管理员可调整角色。
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Show
|
||||
when={permissions.can('user:list')}
|
||||
|
|
@ -69,40 +74,41 @@ export const Users: Component = () => {
|
|||
</Alert>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<Reveal delay={1}>
|
||||
<div class="border border-line bg-card/50">
|
||||
<Show when={err()}>
|
||||
<Alert tone="error" class="rounded-none border-x-0 border-t-0">
|
||||
<Alert tone="error" class="border-x-0 border-t-0">
|
||||
{err()}
|
||||
</Alert>
|
||||
</Show>
|
||||
<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">操作</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="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</span>
|
||||
</div>
|
||||
<For each={users()}>
|
||||
{(u, i) => (
|
||||
<div
|
||||
class={`grid grid-cols-1 items-center gap-3 px-5 py-3 sm:grid-cols-[1fr_auto_auto] sm:gap-4 ${
|
||||
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
|
||||
class={`flex size-8 shrink-0 items-center justify-center rounded-md border text-xs font-bold ${ROLE_META[u.role].avatar}`}
|
||||
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>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">{u.username}</p>
|
||||
<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 gap-2">
|
||||
<div class="flex items-center justify-end">
|
||||
<Show
|
||||
when={permissions.can('user:manage')}
|
||||
fallback={<UserRound class="size-4 text-muted-foreground/60" />}
|
||||
fallback={<UserRound class="size-4 text-muted-foreground/50" />}
|
||||
>
|
||||
<Select
|
||||
class="w-32"
|
||||
|
|
@ -122,7 +128,8 @@ export const Users: Component = () => {
|
|||
暂无用户数据
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
Eye,
|
||||
EyeOff,
|
||||
FileVideo,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
MonitorPlay,
|
||||
Radio,
|
||||
|
|
@ -14,7 +13,7 @@ import {
|
|||
} from 'lucide-solid'
|
||||
import { api } from '../../lib/api'
|
||||
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 { whepSubscribe, cfSubscribe, toRTC } from '../../lib/webrtc'
|
||||
import { playHLS } from '../../lib/hls'
|
||||
|
|
@ -108,27 +107,44 @@ function RoomHub(props: { room: string }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="space-y-1.5">
|
||||
<h1 class="text-2xl font-semibold">房间 <span class="font-mono">{room()}</span></h1>
|
||||
<p class="text-sm text-muted-foreground">观看、发弹幕与查看分发状态。</p>
|
||||
<div class="mx-auto max-w-[1400px] space-y-5 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Reveal>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<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" onClick={() => navigate({ to: '/publish', search: { room: room() } })}>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10"
|
||||
onClick={() => navigate({ to: '/publish', search: { room: room() } })}
|
||||
>
|
||||
<Video class="size-4" />
|
||||
开始直播到该房间
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Show when={error()}>
|
||||
<Alert tone="error">{error()}</Alert>
|
||||
</Show>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card class="space-y-3 p-4 lg:col-span-2 lg:p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<Reveal delay={1} class="min-w-0 lg:col-span-2">
|
||||
<div class="relative h-full border border-line bg-card/50 p-4 sm:p-5">
|
||||
<Corners />
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<Segmented
|
||||
value={mode()}
|
||||
onChange={(v) => setMode(v)}
|
||||
|
|
@ -138,103 +154,120 @@ function RoomHub(props: { room: string }) {
|
|||
{ 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>
|
||||
<span class="hidden font-mono text-[11px] text-muted-foreground sm:block">
|
||||
fan-out · {MODE_LABEL[mode()]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="acid-frame relative aspect-video overflow-hidden rounded-md border bg-black">
|
||||
<div class="relative mt-4 aspect-video overflow-hidden border border-line/70 bg-black">
|
||||
<video ref={(el) => (videoEl = el)} autoplay playsinline 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="shadow-sm">
|
||||
<Badge variant={running() ? 'success' : 'outline'} class="bg-black/50 shadow-sm backdrop-blur-sm">
|
||||
<LiveDot active={running()} />
|
||||
{running() ? '连接中' : '未连接'}
|
||||
{running() ? 'CONNECTING' : 'OFFLINE'}
|
||||
</Badge>
|
||||
<Badge variant="secondary" class="font-mono shadow-sm">
|
||||
<Badge variant="secondary" class="border-line bg-black/50 text-white/90 shadow-sm backdrop-blur-sm">
|
||||
{MODE_LABEL[mode()]}
|
||||
</Badge>
|
||||
</div>
|
||||
<Show when={!running()}>
|
||||
<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">
|
||||
<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">
|
||||
<MonitorPlay class="size-5" />
|
||||
</span>
|
||||
<span class="text-xs">等待接入</span>
|
||||
<span class="micro-label text-white/50">standby · 等待接入</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button onClick={start} disabled={running()}>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button class="h-10" onClick={start} disabled={running()}>
|
||||
<PlayIcon />
|
||||
开始观看
|
||||
</Button>
|
||||
<Button variant="danger" onClick={stopAll} disabled={!running()}>
|
||||
<Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
|
||||
<Square class="size-4" />
|
||||
停止
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setShowDanmaku((s) => !s)}>
|
||||
<Button variant="ghost" class="h-10" onClick={() => setShowDanmaku((s) => !s)}>
|
||||
{showDanmaku() ? <EyeOff class="size-4" /> : <Eye class="size-4" />}
|
||||
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Card class="flex h-[28rem] flex-col space-y-3 p-4 lg:p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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-primary" />
|
||||
<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="secondary">{msgs().length}</Badge>
|
||||
<Badge variant="muted">{msgs().length}</Badge>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">
|
||||
<div class="mt-4 min-h-0 flex-1">
|
||||
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Card class="space-y-3 p-5">
|
||||
<Reveal delay={1}>
|
||||
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Link2 class="size-4 text-primary" />
|
||||
<FileVideo class="size-4 text-acid-lime" />
|
||||
<h3 class="font-semibold">分发状态</h3>
|
||||
<span class="micro-label text-muted-foreground">fan-out status</span>
|
||||
</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="mt-4 border border-dashed border-line px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
房间暂无直播
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<For each={targets()}>
|
||||
{(t) => (
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<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="text-sm font-medium">{backendText(t.backend)}</span>
|
||||
<Badge variant="success">直播中</Badge>
|
||||
<span class="truncate text-sm font-medium">{backendText(t.backend)}</span>
|
||||
<Badge variant="success">
|
||||
<LiveDot active />
|
||||
直播中
|
||||
</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" />
|
||||
<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="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
|
||||
<FileVideo class="size-3.5" />
|
||||
<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="truncate font-mono text-[11px] text-muted-foreground">session:{t.session_id}</p>
|
||||
<p class="mt-2 truncate font-mono text-[10px] text-muted-foreground">
|
||||
session · {t.session_id}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function DanmakuOverlay(props: { messages: () => ChatMessage[] }) {
|
|||
const top = tops[lane % tops.length]
|
||||
lane++
|
||||
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) => {
|
||||
const next = [...p, item]
|
||||
return next.length > 50 ? next.slice(next.length - 50) : next
|
||||
|
|
@ -59,9 +59,9 @@ function measureTime(ts: number) {
|
|||
}
|
||||
|
||||
function roleColor(role: string) {
|
||||
if (role === 'admin') return 'text-acid-magenta border-acid-magenta/30 bg-acid-magenta/10'
|
||||
if (role === 'publisher') return 'text-warning border-warning/30 bg-warning/10'
|
||||
return 'text-acid-cyan border-acid-cyan/30 bg-acid-cyan/10'
|
||||
if (role === 'admin') return 'border-acid-magenta/40 bg-acid-magenta/10 text-acid-magenta'
|
||||
if (role === 'publisher') return 'border-warning/40 bg-warning/10 text-warning'
|
||||
return 'border-acid-cyan/40 bg-acid-cyan/10 text-acid-cyan'
|
||||
}
|
||||
|
||||
// 聊天/弹幕侧栏:消息列表 + 发送框(权限由 canChat 控制)
|
||||
|
|
@ -85,22 +85,19 @@ export function ChatPanel(props: {
|
|||
<Show when={props.messages().length > 0}>
|
||||
<For each={props.messages()}>
|
||||
{(m) => (
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<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),
|
||||
)}`}
|
||||
>
|
||||
{(m.user[0] ?? '?').toUpperCase()}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<span class="text-xs font-medium">{m.user}</span>
|
||||
<span class="text-[11px] text-muted-foreground">{m.role}</span>
|
||||
<Show when={m.host}>
|
||||
<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 class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2">
|
||||
<span class="font-mono text-xs font-medium">{m.user}</span>
|
||||
<span class="micro-label text-[9px] text-muted-foreground">{String(m.role)}</span>
|
||||
<span class="font-mono text-[9px] text-muted-foreground/60">{measureTime(m.ts)}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm leading-snug break-words">{m.message}</p>
|
||||
</div>
|
||||
|
|
@ -109,20 +106,21 @@ export function ChatPanel(props: {
|
|||
</For>
|
||||
</Show>
|
||||
<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" />
|
||||
<p class="text-sm text-muted-foreground">还没有弹幕</p>
|
||||
<p class="micro-label text-muted-foreground/50">danmaku channel idle</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
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
|
||||
class="field h-9 text-sm"
|
||||
placeholder="发条弹幕"
|
||||
class="field field-mono h-9 flex-1 text-sm"
|
||||
placeholder="发条弹幕…"
|
||||
value={text()}
|
||||
aria-label="弹幕内容"
|
||||
onInput={(e) => setText((e.currentTarget as HTMLInputElement).value)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { For, Show, type JSX } from 'solid-js'
|
||||
import { For, onCleanup, onMount, Show, type JSX } from 'solid-js'
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
|
|
@ -134,7 +134,7 @@ export function Select(props: {
|
|||
return (
|
||||
<div class={`relative ${props.class ?? ''}`}>
|
||||
<select
|
||||
class="field appearance-none pr-8"
|
||||
class="field field-mono h-9 appearance-none pr-8 text-xs"
|
||||
value={props.value}
|
||||
disabled={props.disabled}
|
||||
aria-label={props['aria-label']}
|
||||
|
|
@ -151,12 +151,12 @@ export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'wa
|
|||
|
||||
const BADGE_TONE: Record<BadgeVariant, string> = {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-success/25 bg-success/10 text-success',
|
||||
warning: 'border-warning/25 bg-warning/10 text-warning',
|
||||
danger: 'border-destructive/25 bg-destructive/10 text-destructive',
|
||||
muted: 'border-transparent bg-muted text-muted-foreground',
|
||||
secondary: 'border-line bg-secondary text-secondary-foreground',
|
||||
outline: 'border-line text-foreground',
|
||||
success: 'border-success/30 bg-success/10 text-success',
|
||||
warning: 'border-warning/30 bg-warning/10 text-warning',
|
||||
danger: 'border-destructive/30 bg-destructive/10 text-destructive',
|
||||
muted: 'border-line bg-transparent text-muted-foreground',
|
||||
}
|
||||
|
||||
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 = {
|
||||
error: {
|
||||
box: 'border-destructive/30 bg-destructive/8 text-destructive',
|
||||
box: 'border-destructive/40 bg-destructive/10 text-destructive',
|
||||
icon: CircleAlert,
|
||||
},
|
||||
warning: {
|
||||
box: 'border-warning/30 bg-warning/10 text-warning',
|
||||
box: 'border-warning/40 bg-warning/10 text-warning',
|
||||
icon: TriangleAlert,
|
||||
},
|
||||
success: {
|
||||
box: 'border-success/30 bg-success/10 text-success',
|
||||
box: 'border-success/40 bg-success/10 text-success',
|
||||
icon: CircleCheck,
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ export function Alert(props: {
|
|||
return (
|
||||
<div
|
||||
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" />
|
||||
<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 }) {
|
||||
return (
|
||||
<span
|
||||
class={`relative inline-flex size-2 shrink-0 rounded-full ${
|
||||
props.active ? 'bg-success shadow-[0_0_0_3px_hsl(var(--success)/0.14)]' : 'bg-muted-foreground/40'
|
||||
class={`inline-block size-2 shrink-0 ${
|
||||
props.active ? 'bg-success pulse' : 'bg-muted-foreground/40'
|
||||
} ${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"
|
||||
>
|
||||
<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
|
||||
? '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">
|
||||
|
|
@ -297,22 +297,25 @@ export function Segmented<T extends string>(props: {
|
|||
return (
|
||||
<div class={props.class ?? ''}>
|
||||
<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>
|
||||
<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}>
|
||||
{(o) => (
|
||||
<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
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'
|
||||
? 'bg-acid-lime/15 text-foreground dark:bg-primary/10 dark:text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => props.onChange(o.value)}
|
||||
>
|
||||
{o.icon}
|
||||
{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>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -320,3 +323,86 @@ export function Segmented<T extends string>(props: {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,75 +4,75 @@
|
|||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ===== shadcn/ui tokens · Acid(克制版) ===== */
|
||||
/* ===== design tokens · ACID —— 电光酸性 / 强对比 ===== */
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--radius: 0.25rem;
|
||||
|
||||
--background: 56 42% 96%;
|
||||
--foreground: 248 20% 9%;
|
||||
--background: 0 0% 97%;
|
||||
--foreground: 0 0% 5%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 248 20% 9%;
|
||||
--card-foreground: 0 0% 5%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 248 20% 9%;
|
||||
--popover-foreground: 0 0% 5%;
|
||||
|
||||
--primary: 108 60% 30%;
|
||||
--primary-foreground: 60 40% 98%;
|
||||
--secondary: 55 30% 91%;
|
||||
--secondary-foreground: 248 18% 13%;
|
||||
--muted: 54 26% 91%;
|
||||
--muted-foreground: 248 9% 42%;
|
||||
--accent: 188 44% 90%;
|
||||
--accent-foreground: 201 50% 16%;
|
||||
--primary: 78 100% 42%;
|
||||
--primary-foreground: 0 0% 4%;
|
||||
--secondary: 0 0% 93%;
|
||||
--secondary-foreground: 0 0% 8%;
|
||||
--muted: 0 0% 93%;
|
||||
--muted-foreground: 0 0% 38%;
|
||||
--accent: 0 0% 94%;
|
||||
--accent-foreground: 0 0% 5%;
|
||||
|
||||
--destructive: 2 70% 46%;
|
||||
--destructive: 0 85% 45%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--success: 143 58% 34%;
|
||||
--warning: 34 88% 38%;
|
||||
--success: 142 80% 26%;
|
||||
--warning: 40 100% 32%;
|
||||
|
||||
--border: 55 16% 84%;
|
||||
--input: 55 16% 79%;
|
||||
--ring: 108 60% 36%;
|
||||
--border: 0 0% 14%;
|
||||
--input: 0 0% 20%;
|
||||
--ring: 78 100% 38%;
|
||||
|
||||
--acid-lime: 105 72% 42%;
|
||||
--acid-cyan: 187 90% 38%;
|
||||
--acid-magenta: 322 82% 44%;
|
||||
--acid-violet: 258 76% 48%;
|
||||
--acid-lime: 78 100% 30%;
|
||||
--acid-cyan: 187 100% 26%;
|
||||
--acid-magenta: 318 100% 38%;
|
||||
--acid-violet: 268 100% 40%;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 248 22% 5%;
|
||||
--foreground: 56 26% 92%;
|
||||
--background: 0 0% 3%;
|
||||
--foreground: 0 0% 96%;
|
||||
|
||||
--card: 247 18% 7%;
|
||||
--card-foreground: 56 26% 92%;
|
||||
--popover: 247 18% 7%;
|
||||
--popover-foreground: 56 26% 92%;
|
||||
--card: 0 0% 7%;
|
||||
--card-foreground: 0 0% 96%;
|
||||
--popover: 0 0% 8%;
|
||||
--popover-foreground: 0 0% 96%;
|
||||
|
||||
--primary: 91 58% 44%;
|
||||
--primary-foreground: 240 30% 7%;
|
||||
--secondary: 247 16% 12%;
|
||||
--secondary-foreground: 55 20% 88%;
|
||||
--muted: 247 16% 12%;
|
||||
--muted-foreground: 246 10% 62%;
|
||||
--accent: 251 22% 14%;
|
||||
--accent-foreground: 190 72% 70%;
|
||||
--primary: 78 100% 55%;
|
||||
--primary-foreground: 0 0% 4%;
|
||||
--secondary: 0 0% 12%;
|
||||
--secondary-foreground: 0 0% 88%;
|
||||
--muted: 0 0% 12%;
|
||||
--muted-foreground: 0 0% 62%;
|
||||
--accent: 0 0% 14%;
|
||||
--accent-foreground: 0 0% 92%;
|
||||
|
||||
--destructive: 3 68% 52%;
|
||||
--destructive: 0 100% 58%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--success: 143 62% 55%;
|
||||
--warning: 38 92% 55%;
|
||||
--success: 142 100% 45%;
|
||||
--warning: 48 100% 55%;
|
||||
|
||||
--border: 248 14% 18%;
|
||||
--input: 248 13% 24%;
|
||||
--ring: 91 58% 50%;
|
||||
--border: 0 0% 20%;
|
||||
--input: 0 0% 26%;
|
||||
--ring: 78 100% 55%;
|
||||
|
||||
--acid-lime: 90 66% 54%;
|
||||
--acid-cyan: 188 82% 58%;
|
||||
--acid-magenta: 325 80% 62%;
|
||||
--acid-violet: 262 86% 68%;
|
||||
--acid-lime: 78 100% 55%;
|
||||
--acid-cyan: 187 100% 50%;
|
||||
--acid-magenta: 318 100% 55%;
|
||||
--acid-violet: 268 100% 66%;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@
|
|||
--color-success: hsl(var(--success));
|
||||
--color-warning: hsl(var(--warning));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-line: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
|
||||
|
|
@ -105,10 +106,10 @@
|
|||
--color-acid-magenta: hsl(var(--acid-magenta));
|
||||
--color-acid-violet: hsl(var(--acid-violet));
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
--radius-xl: calc(var(--radius) + 6px);
|
||||
|
||||
--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;
|
||||
|
|
@ -134,79 +135,60 @@
|
|||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(var(--acid-lime) / 0.28);
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--acid-lime) / 0.9);
|
||||
color: hsl(0 0% 4%);
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
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 {
|
||||
width: 10px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
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%);
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
@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;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-primary text-primary-foreground shadow-sm hover:bg-primary/90;
|
||||
}
|
||||
.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;
|
||||
.mono-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field {
|
||||
@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;
|
||||
.micro-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply text-sm leading-none font-medium;
|
||||
.field-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-semibold whitespace-nowrap transition-colors;
|
||||
.outline-text {
|
||||
-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 {
|
||||
|
|
@ -222,59 +204,311 @@
|
|||
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 {
|
||||
@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;
|
||||
left: 100%;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
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);
|
||||
animation-name: danmaku-move;
|
||||
animation-timing-function: linear;
|
||||
|
|
@ -292,6 +526,82 @@
|
|||
will-change: left;
|
||||
}
|
||||
@keyframes danmaku-move {
|
||||
from { left: 100%; }
|
||||
to { left: -100%; }
|
||||
from {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,21 @@ import tailwindcss from '@tailwindcss/vite'
|
|||
|
||||
// 构建产物直接落到 Go 的 //go:embed 目录,保持单体仓库发布形态。
|
||||
// base 设为 /static/ 让 Vite 资源路径命中 Go 的 /static/ 文件服务。
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [solid(), tailwindcss()],
|
||||
base: '/static/',
|
||||
// dev 在根路径服务(与 TanStack Router 的路由路径对齐);build 落到 /static/ 供 Go embed
|
||||
base: mode === 'development' ? '/' : '/static/',
|
||||
build: {
|
||||
outDir: fileURLToPath(new URL('../internal/server/static', import.meta.url)),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: { port: 5173 },
|
||||
})
|
||||
server: {
|
||||
port: 5173,
|
||||
// 开发态把控制面 / RTC / 直播流反代到本地 Go 服务(不影响构建产物)
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8088',
|
||||
'/rtc': 'http://localhost:8088',
|
||||
'/live': 'http://localhost:8088',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
Loading…
Reference in New Issue