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
16895dcdd8
commit
ffc69d691c
|
|
@ -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>
|
||||||
<script type="module" crossorigin src="/static/assets/index-CFU6bGFM.js"></script>
|
<script type="module" crossorigin src="/static/assets/index-BjOqv38F.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/static/assets/ui-W1Pm8dqI.js">
|
<link rel="modulepreload" crossorigin href="/static/assets/ui-BEZm0Fjb.js">
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-Djwy0HhI.css">
|
<link rel="stylesheet" crossorigin href="/static/assets/index-iV_C3-n9.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Component, createSignal, onMount, Show, For } from 'solid-js'
|
import { Component, createSignal, createEffect, For, onCleanup, onMount, Show } from 'solid-js'
|
||||||
import { useNavigate } from '@tanstack/solid-router'
|
import { Link, useNavigate } from '@tanstack/solid-router'
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
Hash,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
Server,
|
Server,
|
||||||
|
|
@ -11,107 +12,308 @@ import {
|
||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { permissions } from '../../lib/permissions'
|
import { permissions } from '../../lib/permissions'
|
||||||
import { Badge, Button, Card, LiveDot, Separator, TextInput } from '../../components/ui'
|
import {
|
||||||
import type { BackendInfo, BackendKind, Room } from '../../lib/types'
|
Badge,
|
||||||
|
Button,
|
||||||
|
Corners,
|
||||||
|
LiveDot,
|
||||||
|
Marquee,
|
||||||
|
Meter,
|
||||||
|
Reveal,
|
||||||
|
} from '../../components/ui'
|
||||||
|
import type { BackendInfo, Room } from '../../lib/types'
|
||||||
|
|
||||||
|
const PROTOCOLS = ['WHIP', 'WHEP', 'HLS', 'FLV']
|
||||||
|
|
||||||
function backendIcon(kind: string) {
|
function backendIcon(kind: string) {
|
||||||
if (kind === 'BACKEND_KIND_CLOUDFLARE') return Cloud
|
return kind === 'BACKEND_KIND_CLOUDFLARE' ? Cloud : Server
|
||||||
return Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function roomTargets(r: Room) {
|
function targetsOf(r: Room) {
|
||||||
return r.targets?.length ?? 0
|
return r.targets?.length ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmptyRooms(props: { authed: boolean }) {
|
||||||
|
return (
|
||||||
|
<div class="mt-6 flex flex-col items-center gap-3 border border-dashed border-line px-6 py-14 text-center">
|
||||||
|
<MonitorPlay class="size-6 text-muted-foreground/60" />
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{props.authed ? '暂无在线房间' : '房间列表需登录'}</p>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
{props.authed
|
||||||
|
? '创建一个房间后,分发拓扑会出现在这里。'
|
||||||
|
: '登录后可查看与管理房间分发拓扑。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Show when={!props.authed}>
|
||||||
|
<Link to="/login" class="btn btn-outline btn-sm mt-1">
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const Home: Component = () => {
|
export const Home: Component = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [backends, setBackends] = createSignal<BackendInfo[]>([])
|
const [backends, setBackends] = createSignal<BackendInfo[]>([])
|
||||||
const [rooms, setRooms] = createSignal<Room[]>([])
|
const [rooms, setRooms] = createSignal<Room[]>([])
|
||||||
const [room, setRoom] = createSignal('demo')
|
const [room, setRoom] = createSignal('demo')
|
||||||
|
let heroEl: HTMLElement | undefined
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
try {
|
api
|
||||||
const c = await api.getConfig()
|
.getConfig()
|
||||||
setBackends(c.backends || [])
|
.then((c) => setBackends(c.backends || []))
|
||||||
} catch {}
|
.catch(() => {})
|
||||||
if (permissions.isAuthed()) {
|
|
||||||
try {
|
const el = heroEl
|
||||||
const r = await api.listRooms()
|
if (el && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
setRooms(r.rooms || [])
|
// 鼠标视差:各层按 data-parallax 深度位移,rAF + lerp 平滑;聚光灯同步跟随
|
||||||
} catch {}
|
const layers = Array.from(el.querySelectorAll<HTMLElement>('[data-parallax]'))
|
||||||
|
let tx = 0
|
||||||
|
let ty = 0
|
||||||
|
let cx = 0
|
||||||
|
let cy = 0
|
||||||
|
let raf = 0
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
tx = (e.clientX - r.left) / r.width - 0.5
|
||||||
|
ty = (e.clientY - r.top) / r.height - 0.5
|
||||||
|
el.style.setProperty('--mx', `${e.clientX - r.left}px`)
|
||||||
|
el.style.setProperty('--my', `${e.clientY - r.top}px`)
|
||||||
}
|
}
|
||||||
|
const onLeave = () => {
|
||||||
|
tx = 0
|
||||||
|
ty = 0
|
||||||
|
}
|
||||||
|
const tick = () => {
|
||||||
|
cx += (tx - cx) * 0.08
|
||||||
|
cy += (ty - cy) * 0.08
|
||||||
|
for (const layer of layers) {
|
||||||
|
const d = Number(layer.dataset.parallax || 0)
|
||||||
|
layer.style.transform = `translate3d(${(cx * d).toFixed(2)}px, ${(cy * d).toFixed(2)}px, 0)`
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
el.addEventListener('mousemove', onMove)
|
||||||
|
el.addEventListener('mouseleave', onLeave)
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
onCleanup(() => {
|
||||||
|
el.removeEventListener('mousemove', onMove)
|
||||||
|
el.removeEventListener('mouseleave', onLeave)
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 登录态就绪后再拉房间(整页刷新时鉴权接口晚于组件挂载,需响应式等待)
|
||||||
|
createEffect(() => {
|
||||||
|
if (!permissions.check()) return
|
||||||
|
if (!permissions.isAuthed()) return
|
||||||
|
api
|
||||||
|
.listRooms()
|
||||||
|
.then((r) => setRooms(r.rooms || []))
|
||||||
|
.catch(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
const go = (to: string) => navigate({ to, search: { room: room() } })
|
const go = (to: string) => navigate({ to, search: { room: room() } })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-8">
|
<div class="relative">
|
||||||
<section class="flex flex-col gap-6 lg:flex-row lg:items-start">
|
{/* ===== HERO ===== */}
|
||||||
<div class="min-w-0 flex-1 space-y-5">
|
<section ref={(n) => (heroEl = n)} class="relative overflow-hidden">
|
||||||
<div class="space-y-3">
|
<div class="spotlight" aria-hidden="true" />
|
||||||
<div class="inline-flex items-center gap-2 font-mono text-xs text-muted-foreground">
|
<div
|
||||||
<span class="size-1.5 rounded-full bg-success shadow-[0_0_0_3px_hsl(var(--success)/0.14)]" />
|
class="ghost-text display pointer-events-none absolute -top-8 right-0 hidden text-[24vw] leading-none will-change-transform xl:block"
|
||||||
live-sfu / fan-out console
|
data-parallax="-40"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
<h1 class="text-3xl leading-tight font-semibold sm:text-4xl">
|
>
|
||||||
一路推流,<span class="acid-text">多端扇出</span>
|
SFU
|
||||||
</h1>
|
|
||||||
<p class="max-w-xl leading-relaxed text-muted-foreground">
|
|
||||||
Cloudflare Realtime 与 SRS 双 SFU 分发,浏览器经服务端反代直连,凭证不出服务端。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="space-y-4 p-5">
|
<div class="relative mx-auto max-w-[1400px] px-4 pt-14 pb-12 sm:px-6 sm:pt-20">
|
||||||
<div class="flex items-center gap-2">
|
<Reveal>
|
||||||
<MonitorPlay class="size-4 text-primary" />
|
<div class="flex items-center gap-2.5">
|
||||||
<h2 class="font-semibold">进入房间</h2>
|
<span class="size-2 bg-success pulse" />
|
||||||
|
<span class="mono-label text-muted-foreground">live-sfu / fan-out console</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
</Reveal>
|
||||||
<TextInput value={room()} onInput={setRoom} placeholder="房间名,如 demo" class="sm:max-w-xs" />
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<Reveal delay={1}>
|
||||||
<Button onClick={() => go('/room')}>
|
<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" />
|
<ArrowRight class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => go('/publish')}>
|
<Button variant="outline" class="h-11" onClick={() => go('/publish')}>
|
||||||
<Video class="size-4" />
|
<Video class="size-4" />
|
||||||
开始直播
|
开始直播
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Show when={permissions.isAuthed()}>
|
<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">
|
<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="flex items-center gap-1.5">
|
<span class="mono-label flex items-center gap-1.5 text-muted-foreground">
|
||||||
当前角色
|
role <span class="text-acid-cyan">{permissions.role()}</span>
|
||||||
<Badge class="border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan">{permissions.role()}</Badge>
|
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span class="mono-label text-muted-foreground">
|
||||||
发布:
|
publish ·{' '}
|
||||||
<b class={permissions.can('room:publish') ? 'text-success' : 'text-muted-foreground'}>
|
<span class={permissions.can('room:publish') ? 'text-success' : 'opacity-50'}>
|
||||||
{permissions.can('room:publish') ? '可' : '不可'}
|
{permissions.can('room:publish') ? 'ON' : 'OFF'}
|
||||||
</b>
|
</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>
|
||||||
<span>
|
|
||||||
发弹幕:
|
|
||||||
<b class={permissions.can('room:chat') ? 'text-success' : 'text-muted-foreground'}>
|
|
||||||
{permissions.can('room:chat') ? '可' : '不可'}
|
|
||||||
</b>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card class="w-full shrink-0 space-y-4 self-start p-5 lg:w-80 xl:w-[22rem]">
|
<aside class="min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<RadioTower class="size-4 text-primary" />
|
<RadioTower class="size-4 text-acid-lime" />
|
||||||
<h2 class="font-semibold">后端状态</h2>
|
<h2 class="font-semibold">Signal Stack</h2>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{backends().length} 路</Badge>
|
<Badge variant="muted">{backends().length} 路</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="mt-4 space-y-2.5">
|
||||||
<Show
|
<Show
|
||||||
when={backends().length > 0}
|
when={backends().length > 0}
|
||||||
fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}
|
fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}
|
||||||
|
|
@ -120,90 +322,56 @@ export const Home: Component = () => {
|
||||||
{(b) => {
|
{(b) => {
|
||||||
const Icon = backendIcon(b.kind)
|
const Icon = backendIcon(b.kind)
|
||||||
return (
|
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">
|
<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" />
|
<Icon class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-medium">{b.name}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={b.configured ? 'success' : 'warning'}>
|
<div class="flex shrink-0 items-center gap-2.5">
|
||||||
{b.configured ? '已配置' : '未配置'}
|
<Meter active={b.configured} />
|
||||||
|
<Badge variant={b.configured ? 'success' : 'muted'}>
|
||||||
|
{b.configured ? '在线' : '未配置'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<div class="mt-4 border-t border-line/70 pt-4">
|
||||||
|
<p class="micro-label text-muted-foreground">distribution protocols</p>
|
||||||
<div>
|
<div class="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
<p class="label mb-2">分发协议</p>
|
<For each={PROTOCOLS}>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
{(p) => <Badge variant="outline" class="font-mono">{p}</Badge>}
|
||||||
<For each={['WHIP', 'WHEP', 'HLS', 'FLV']}>
|
|
||||||
{(p) => (
|
|
||||||
<Badge variant="outline" class="font-mono">
|
|
||||||
{p}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<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>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</aside>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,27 @@
|
||||||
import { Component, For, onMount, Show } from 'solid-js'
|
import { Component, For, onMount, Show } from 'solid-js'
|
||||||
import { Link, Outlet, useLocation, useNavigate } from '@tanstack/solid-router'
|
import { Link, Outlet, useLocation } from '@tanstack/solid-router'
|
||||||
import {
|
import { RadioTower } from 'lucide-solid'
|
||||||
CirclePlay,
|
|
||||||
Home,
|
|
||||||
MonitorPlay,
|
|
||||||
RadioTower,
|
|
||||||
Users,
|
|
||||||
Video,
|
|
||||||
} from 'lucide-solid'
|
|
||||||
import { refreshMe } from '../../lib/auth'
|
import { refreshMe } from '../../lib/auth'
|
||||||
import { permissions } from '../../lib/permissions'
|
import { permissions } from '../../lib/permissions'
|
||||||
import { Badge, ThemeToggle } from '../../components/ui'
|
import { ThemeToggle } from '../../components/ui'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
|
idx: string
|
||||||
label: string
|
label: string
|
||||||
icon: (p: { class?: string }) => any
|
|
||||||
activePaths?: string[]
|
activePaths?: string[]
|
||||||
adminOnly?: boolean
|
adminOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV: NavItem[] = [
|
const NAV: NavItem[] = [
|
||||||
{ to: '/', label: '概览', icon: Home },
|
{ to: '/', idx: '01', label: '概览' },
|
||||||
{ to: '/rooms', label: '房间', icon: Video },
|
{ to: '/rooms', idx: '02', label: '房间' },
|
||||||
{ to: '/watch', label: '观看', icon: MonitorPlay, activePaths: ['/watch', '/room'] },
|
{ to: '/watch', idx: '03', label: '观看', activePaths: ['/watch', '/room'] },
|
||||||
{ to: '/publish', label: '推流', icon: CirclePlay },
|
{ to: '/publish', idx: '04', label: '推流' },
|
||||||
{ to: '/users', label: '用户', icon: Users, adminOnly: true },
|
{ to: '/users', idx: '05', label: '用户', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
function roleBadge(role: string) {
|
|
||||||
if (role === 'admin') return 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta'
|
|
||||||
if (role === 'publisher') return 'border-warning/30 bg-warning/10 text-warning'
|
|
||||||
return 'border-transparent bg-muted text-muted-foreground'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Layout: Component = () => {
|
export const Layout: Component = () => {
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
|
@ -43,75 +29,87 @@ export const Layout: Component = () => {
|
||||||
await permissions.refresh()
|
await permissions.refresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
const isActive = (item: NavItem) => {
|
const isActive = (item: NavItem) => (item.activePaths ?? [item.to]).includes(location().pathname)
|
||||||
const paths = item.activePaths ?? [item.to]
|
|
||||||
return paths.includes(location().pathname)
|
const navLinks = (h: string) => (
|
||||||
}
|
<>
|
||||||
|
<For each={NAV}>
|
||||||
|
{(item) => (
|
||||||
|
<Show when={!item.adminOnly || permissions.can('user:manage')}>
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
class={`nav-link flex ${h} shrink-0 items-center gap-1.5 font-mono text-[11px] tracking-[0.16em] uppercase whitespace-nowrap transition-colors ${
|
||||||
|
isActive(item)
|
||||||
|
? 'is-active text-foreground dark:text-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class="text-[9px] opacity-50">{item.idx}</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex min-h-screen flex-col">
|
<div class="relative min-h-screen">
|
||||||
<header class="header-line sticky top-0 z-40 border-b bg-background/85 backdrop-blur-xl">
|
<div class="atmo-grid" aria-hidden="true" />
|
||||||
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3 px-4">
|
<div class="atmo-glow atmo-glow-a" aria-hidden="true" />
|
||||||
|
<div class="atmo-glow atmo-glow-b" aria-hidden="true" />
|
||||||
|
<div class="scanline-sweep" aria-hidden="true" />
|
||||||
|
<div class="noise" aria-hidden="true" />
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-40 border-b border-line/80 bg-background/85 backdrop-blur-md">
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 top-0 h-[3px]"
|
||||||
|
style="background: linear-gradient(90deg, hsl(var(--acid-lime)), hsl(var(--acid-cyan)) 38%, hsl(var(--acid-violet)) 70%, hsl(var(--acid-magenta)))"
|
||||||
|
/>
|
||||||
|
<div class="mx-auto flex h-12 max-w-[1400px] items-center gap-4 px-4 sm:px-6">
|
||||||
<Link to="/" class="flex min-w-0 items-center gap-2.5" aria-label="首页">
|
<Link to="/" class="flex min-w-0 items-center gap-2.5" aria-label="首页">
|
||||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-acid-lime/30 bg-primary text-primary-foreground shadow-sm">
|
<span class="size-2 shrink-0 bg-acid-lime pulse-acid" />
|
||||||
<RadioTower class="size-4" />
|
<span class="display text-base whitespace-nowrap">LIVE·SFU</span>
|
||||||
</span>
|
<span class="micro-label hidden text-muted-foreground md:inline">fan-out console</span>
|
||||||
<span class="truncate text-sm font-semibold sm:text-base">
|
|
||||||
直播 SFU <span class="acid-text">分流台</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center gap-1.5 sm:gap-2">
|
<nav class="ml-auto hidden items-center gap-5 md:flex" aria-label="主导航">
|
||||||
|
{navLinks('h-6')}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="ml-auto flex shrink-0 items-center gap-2 md:ml-0">
|
||||||
<Show when={permissions.isAuthed()}>
|
<Show when={permissions.isAuthed()}>
|
||||||
<span class="hidden items-center gap-2 rounded-md border bg-card py-1 pr-1 pl-1 sm:flex">
|
<span class="hidden items-center gap-1.5 border border-line bg-card/60 px-2 py-1 sm:flex">
|
||||||
<span class="flex size-5 items-center justify-center rounded-sm bg-primary/10 text-[11px] font-bold text-primary">
|
<span class="size-1.5 bg-acid-cyan" />
|
||||||
{(permissions.role()[0] ?? '').toUpperCase()}
|
<span class="font-mono text-[10px] tracking-[0.14em] uppercase text-muted-foreground">
|
||||||
|
{permissions.role()}
|
||||||
</span>
|
</span>
|
||||||
<Badge class={roleBadge(permissions.role())}>{permissions.role()}</Badge>
|
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Show when={!permissions.isAuthed()}>
|
<Show when={!permissions.isAuthed()}>
|
||||||
<Link to="/login" class="btn btn-outline btn-sm">
|
<Link to="/login" class="btn btn-primary btn-sm">
|
||||||
登录
|
登录
|
||||||
</Link>
|
</Link>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="mx-auto max-w-6xl overflow-x-auto px-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<nav class="overflow-x-auto border-t border-line/60 [scrollbar-width:none] md:hidden [&::-webkit-scrollbar]:hidden" aria-label="主导航(移动端)">
|
||||||
<div class="flex items-center gap-1">
|
<div class="mx-auto flex max-w-[1400px] items-center gap-4 px-4">
|
||||||
<For each={NAV}>
|
{navLinks('h-10')}
|
||||||
{(item) => (
|
|
||||||
<Show when={!item.adminOnly || permissions.can('user:manage')}>
|
|
||||||
<Link
|
|
||||||
to={item.to}
|
|
||||||
class={`flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors ${
|
|
||||||
isActive(item)
|
|
||||||
? 'bg-primary/10 text-primary'
|
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<item.icon class="size-4" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1">
|
<main class="relative z-10">
|
||||||
<div class="mx-auto max-w-6xl px-4 py-6 sm:py-8">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="border-t bg-background/70">
|
<footer class="relative z-10 mt-16 border-t border-line/80 bg-background/70 backdrop-blur-sm">
|
||||||
<div class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-2 px-4 py-4 text-xs text-muted-foreground sm:flex-row">
|
<div class="mx-auto flex max-w-[1400px] flex-col gap-1.5 px-4 py-4 font-mono text-[10px] tracking-[0.18em] uppercase text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||||
<span>Cloudflare Realtime · SRS · WHIP / WHEP / HLS</span>
|
<span>Cloudflare Realtime · SRS · WHIP / WHEP / HLS / FLV</span>
|
||||||
<span class="font-mono">live-sfu fan-out console</span>
|
<span>live-sfu fan-out console — 一路推流 多端扇出</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { LogIn, RadioTower } from 'lucide-solid'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { refreshMe } from '../../lib/auth'
|
import { refreshMe } from '../../lib/auth'
|
||||||
import { permissions } from '../../lib/permissions'
|
import { permissions } from '../../lib/permissions'
|
||||||
import { Alert, Button, Card, Select, TextInput } from '../../components/ui'
|
import { Alert, Button, Corners, Reveal, Select, TextInput } from '../../components/ui'
|
||||||
|
|
||||||
const DEMO_USERS = [
|
const DEMO_USERS = [
|
||||||
{ name: 'admin', role: '管理员', pass: 'Admin123!' },
|
{ name: 'admin', role: '管理员', pass: 'Admin123!' },
|
||||||
|
|
@ -37,22 +37,32 @@ export const Login: Component = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="mx-auto flex w-full max-w-md flex-col gap-4 py-4">
|
<div class="relative flex min-h-[calc(100vh-3rem)] items-center justify-center overflow-hidden px-4 py-10">
|
||||||
<div class="flex flex-col items-center gap-3 text-center">
|
<div
|
||||||
<span class="flex size-10 items-center justify-center rounded-lg border border-acid-lime/30 bg-primary text-primary-foreground shadow-sm">
|
class="ghost-text display pointer-events-none absolute -bottom-[7vw] left-1/2 -translate-x-1/2 text-[36vw] leading-none"
|
||||||
|
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" />
|
<RadioTower class="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold">登录分流控制台</h1>
|
<h1 class="display text-3xl">登录</h1>
|
||||||
<p class="mt-1 text-sm text-muted-foreground">发布、订阅与房间管理需要登录</p>
|
<p class="micro-label mt-1.5 text-muted-foreground">fan-out console / auth</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="p-6">
|
<form class="mt-7 space-y-5" onSubmit={submit}>
|
||||||
<form class="space-y-4" onSubmit={submit}>
|
<div class="space-y-2">
|
||||||
<div class="space-y-1.5">
|
<label class="micro-label flex items-center justify-between text-muted-foreground" for="login-username">
|
||||||
<label class="label" for="login-username">
|
用户名 <span class="opacity-50">username</span>
|
||||||
用户名
|
|
||||||
</label>
|
</label>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="login-username"
|
id="login-username"
|
||||||
|
|
@ -60,12 +70,12 @@ export const Login: Component = () => {
|
||||||
onInput={setUsername}
|
onInput={setUsername}
|
||||||
placeholder="admin"
|
placeholder="admin"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
class="h-9"
|
class="h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-2">
|
||||||
<label class="label" for="login-password">
|
<label class="micro-label flex items-center justify-between text-muted-foreground" for="login-password">
|
||||||
密码
|
密码 <span class="opacity-50">password</span>
|
||||||
</label>
|
</label>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="login-password"
|
id="login-password"
|
||||||
|
|
@ -74,11 +84,13 @@ export const Login: Component = () => {
|
||||||
onInput={setPassword}
|
onInput={setPassword}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
class="h-9"
|
class="h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-2">
|
||||||
<label class="label">角色</label>
|
<label class="micro-label flex items-center justify-between text-muted-foreground">
|
||||||
|
角色 <span class="opacity-50">role</span>
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={role()}
|
value={role()}
|
||||||
onChange={(v) => setRole(v as 'viewer' | 'publisher')}
|
onChange={(v) => setRole(v as 'viewer' | 'publisher')}
|
||||||
|
|
@ -86,42 +98,51 @@ export const Login: Component = () => {
|
||||||
{ value: 'viewer', label: '观看者 viewer' },
|
{ value: 'viewer', label: '观看者 viewer' },
|
||||||
{ value: 'publisher', label: '发布者 publisher' },
|
{ value: 'publisher', label: '发布者 publisher' },
|
||||||
]}
|
]}
|
||||||
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={busy()} class="w-full">
|
<Button type="submit" disabled={busy()} class="h-10 w-full">
|
||||||
<LogIn class="size-4" />
|
<LogIn class="size-4" />
|
||||||
{busy() ? '登录中…' : '登录'}
|
{busy() ? '登录中…' : '登录'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Show when={err()}>
|
<Show when={err()}>
|
||||||
<Alert tone="error" class="mt-4">
|
<Alert tone="error" class="mt-4">
|
||||||
{err()}
|
{err()}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Show>
|
</Show>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<div class="rounded-lg border bg-card p-4">
|
<Reveal delay={1} class="mt-4">
|
||||||
<p class="label mb-2">演示账号</p>
|
<div class="border border-line/80 bg-card/50 p-4">
|
||||||
<div class="grid gap-2 sm:grid-cols-3">
|
<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}>
|
<For each={DEMO_USERS}>
|
||||||
{(u) => (
|
{(u) => (
|
||||||
<button
|
<button
|
||||||
type="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={() => {
|
onClick={() => {
|
||||||
setUsername(u.name)
|
setUsername(u.name)
|
||||||
setPassword(u.pass)
|
setPassword(u.pass)
|
||||||
setRole(u.role === '管理员' ? 'publisher' : 'viewer')
|
setRole(u.role === '管理员' ? 'publisher' : 'viewer')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium">{u.name}</span>
|
<span class="flex items-baseline justify-between gap-2">
|
||||||
<span class="text-xs text-muted-foreground">{u.role}</span>
|
<span class="font-mono text-xs font-medium">{u.name}</span>
|
||||||
<span class="font-mono text-[10px] text-muted-foreground/80">{u.pass}</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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,16 @@ import {
|
||||||
CircleStop,
|
CircleStop,
|
||||||
Cloud,
|
Cloud,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
Link2,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Server,
|
Server,
|
||||||
Video,
|
Video,
|
||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { permissions } from '../../lib/permissions'
|
import { permissions } from '../../lib/permissions'
|
||||||
import { Alert, Badge, Button, Card, CheckboxRow, LiveDot } from '../../components/ui'
|
import { Alert, Badge, Button, CheckboxRow, Corners, LiveDot, Meter, Reveal } from '../../components/ui'
|
||||||
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
|
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
|
||||||
import { subscribeRoom } from '../../lib/sse'
|
import { subscribeRoom } from '../../lib/sse'
|
||||||
import { subscribeChat, sendChat, broadcastChat, type ChatMessage } from '../../lib/chat'
|
import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat'
|
||||||
import { whipPublish, cfPublish, toRTC } from '../../lib/webrtc'
|
import { whipPublish, cfPublish, toRTC } from '../../lib/webrtc'
|
||||||
import type { BackendInfo, RoomEvent, StreamTarget } from '../../lib/types'
|
import type { BackendInfo, RoomEvent, StreamTarget } from '../../lib/types'
|
||||||
|
|
||||||
|
|
@ -60,7 +59,7 @@ export const Publish: Component = () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const send = (text: string, color?: string) => {
|
const send = (text: string, color?: string) => {
|
||||||
broadcastChat(room(), text, color).catch(() => {})
|
sendChat(room(), text, color).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggle = (kind: string) => setSelected((s) => ({ ...s, [kind]: !s[kind] }))
|
const toggle = (kind: string) => setSelected((s) => ({ ...s, [kind]: !s[kind] }))
|
||||||
|
|
@ -126,19 +125,23 @@ export const Publish: Component = () => {
|
||||||
onCleanup(stopAll)
|
onCleanup(stopAll)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="mx-auto max-w-[1400px] space-y-5 px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<Reveal>
|
||||||
<div class="space-y-1.5">
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h1 class="text-2xl font-semibold">开始直播</h1>
|
<div>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="mono-label text-muted-foreground">publish / {room()}</p>
|
||||||
房间 <span class="font-mono text-foreground">{room()}</span> · 选择分发后端,一路推流将扇出到所选 SFU。
|
<h1 class="display mt-2 text-4xl sm:text-5xl">开始直播</h1>
|
||||||
|
<p class="mt-3 max-w-lg text-sm text-muted-foreground">
|
||||||
|
选择分发后端,一路推流将扇出到所选 SFU。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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()} />
|
<LiveDot active={running()} />
|
||||||
{running() ? '推流中' : '待机'}
|
<span class="mono-label">{running() ? '推流中 / live' : '待机 / standby'}</span>
|
||||||
</Badge>
|
<Meter active={running()} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<Show when={!permissions.can('room:publish')}>
|
<Show when={!permissions.can('room:publish')}>
|
||||||
<Alert tone="warning">
|
<Alert tone="warning">
|
||||||
|
|
@ -150,63 +153,89 @@ export const Publish: Component = () => {
|
||||||
<Alert tone="error">{error()}</Alert>
|
<Alert tone="error">{error()}</Alert>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||||
<Card class="space-y-3 p-5">
|
<Reveal delay={1} class="min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="relative h-full border border-line bg-card/50 p-4 sm:p-5">
|
||||||
|
<Corners />
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex items-center 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>
|
<h3 class="font-semibold">本地预览</h3>
|
||||||
|
<span class="micro-label text-muted-foreground">preview</span>
|
||||||
</div>
|
</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)}>
|
<Button variant="ghost" size="sm" onClick={() => setShowDanmaku((v) => !v)}>
|
||||||
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
|
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
|
||||||
</Button>
|
</Button>
|
||||||
<span class="font-mono text-xs text-muted-foreground">
|
|
||||||
{stream() ? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频` : 'off'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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" />
|
<video ref={(el) => (videoEl = el)} autoplay playsinline muted class="size-full" />
|
||||||
<Show when={showDanmaku()}>
|
<Show when={showDanmaku()}>
|
||||||
<DanmakuOverlay messages={msgs} />
|
<DanmakuOverlay messages={msgs} />
|
||||||
</Show>
|
</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()}>
|
<Show when={!stream()}>
|
||||||
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 text-white/60">
|
<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 rounded-full border border-white/20 bg-white/5">
|
<span class="flex size-12 items-center justify-center border border-white/20 bg-white/5">
|
||||||
<Video class="size-5" />
|
<Video class="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs">摄像头未开启</span>
|
<span class="micro-label text-white/50">camera off</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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" />
|
<Video class="size-4" />
|
||||||
开始推流
|
开始推流
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={stopAll} disabled={!running()}>
|
<Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
|
||||||
<CircleStop class="size-4" />
|
<CircleStop class="size-4" />
|
||||||
停止推流
|
停止推流
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<Card class="space-y-3 p-5">
|
<Reveal delay={2} class="min-w-0 self-start">
|
||||||
<div class="flex items-center justify-between">
|
<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">
|
<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>
|
<h3 class="font-semibold">分发后端</h3>
|
||||||
|
<span class="micro-label text-muted-foreground">routing</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{backends().length} 路</Badge>
|
<Badge variant="muted">{backends().length} 路</Badge>
|
||||||
</div>
|
</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()}>
|
<For each={backends()}>
|
||||||
{(b) => {
|
{(b) => {
|
||||||
const Icon = backendIcon(b.kind)
|
const Icon = backendIcon(b.kind)
|
||||||
return (
|
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">
|
<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" />
|
<Icon class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
|
@ -216,79 +245,95 @@ export const Publish: Component = () => {
|
||||||
onChange={() => toggle(b.kind)}
|
onChange={() => toggle(b.kind)}
|
||||||
disabled={running()}
|
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' : '对照后端'}
|
{backendLabel(b.kind)} · {b.primary ? '主 SFU' : '对照后端'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={b.configured ? 'success' : 'warning'}>{b.configured ? '已配置' : '未配置'}</Badge>
|
<Badge variant={b.configured ? 'success' : 'muted'}>
|
||||||
|
{b.configured ? '在线' : '未配置'}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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 密钥。
|
推流凭证仅在服务端会话中生效,浏览器端不会出现 SFU 密钥。
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</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">
|
<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>
|
<h3 class="font-semibold">分发状态</h3>
|
||||||
|
<span class="micro-label text-muted-foreground">fan-out status</span>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={targets().length > 0}
|
when={targets().length > 0}
|
||||||
fallback={
|
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()}>
|
<For each={targets()}>
|
||||||
{(t) => (
|
{(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">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="flex min-w-0 items-center gap-2 text-sm font-medium">
|
<span class="truncate text-sm font-medium">{backendLabel(t.backend)}</span>
|
||||||
{backendLabel(t.backend)}
|
<Badge variant="success">
|
||||||
</span>
|
<LiveDot active />
|
||||||
<Badge variant="success">直播中</Badge>
|
直播中
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Show when={t.stream}>
|
<Show when={t.stream}>
|
||||||
<div class="flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.m3u8`} target="_blank">
|
<a class="mono-chip" href={`/live/${t.stream}.m3u8`} target="_blank" rel="noreferrer">
|
||||||
<FileVideo class="size-3.5" />
|
<FileVideo class="size-3" />
|
||||||
HLS
|
HLS
|
||||||
</a>
|
</a>
|
||||||
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
|
<a class="mono-chip" href={`/live/${t.stream}.flv`} target="_blank" rel="noreferrer">
|
||||||
<FileVideo class="size-3.5" />
|
<FileVideo class="size-3" />
|
||||||
FLV
|
FLV
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={t.session_id}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<Card class="flex flex-col gap-3 p-5">
|
<Reveal delay={2}>
|
||||||
<div class="flex items-center justify-between">
|
<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">
|
<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>
|
<h3 class="font-semibold">房间弹幕 / 聊天</h3>
|
||||||
|
<span class="micro-label text-muted-foreground">danmaku</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="muted">{msgs().length}</Badge>
|
<Badge variant="muted">{msgs().length}</Badge>
|
||||||
</div>
|
</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} />
|
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Component, createSignal, createEffect, For, Show } from 'solid-js'
|
import { Component, createSignal, createEffect, For, Show } from 'solid-js'
|
||||||
import { useNavigate } from '@tanstack/solid-router'
|
import { useNavigate } from '@tanstack/solid-router'
|
||||||
import { ArrowRight, DoorOpen, Plus, Radio } from 'lucide-solid'
|
import { ArrowRight, DoorOpen, Plus } from 'lucide-solid'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { permissions } from '../../lib/permissions'
|
import { permissions } from '../../lib/permissions'
|
||||||
import { Button, Card, LiveDot, TextInput } from '../../components/ui'
|
import { Button, Corners, LiveDot, Reveal } from '../../components/ui'
|
||||||
import type { Room } from '../../lib/types'
|
import type { Room } from '../../lib/types'
|
||||||
|
|
||||||
export const Rooms: Component = () => {
|
export const Rooms: Component = () => {
|
||||||
|
|
@ -39,44 +39,55 @@ export const Rooms: Component = () => {
|
||||||
const enter = (room: string) => navigate({ to: '/room', search: { room } })
|
const enter = (room: string) => navigate({ to: '/room', search: { room } })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="mx-auto max-w-[1400px] space-y-10 px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<div class="space-y-1.5">
|
<Reveal>
|
||||||
<h1 class="text-2xl font-semibold">房间</h1>
|
<div>
|
||||||
<p class="text-sm text-muted-foreground">创建或进入房间,房间内可观看、发弹幕与查看分发状态。</p>
|
<p class="mono-label text-muted-foreground">rooms / registry</p>
|
||||||
|
<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>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<Card class="space-y-3 p-5">
|
<Reveal delay={1}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="relative max-w-2xl border border-line bg-card/60 p-3 sm:p-4">
|
||||||
<Plus class="size-4 text-primary" />
|
<Corners />
|
||||||
<h3 class="font-semibold">创建房间</h3>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
<TextInput
|
<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">
|
||||||
value={name()}
|
<Plus class="size-4 shrink-0 text-muted-foreground" />
|
||||||
onInput={setName}
|
<input
|
||||||
|
class="field-mono h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
|
||||||
placeholder="房间名,如 demo"
|
placeholder="房间名,如 demo"
|
||||||
class="sm:max-w-xs"
|
value={name()}
|
||||||
|
onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') create()
|
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" />
|
<ArrowRight class="size-4" />
|
||||||
创建并进入
|
创建并进入
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<div class="flex items-center gap-2">
|
<Reveal>
|
||||||
<Radio class="size-4 text-primary" />
|
<div class="flex items-center justify-between gap-3 border-b border-line pb-3">
|
||||||
<h2 class="text-lg font-semibold">房间列表</h2>
|
<h2 class="font-semibold">房间列表</h2>
|
||||||
|
<span class="mono-label text-muted-foreground">{rooms().length} rooms</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={rooms().length > 0}
|
when={rooms().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col items-center gap-3 rounded-lg border bg-card px-6 py-10 text-center">
|
<div class="flex flex-col items-center gap-3 border border-dashed border-line px-6 py-12 text-center">
|
||||||
<span class="flex size-10 items-center justify-center rounded-full bg-secondary text-muted-foreground">
|
<span class="flex size-10 items-center justify-center border border-line bg-card text-muted-foreground">
|
||||||
<DoorOpen class="size-5" />
|
<DoorOpen class="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -86,24 +97,31 @@ export const Rooms: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div>
|
||||||
<For each={rooms()}>
|
<For each={rooms()}>
|
||||||
{(r) => (
|
{(r, i) => (
|
||||||
<Card class="flex flex-col gap-3 p-4">
|
<Reveal delay={Math.min(i() + 1, 5)}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="group -mx-3 grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-line/80 py-4 transition-colors hover:bg-accent/40 sm:mx-0 sm:-mx-4 sm:grid-cols-[3rem_auto_minmax(0,1fr)_auto] sm:gap-4 sm:px-4">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<span class="hidden font-mono text-xs text-muted-foreground/70 sm:block">
|
||||||
<LiveDot active={(r.targets?.length ?? 0) > 0} />
|
{String(i() + 1).padStart(2, '0')}
|
||||||
<span class="truncate font-medium">{r.name}</span>
|
|
||||||
</div>
|
|
||||||
<span class="shrink-0 font-mono text-xs text-muted-foreground">
|
|
||||||
{r.targets?.length ?? 0} 路分发
|
|
||||||
</span>
|
</span>
|
||||||
|
<LiveDot active={(r.targets?.length ?? 0) > 0} />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="display truncate text-xl transition-colors sm:text-2xl dark:group-hover:text-primary">
|
||||||
|
{r.name}
|
||||||
|
</p>
|
||||||
|
<p class="mono-label mt-1 text-muted-foreground">
|
||||||
|
{r.targets?.length ?? 0} 路分发 · {(r.targets?.length ?? 0) > 0 ? 'live' : 'idle'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<ArrowRight class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Component, createSignal, For, Show, createEffect } from 'solid-js'
|
||||||
import { ShieldAlert, ShieldCheck, UserRound } from 'lucide-solid'
|
import { ShieldAlert, ShieldCheck, UserRound } from 'lucide-solid'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { permissions } from '../../lib/permissions'
|
import { permissions } from '../../lib/permissions'
|
||||||
import { Alert, Badge, Card, Select } from '../../components/ui'
|
import { Alert, Badge, Reveal, Select } from '../../components/ui'
|
||||||
import type { Role, UserInfo } from '../../lib/types'
|
import type { Role, UserInfo } from '../../lib/types'
|
||||||
|
|
||||||
const ROLES: Role[] = ['viewer', 'publisher', 'admin']
|
const ROLES: Role[] = ['viewer', 'publisher', 'admin']
|
||||||
|
|
@ -11,22 +11,22 @@ const ROLE_META: Record<Role, { label: string; badge: string; avatar: string }>
|
||||||
admin: {
|
admin: {
|
||||||
label: '管理员',
|
label: '管理员',
|
||||||
badge: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta',
|
badge: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta',
|
||||||
avatar: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta',
|
avatar: 'border-acid-magenta/40 bg-acid-magenta/10 text-acid-magenta',
|
||||||
},
|
},
|
||||||
publisher: {
|
publisher: {
|
||||||
label: '发布者',
|
label: '发布者',
|
||||||
badge: 'border-warning/30 bg-warning/10 text-warning',
|
badge: 'border-warning/30 bg-warning/10 text-warning',
|
||||||
avatar: 'border-warning/30 bg-warning/10 text-warning',
|
avatar: 'border-warning/40 bg-warning/10 text-warning',
|
||||||
},
|
},
|
||||||
viewer: {
|
viewer: {
|
||||||
label: '观看者',
|
label: '观看者',
|
||||||
badge: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan',
|
badge: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan',
|
||||||
avatar: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan',
|
avatar: 'border-acid-cyan/40 bg-acid-cyan/10 text-acid-cyan',
|
||||||
},
|
},
|
||||||
guest: {
|
guest: {
|
||||||
label: '访客',
|
label: '访客',
|
||||||
badge: 'border-transparent bg-muted text-muted-foreground',
|
badge: 'border-line bg-transparent text-muted-foreground',
|
||||||
avatar: 'border-transparent bg-muted text-muted-foreground',
|
avatar: 'border-line bg-muted text-muted-foreground',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,11 +52,16 @@ export const Users: Component = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="mx-auto max-w-[1400px] space-y-8 px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<div class="space-y-1.5">
|
<Reveal>
|
||||||
<h1 class="text-2xl font-semibold">用户与角色</h1>
|
<div>
|
||||||
<p class="text-sm text-muted-foreground">基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。</p>
|
<p class="mono-label text-muted-foreground">users / rbac</p>
|
||||||
|
<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>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={permissions.can('user:list')}
|
when={permissions.can('user:list')}
|
||||||
|
|
@ -69,40 +74,41 @@ export const Users: Component = () => {
|
||||||
</Alert>
|
</Alert>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card>
|
<Reveal delay={1}>
|
||||||
|
<div class="border border-line bg-card/50">
|
||||||
<Show when={err()}>
|
<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()}
|
{err()}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Show>
|
</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">
|
<div class="hidden grid-cols-[minmax(0,1fr)_8rem_10rem] gap-4 border-b border-line/80 px-5 py-3 sm:grid">
|
||||||
<span>用户名</span>
|
<span class="micro-label text-muted-foreground">username</span>
|
||||||
<span class="w-28">当前角色</span>
|
<span class="micro-label text-muted-foreground">role</span>
|
||||||
<span class="w-32 text-right">操作</span>
|
<span class="micro-label text-right text-muted-foreground">action</span>
|
||||||
</div>
|
</div>
|
||||||
<For each={users()}>
|
<For each={users()}>
|
||||||
{(u, i) => (
|
{(u, i) => (
|
||||||
<div
|
<div
|
||||||
class={`grid grid-cols-1 items-center gap-3 px-5 py-3 sm:grid-cols-[1fr_auto_auto] sm:gap-4 ${
|
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' : ''
|
i() < users().length - 1 ? 'border-b border-line/70' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<span
|
<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()}
|
{(u.username[0] ?? '?').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-medium">{u.username}</p>
|
<p class="truncate font-mono text-sm font-medium">{u.username}</p>
|
||||||
<p class="font-mono text-[11px] text-muted-foreground">{ROLE_META[u.role].label}</p>
|
<p class="micro-label mt-1 text-muted-foreground">{ROLE_META[u.role].label}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge class={ROLE_META[u.role].badge}>{u.role}</Badge>
|
<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
|
<Show
|
||||||
when={permissions.can('user:manage')}
|
when={permissions.can('user:manage')}
|
||||||
fallback={<UserRound class="size-4 text-muted-foreground/60" />}
|
fallback={<UserRound class="size-4 text-muted-foreground/50" />}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
class="w-32"
|
class="w-32"
|
||||||
|
|
@ -122,7 +128,8 @@ export const Users: Component = () => {
|
||||||
暂无用户数据
|
暂无用户数据
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
Link2,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MonitorPlay,
|
MonitorPlay,
|
||||||
Radio,
|
Radio,
|
||||||
|
|
@ -14,7 +13,7 @@ import {
|
||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { permissions } from '../../lib/permissions'
|
import { permissions } from '../../lib/permissions'
|
||||||
import { Alert, Badge, Button, Card, LiveDot, Segmented } from '../../components/ui'
|
import { Alert, Badge, Button, Corners, LiveDot, Reveal, Segmented } from '../../components/ui'
|
||||||
import { subscribeRoom } from '../../lib/sse'
|
import { subscribeRoom } from '../../lib/sse'
|
||||||
import { whepSubscribe, cfSubscribe, toRTC } from '../../lib/webrtc'
|
import { whepSubscribe, cfSubscribe, toRTC } from '../../lib/webrtc'
|
||||||
import { playHLS } from '../../lib/hls'
|
import { playHLS } from '../../lib/hls'
|
||||||
|
|
@ -108,27 +107,44 @@ function RoomHub(props: { room: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="mx-auto max-w-[1400px] space-y-5 px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
<Reveal>
|
||||||
<div class="space-y-1.5">
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h1 class="text-2xl font-semibold">房间 <span class="font-mono">{room()}</span></h1>
|
<div class="min-w-0">
|
||||||
<p class="text-sm text-muted-foreground">观看、发弹幕与查看分发状态。</p>
|
<p class="mono-label text-muted-foreground">watch / room</p>
|
||||||
|
<h1 class="display mt-2 truncate text-4xl sm:text-5xl">{room()}</h1>
|
||||||
|
<p class="mt-3 text-sm text-muted-foreground">观看、发弹幕与查看分发状态。</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div class="flex items-center gap-2.5 border border-line bg-card/60 px-3.5 py-2.5">
|
||||||
|
<LiveDot active={targets().length > 0} />
|
||||||
|
<span class="mono-label">
|
||||||
|
{targets().length} targets · {targets().length > 0 ? 'live' : 'idle'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={permissions.can('room:publish')}>
|
<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" />
|
<Video class="size-4" />
|
||||||
开始直播到该房间
|
开始直播到该房间
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<Alert tone="error">{error()}</Alert>
|
<Alert tone="error">{error()}</Alert>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
<Card class="space-y-3 p-4 lg:col-span-2 lg:p-5">
|
<Reveal delay={1} class="min-w-0 lg:col-span-2">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="relative h-full border border-line bg-card/50 p-4 sm:p-5">
|
||||||
|
<Corners />
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Segmented
|
<Segmented
|
||||||
value={mode()}
|
value={mode()}
|
||||||
onChange={(v) => setMode(v)}
|
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" /> },
|
{ 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>
|
||||||
|
|
||||||
<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" />
|
<video ref={(el) => (videoEl = el)} autoplay playsinline class="size-full" />
|
||||||
<Show when={showDanmaku()}>
|
<Show when={showDanmaku()}>
|
||||||
<DanmakuOverlay messages={msgs} />
|
<DanmakuOverlay messages={msgs} />
|
||||||
</Show>
|
</Show>
|
||||||
<div class="pointer-events-none absolute top-3 left-3 flex flex-wrap items-center gap-1.5">
|
<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()} />
|
<LiveDot active={running()} />
|
||||||
{running() ? '连接中' : '未连接'}
|
{running() ? 'CONNECTING' : 'OFFLINE'}
|
||||||
</Badge>
|
</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()]}
|
{MODE_LABEL[mode()]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!running()}>
|
<Show when={!running()}>
|
||||||
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 text-white/60">
|
<div class="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 rounded-full border border-white/20 bg-white/5">
|
<span class="flex size-12 items-center justify-center border border-white/20 bg-white/5">
|
||||||
<MonitorPlay class="size-5" />
|
<MonitorPlay class="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs">等待接入</span>
|
<span class="micro-label text-white/50">standby · 等待接入</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<Button onClick={start} disabled={running()}>
|
<Button class="h-10" onClick={start} disabled={running()}>
|
||||||
<PlayIcon />
|
<PlayIcon />
|
||||||
开始观看
|
开始观看
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={stopAll} disabled={!running()}>
|
<Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
|
||||||
<Square class="size-4" />
|
<Square class="size-4" />
|
||||||
停止
|
停止
|
||||||
</Button>
|
</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() ? <EyeOff class="size-4" /> : <Eye class="size-4" />}
|
||||||
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
|
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
<Card class="flex h-[28rem] flex-col space-y-3 p-4 lg:p-5">
|
<Reveal delay={2} class="min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<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">
|
<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>
|
<h3 class="font-semibold">弹幕 / 聊天</h3>
|
||||||
|
<span class="micro-label hidden text-muted-foreground sm:inline">danmaku</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{msgs().length}</Badge>
|
<Badge variant="muted">{msgs().length}</Badge>
|
||||||
</div>
|
</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} />
|
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</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">
|
<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>
|
<h3 class="font-semibold">分发状态</h3>
|
||||||
|
<span class="micro-label text-muted-foreground">fan-out status</span>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={targets().length > 0}
|
when={targets().length > 0}
|
||||||
fallback={
|
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()}>
|
<For each={targets()}>
|
||||||
{(t) => (
|
{(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">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="text-sm font-medium">{backendText(t.backend)}</span>
|
<span class="truncate text-sm font-medium">{backendText(t.backend)}</span>
|
||||||
<Badge variant="success">直播中</Badge>
|
<Badge variant="success">
|
||||||
|
<LiveDot active />
|
||||||
|
直播中
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Show when={t.stream}>
|
<Show when={t.stream}>
|
||||||
<div class="flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.m3u8`} target="_blank">
|
<a class="mono-chip" href={`/live/${t.stream}.m3u8`} target="_blank" rel="noreferrer">
|
||||||
<FileVideo class="size-3.5" />
|
<FileVideo class="size-3" />
|
||||||
HLS
|
HLS
|
||||||
</a>
|
</a>
|
||||||
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
|
<a class="mono-chip" href={`/live/${t.stream}.flv`} target="_blank" rel="noreferrer">
|
||||||
<FileVideo class="size-3.5" />
|
<FileVideo class="size-3" />
|
||||||
FLV
|
FLV
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={t.session_id}>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Card>
|
</div>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function DanmakuOverlay(props: { messages: () => ChatMessage[] }) {
|
||||||
const top = tops[lane % tops.length]
|
const top = tops[lane % tops.length]
|
||||||
lane++
|
lane++
|
||||||
const dur = 7 + Math.random() * 4
|
const dur = 7 + Math.random() * 4
|
||||||
const item: Active = { key: ++seq, text: (last.host ? '主播:' : '') + last.message, color: last.color || '#f1f5f9', top, dur }
|
const item: Active = { key: ++seq, text: last.message, color: last.color || '#f1f5f9', top, dur }
|
||||||
setItems((p) => {
|
setItems((p) => {
|
||||||
const next = [...p, item]
|
const next = [...p, item]
|
||||||
return next.length > 50 ? next.slice(next.length - 50) : next
|
return next.length > 50 ? next.slice(next.length - 50) : next
|
||||||
|
|
@ -59,9 +59,9 @@ function measureTime(ts: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function roleColor(role: string) {
|
function roleColor(role: string) {
|
||||||
if (role === 'admin') return 'text-acid-magenta border-acid-magenta/30 bg-acid-magenta/10'
|
if (role === 'admin') return 'border-acid-magenta/40 bg-acid-magenta/10 text-acid-magenta'
|
||||||
if (role === 'publisher') return 'text-warning border-warning/30 bg-warning/10'
|
if (role === 'publisher') return 'border-warning/40 bg-warning/10 text-warning'
|
||||||
return 'text-acid-cyan border-acid-cyan/30 bg-acid-cyan/10'
|
return 'border-acid-cyan/40 bg-acid-cyan/10 text-acid-cyan'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聊天/弹幕侧栏:消息列表 + 发送框(权限由 canChat 控制)
|
// 聊天/弹幕侧栏:消息列表 + 发送框(权限由 canChat 控制)
|
||||||
|
|
@ -85,22 +85,19 @@ export function ChatPanel(props: {
|
||||||
<Show when={props.messages().length > 0}>
|
<Show when={props.messages().length > 0}>
|
||||||
<For each={props.messages()}>
|
<For each={props.messages()}>
|
||||||
{(m) => (
|
{(m) => (
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2.5">
|
||||||
<span
|
<span
|
||||||
class={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-[11px] font-bold ${roleColor(
|
class={`mt-0.5 flex size-6 shrink-0 items-center justify-center border font-mono text-[10px] font-bold ${roleColor(
|
||||||
String(m.role),
|
String(m.role),
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
{(m.user[0] ?? '?').toUpperCase()}
|
{(m.user[0] ?? '?').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-baseline gap-x-1.5">
|
<div class="flex flex-wrap items-baseline gap-x-2">
|
||||||
<span class="text-xs font-medium">{m.user}</span>
|
<span class="font-mono text-xs font-medium">{m.user}</span>
|
||||||
<span class="text-[11px] text-muted-foreground">{m.role}</span>
|
<span class="micro-label text-[9px] text-muted-foreground">{String(m.role)}</span>
|
||||||
<Show when={m.host}>
|
<span class="font-mono text-[9px] text-muted-foreground/60">{measureTime(m.ts)}</span>
|
||||||
<span class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">主播</span>
|
|
||||||
</Show>
|
|
||||||
<span class="font-mono text-[10px] text-muted-foreground/70">{measureTime(m.ts)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-0.5 text-sm leading-snug break-words">{m.message}</p>
|
<p class="mt-0.5 text-sm leading-snug break-words">{m.message}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,20 +106,21 @@ export function ChatPanel(props: {
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.messages().length === 0}>
|
<Show when={props.messages().length === 0}>
|
||||||
<div class="flex h-full min-h-24 flex-col items-center justify-center gap-2 text-center">
|
<div class="flex h-full min-h-24 flex-col items-center justify-center gap-2.5 text-center">
|
||||||
<MessageSquare class="size-5 text-muted-foreground/50" />
|
<MessageSquare class="size-5 text-muted-foreground/50" />
|
||||||
<p class="text-sm text-muted-foreground">还没有弹幕</p>
|
<p class="text-sm text-muted-foreground">还没有弹幕</p>
|
||||||
|
<p class="micro-label text-muted-foreground/50">danmaku channel idle</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={props.canChat}
|
when={props.canChat}
|
||||||
fallback={<p class="border-t p-3 text-xs text-muted-foreground">登录后可在房间内发送弹幕</p>}
|
fallback={<p class="border-t border-line/80 p-3 text-xs text-muted-foreground">登录后可在房间内发送弹幕</p>}
|
||||||
>
|
>
|
||||||
<div class="flex gap-2 border-t p-2">
|
<div class="flex gap-2 border-t border-line/80 p-2.5">
|
||||||
<input
|
<input
|
||||||
class="field h-9 text-sm"
|
class="field field-mono h-9 flex-1 text-sm"
|
||||||
placeholder="发条弹幕"
|
placeholder="发条弹幕…"
|
||||||
value={text()}
|
value={text()}
|
||||||
aria-label="弹幕内容"
|
aria-label="弹幕内容"
|
||||||
onInput={(e) => setText((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => setText((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { For, Show, type JSX } from 'solid-js'
|
import { For, onCleanup, onMount, Show, type JSX } from 'solid-js'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -134,7 +134,7 @@ export function Select(props: {
|
||||||
return (
|
return (
|
||||||
<div class={`relative ${props.class ?? ''}`}>
|
<div class={`relative ${props.class ?? ''}`}>
|
||||||
<select
|
<select
|
||||||
class="field appearance-none pr-8"
|
class="field field-mono h-9 appearance-none pr-8 text-xs"
|
||||||
value={props.value}
|
value={props.value}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
aria-label={props['aria-label']}
|
aria-label={props['aria-label']}
|
||||||
|
|
@ -151,12 +151,12 @@ export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'wa
|
||||||
|
|
||||||
const BADGE_TONE: Record<BadgeVariant, string> = {
|
const BADGE_TONE: Record<BadgeVariant, string> = {
|
||||||
default: 'border-transparent bg-primary text-primary-foreground',
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
secondary: 'border-line bg-secondary text-secondary-foreground',
|
||||||
outline: 'text-foreground',
|
outline: 'border-line text-foreground',
|
||||||
success: 'border-success/25 bg-success/10 text-success',
|
success: 'border-success/30 bg-success/10 text-success',
|
||||||
warning: 'border-warning/25 bg-warning/10 text-warning',
|
warning: 'border-warning/30 bg-warning/10 text-warning',
|
||||||
danger: 'border-destructive/25 bg-destructive/10 text-destructive',
|
danger: 'border-destructive/30 bg-destructive/10 text-destructive',
|
||||||
muted: 'border-transparent bg-muted text-muted-foreground',
|
muted: 'border-line bg-transparent text-muted-foreground',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Badge(props: { variant?: BadgeVariant; class?: string; children: JSX.Element }) {
|
export function Badge(props: { variant?: BadgeVariant; class?: string; children: JSX.Element }) {
|
||||||
|
|
@ -165,19 +165,19 @@ export function Badge(props: { variant?: BadgeVariant; class?: string; children:
|
||||||
|
|
||||||
const ALERT_TONE = {
|
const ALERT_TONE = {
|
||||||
error: {
|
error: {
|
||||||
box: 'border-destructive/30 bg-destructive/8 text-destructive',
|
box: 'border-destructive/40 bg-destructive/10 text-destructive',
|
||||||
icon: CircleAlert,
|
icon: CircleAlert,
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
box: 'border-warning/30 bg-warning/10 text-warning',
|
box: 'border-warning/40 bg-warning/10 text-warning',
|
||||||
icon: TriangleAlert,
|
icon: TriangleAlert,
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
box: 'border-success/30 bg-success/10 text-success',
|
box: 'border-success/40 bg-success/10 text-success',
|
||||||
icon: CircleCheck,
|
icon: CircleCheck,
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
box: 'border-acid-cyan/30 bg-acid-cyan/8 text-accent-foreground',
|
box: 'border-acid-cyan/40 bg-acid-cyan/10 text-accent-foreground',
|
||||||
icon: Info,
|
icon: Info,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ export function Alert(props: {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={tone === 'error' ? 'alert' : undefined}
|
role={tone === 'error' ? 'alert' : undefined}
|
||||||
class={`flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm ${style.box} ${props.class ?? ''}`}
|
class={`flex items-start gap-2.5 border px-3 py-2.5 text-sm ${style.box} ${props.class ?? ''}`}
|
||||||
>
|
>
|
||||||
<Icon class="mt-px size-4 shrink-0" />
|
<Icon class="mt-px size-4 shrink-0" />
|
||||||
<div class="min-w-0 flex-1">{props.children}</div>
|
<div class="min-w-0 flex-1">{props.children}</div>
|
||||||
|
|
@ -204,8 +204,8 @@ export function Alert(props: {
|
||||||
export function LiveDot(props: { active?: boolean; class?: string }) {
|
export function LiveDot(props: { active?: boolean; class?: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
class={`relative inline-flex size-2 shrink-0 rounded-full ${
|
class={`inline-block size-2 shrink-0 ${
|
||||||
props.active ? 'bg-success shadow-[0_0_0_3px_hsl(var(--success)/0.14)]' : 'bg-muted-foreground/40'
|
props.active ? 'bg-success pulse' : 'bg-muted-foreground/40'
|
||||||
} ${props.class ?? ''}`}
|
} ${props.class ?? ''}`}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -241,10 +241,10 @@ export function CheckboxRow(props: { label: string; checked: boolean; onChange:
|
||||||
class="group flex cursor-pointer items-center gap-2.5 select-none"
|
class="group flex cursor-pointer items-center gap-2.5 select-none"
|
||||||
>
|
>
|
||||||
<CheckboxControl
|
<CheckboxControl
|
||||||
class={`flex size-4 shrink-0 items-center justify-center rounded-sm border shadow-sm transition-colors ${
|
class={`flex size-4 shrink-0 items-center justify-center border transition-colors ${
|
||||||
props.checked
|
props.checked
|
||||||
? 'border-primary bg-primary text-primary-foreground'
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
: 'border-input bg-card text-transparent group-hover:border-primary/50'
|
: 'border-input bg-card text-transparent group-hover:border-primary/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CheckboxIndicator class="leading-none">
|
<CheckboxIndicator class="leading-none">
|
||||||
|
|
@ -297,22 +297,25 @@ export function Segmented<T extends string>(props: {
|
||||||
return (
|
return (
|
||||||
<div class={props.class ?? ''}>
|
<div class={props.class ?? ''}>
|
||||||
<Show when={props.label}>
|
<Show when={props.label}>
|
||||||
<span class="label mb-2 inline-block">{props.label}</span>
|
<span class="micro-label mb-2 inline-block text-muted-foreground">{props.label}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-md border bg-muted p-1">
|
<div class="inline-flex max-w-full border border-line">
|
||||||
<For each={props.options}>
|
<For each={props.options}>
|
||||||
{(o) => (
|
{(o) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-sm px-3 text-xs font-medium whitespace-nowrap transition-colors focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none ${
|
class={`relative inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 border-r border-line px-3 font-mono text-[11px] tracking-[0.14em] uppercase whitespace-nowrap transition-colors last:border-r-0 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none ${
|
||||||
o.value === props.value
|
o.value === props.value
|
||||||
? 'bg-card text-foreground shadow-sm'
|
? 'bg-acid-lime/15 text-foreground dark:bg-primary/10 dark:text-primary'
|
||||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'
|
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => props.onChange(o.value)}
|
onClick={() => props.onChange(o.value)}
|
||||||
>
|
>
|
||||||
{o.icon}
|
{o.icon}
|
||||||
{o.label}
|
{o.label}
|
||||||
|
<Show when={o.value === props.value}>
|
||||||
|
<span class="absolute inset-x-0 top-0 h-0.5 bg-foreground dark:bg-primary" />
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -320,3 +323,86 @@ export function Segmented<T extends string>(props: {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== HUD 四角(父元素需 relative) ===== */
|
||||||
|
export function Corners(props: { class?: string }) {
|
||||||
|
const c = props.class ?? ''
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span class={`hud-corner hud-tl ${c}`} aria-hidden="true" />
|
||||||
|
<span class={`hud-corner hud-tr ${c}`} aria-hidden="true" />
|
||||||
|
<span class={`hud-corner hud-bl ${c}`} aria-hidden="true" />
|
||||||
|
<span class={`hud-corner hud-br ${c}`} aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 信号电平条 ===== */
|
||||||
|
export function Meter(props: { active?: boolean; class?: string }) {
|
||||||
|
return (
|
||||||
|
<span class={`meter ${props.active ? '' : 'idle'} ${props.class ?? ''}`} aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 滚动入场(IntersectionObserver) ===== */
|
||||||
|
export function Reveal(props: { class?: string; delay?: number; children: JSX.Element }) {
|
||||||
|
let el: HTMLDivElement | undefined
|
||||||
|
onMount(() => {
|
||||||
|
const node = el
|
||||||
|
if (!node) return
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
node.classList.add('is-in')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
node.classList.add('is-in')
|
||||||
|
io.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.08, rootMargin: '0px 0px -6% 0px' },
|
||||||
|
)
|
||||||
|
io.observe(node)
|
||||||
|
onCleanup(() => io.disconnect())
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div ref={(n) => (el = n)} class={`reveal ${props.class ?? ''}`} style={{ '--i': String(props.delay ?? 0) } as any}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 跑马灯(内容复制两份实现无缝循环) ===== */
|
||||||
|
export function Marquee(props: { items: string[]; class?: string }) {
|
||||||
|
const row = (aria: boolean) => (
|
||||||
|
<div class="flex shrink-0 items-center" aria-hidden={aria || undefined}>
|
||||||
|
<For each={props.items}>
|
||||||
|
{(t) => (
|
||||||
|
<>
|
||||||
|
<span class="px-5 font-mono text-[11px] tracking-[0.22em] uppercase text-muted-foreground sm:px-7">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
<span class="size-1.5 shrink-0 rotate-45 bg-acid-lime" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div class={`marquee border-y border-line/80 py-3 ${props.class ?? ''}`}>
|
||||||
|
<div class="marquee-track">
|
||||||
|
{row(false)}
|
||||||
|
{row(true)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,75 +4,75 @@
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
/* ===== shadcn/ui tokens · Acid(克制版) ===== */
|
/* ===== design tokens · ACID —— 电光酸性 / 强对比 ===== */
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.5rem;
|
--radius: 0.25rem;
|
||||||
|
|
||||||
--background: 56 42% 96%;
|
--background: 0 0% 97%;
|
||||||
--foreground: 248 20% 9%;
|
--foreground: 0 0% 5%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 248 20% 9%;
|
--card-foreground: 0 0% 5%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 248 20% 9%;
|
--popover-foreground: 0 0% 5%;
|
||||||
|
|
||||||
--primary: 108 60% 30%;
|
--primary: 78 100% 42%;
|
||||||
--primary-foreground: 60 40% 98%;
|
--primary-foreground: 0 0% 4%;
|
||||||
--secondary: 55 30% 91%;
|
--secondary: 0 0% 93%;
|
||||||
--secondary-foreground: 248 18% 13%;
|
--secondary-foreground: 0 0% 8%;
|
||||||
--muted: 54 26% 91%;
|
--muted: 0 0% 93%;
|
||||||
--muted-foreground: 248 9% 42%;
|
--muted-foreground: 0 0% 38%;
|
||||||
--accent: 188 44% 90%;
|
--accent: 0 0% 94%;
|
||||||
--accent-foreground: 201 50% 16%;
|
--accent-foreground: 0 0% 5%;
|
||||||
|
|
||||||
--destructive: 2 70% 46%;
|
--destructive: 0 85% 45%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--success: 143 58% 34%;
|
--success: 142 80% 26%;
|
||||||
--warning: 34 88% 38%;
|
--warning: 40 100% 32%;
|
||||||
|
|
||||||
--border: 55 16% 84%;
|
--border: 0 0% 14%;
|
||||||
--input: 55 16% 79%;
|
--input: 0 0% 20%;
|
||||||
--ring: 108 60% 36%;
|
--ring: 78 100% 38%;
|
||||||
|
|
||||||
--acid-lime: 105 72% 42%;
|
--acid-lime: 78 100% 30%;
|
||||||
--acid-cyan: 187 90% 38%;
|
--acid-cyan: 187 100% 26%;
|
||||||
--acid-magenta: 322 82% 44%;
|
--acid-magenta: 318 100% 38%;
|
||||||
--acid-violet: 258 76% 48%;
|
--acid-violet: 268 100% 40%;
|
||||||
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 248 22% 5%;
|
--background: 0 0% 3%;
|
||||||
--foreground: 56 26% 92%;
|
--foreground: 0 0% 96%;
|
||||||
|
|
||||||
--card: 247 18% 7%;
|
--card: 0 0% 7%;
|
||||||
--card-foreground: 56 26% 92%;
|
--card-foreground: 0 0% 96%;
|
||||||
--popover: 247 18% 7%;
|
--popover: 0 0% 8%;
|
||||||
--popover-foreground: 56 26% 92%;
|
--popover-foreground: 0 0% 96%;
|
||||||
|
|
||||||
--primary: 91 58% 44%;
|
--primary: 78 100% 55%;
|
||||||
--primary-foreground: 240 30% 7%;
|
--primary-foreground: 0 0% 4%;
|
||||||
--secondary: 247 16% 12%;
|
--secondary: 0 0% 12%;
|
||||||
--secondary-foreground: 55 20% 88%;
|
--secondary-foreground: 0 0% 88%;
|
||||||
--muted: 247 16% 12%;
|
--muted: 0 0% 12%;
|
||||||
--muted-foreground: 246 10% 62%;
|
--muted-foreground: 0 0% 62%;
|
||||||
--accent: 251 22% 14%;
|
--accent: 0 0% 14%;
|
||||||
--accent-foreground: 190 72% 70%;
|
--accent-foreground: 0 0% 92%;
|
||||||
|
|
||||||
--destructive: 3 68% 52%;
|
--destructive: 0 100% 58%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--success: 143 62% 55%;
|
--success: 142 100% 45%;
|
||||||
--warning: 38 92% 55%;
|
--warning: 48 100% 55%;
|
||||||
|
|
||||||
--border: 248 14% 18%;
|
--border: 0 0% 20%;
|
||||||
--input: 248 13% 24%;
|
--input: 0 0% 26%;
|
||||||
--ring: 91 58% 50%;
|
--ring: 78 100% 55%;
|
||||||
|
|
||||||
--acid-lime: 90 66% 54%;
|
--acid-lime: 78 100% 55%;
|
||||||
--acid-cyan: 188 82% 58%;
|
--acid-cyan: 187 100% 50%;
|
||||||
--acid-magenta: 325 80% 62%;
|
--acid-magenta: 318 100% 55%;
|
||||||
--acid-violet: 262 86% 68%;
|
--acid-violet: 268 100% 66%;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +97,7 @@
|
||||||
--color-success: hsl(var(--success));
|
--color-success: hsl(var(--success));
|
||||||
--color-warning: hsl(var(--warning));
|
--color-warning: hsl(var(--warning));
|
||||||
--color-border: hsl(var(--border));
|
--color-border: hsl(var(--border));
|
||||||
|
--color-line: hsl(var(--border));
|
||||||
--color-input: hsl(var(--input));
|
--color-input: hsl(var(--input));
|
||||||
--color-ring: hsl(var(--ring));
|
--color-ring: hsl(var(--ring));
|
||||||
|
|
||||||
|
|
@ -105,10 +106,10 @@
|
||||||
--color-acid-magenta: hsl(var(--acid-magenta));
|
--color-acid-magenta: hsl(var(--acid-magenta));
|
||||||
--color-acid-violet: hsl(var(--acid-violet));
|
--color-acid-violet: hsl(var(--acid-violet));
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 2px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: var(--radius);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: calc(var(--radius) + 2px);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 6px);
|
||||||
|
|
||||||
--font-sans: "Space Grotesk Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
--font-sans: "Space Grotesk Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
--font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, monospace;
|
--font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
|
@ -134,79 +135,60 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: hsl(var(--acid-lime) / 0.28);
|
background: hsl(var(--acid-lime) / 0.9);
|
||||||
color: hsl(var(--foreground));
|
color: hsl(0 0% 4%);
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
::-webkit-scrollbar {
|
||||||
content: "";
|
width: 10px;
|
||||||
position: fixed;
|
height: 8px;
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
background:
|
|
||||||
linear-gradient(115deg, hsl(var(--acid-lime) / 0.1), transparent 34%),
|
|
||||||
linear-gradient(238deg, hsl(var(--acid-cyan) / 0.09), transparent 44%),
|
|
||||||
linear-gradient(325deg, hsl(var(--acid-magenta) / 0.07), transparent 42%);
|
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
body::after {
|
background: transparent;
|
||||||
content: "";
|
}
|
||||||
position: fixed;
|
::-webkit-scrollbar-thumb {
|
||||||
inset: 0;
|
background: hsl(var(--border));
|
||||||
z-index: -1;
|
border: 2px solid transparent;
|
||||||
pointer-events: none;
|
background-clip: padding-box;
|
||||||
background-image:
|
|
||||||
linear-gradient(to right, hsl(var(--foreground) / 0.035) 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, hsl(var(--foreground) / 0.035) 1px, transparent 1px);
|
|
||||||
background-size: 56px 56px;
|
|
||||||
mask-image: linear-gradient(to bottom, black, transparent 76%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
/* ===== 字体系统 ===== */
|
||||||
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
|
.display {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 0.98;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.mono-label {
|
||||||
@apply inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md px-4 text-sm font-medium whitespace-nowrap transition-colors select-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50;
|
font-family: var(--font-mono);
|
||||||
}
|
font-size: 11px;
|
||||||
.btn-primary {
|
letter-spacing: 0.16em;
|
||||||
@apply bg-primary text-primary-foreground shadow-sm hover:bg-primary/90;
|
text-transform: uppercase;
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
@apply bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80;
|
|
||||||
}
|
|
||||||
.btn-outline {
|
|
||||||
@apply border bg-card shadow-sm hover:bg-accent hover:text-accent-foreground;
|
|
||||||
}
|
|
||||||
.btn-ghost {
|
|
||||||
@apply text-foreground/70 hover:bg-accent hover:text-accent-foreground;
|
|
||||||
}
|
|
||||||
.btn-destructive {
|
|
||||||
@apply bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90;
|
|
||||||
}
|
|
||||||
.btn-sm {
|
|
||||||
@apply h-8 px-3 text-xs;
|
|
||||||
}
|
|
||||||
.btn-lg {
|
|
||||||
@apply h-10 px-6 text-[0.95rem];
|
|
||||||
}
|
|
||||||
.btn-icon {
|
|
||||||
@apply size-9 p-0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.micro-label {
|
||||||
@apply flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.field-mono {
|
||||||
@apply text-sm leading-none font-medium;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.outline-text {
|
||||||
@apply inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-semibold whitespace-nowrap transition-colors;
|
-webkit-text-stroke: 2px hsl(var(--acid-lime));
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-text {
|
||||||
|
-webkit-text-stroke: 1.5px hsl(var(--foreground) / 0.16);
|
||||||
|
color: transparent;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acid-text {
|
.acid-text {
|
||||||
|
|
@ -222,59 +204,311 @@
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
/* ===== 面板与容器 ===== */
|
||||||
@apply rounded-lg border bg-background/70;
|
.card {
|
||||||
|
@apply rounded-sm border bg-card text-card-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-[3px] border px-1.5 py-0.5 font-mono text-[10px] font-medium tracking-[0.14em] uppercase whitespace-nowrap transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono-chip {
|
||||||
|
@apply inline-flex cursor-pointer items-center gap-1.5 border border-line bg-card/60 px-2 py-1 font-mono text-[10px] tracking-[0.12em] uppercase text-muted-foreground transition-colors;
|
||||||
|
}
|
||||||
|
.mono-chip:hover {
|
||||||
|
border-color: hsl(var(--foreground) / 0.7);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.dark .mono-chip:hover {
|
||||||
|
border-color: hsl(var(--acid-lime) / 0.7);
|
||||||
|
color: hsl(var(--acid-lime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 按钮 ===== */
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-[3px] px-4 text-sm font-medium whitespace-nowrap select-none transition-all duration-150 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary text-primary-foreground;
|
||||||
|
}
|
||||||
|
.dark .btn-primary {
|
||||||
|
box-shadow: 0 0 32px -6px hsl(var(--acid-lime) / 0.7);
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
@apply bg-primary/85;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-secondary text-secondary-foreground;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
@apply bg-secondary/80;
|
||||||
|
}
|
||||||
|
.btn-outline {
|
||||||
|
@apply border border-line bg-transparent text-foreground;
|
||||||
|
}
|
||||||
|
.btn-outline:hover:not(:disabled) {
|
||||||
|
border-color: hsl(var(--foreground) / 0.8);
|
||||||
|
background: hsl(var(--acid-lime) / 0.12);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.dark .btn-outline:hover:not(:disabled) {
|
||||||
|
border-color: hsl(var(--acid-lime) / 0.8);
|
||||||
|
background: hsl(var(--acid-lime) / 0.08);
|
||||||
|
color: hsl(var(--acid-lime));
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply text-muted-foreground;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
@apply bg-accent text-foreground;
|
||||||
|
}
|
||||||
|
.btn-destructive {
|
||||||
|
@apply border border-destructive/50 bg-destructive/10 text-destructive;
|
||||||
|
}
|
||||||
|
.btn-destructive:hover:not(:disabled) {
|
||||||
|
@apply bg-destructive text-destructive-foreground;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
@apply h-8 px-3 text-xs;
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
@apply h-11 px-6 text-sm;
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
@apply size-9 p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 表单 ===== */
|
||||||
|
.field {
|
||||||
|
@apply h-9 w-full rounded-[3px] border border-input bg-background/60 px-3 py-1 text-sm transition-colors placeholder:text-muted-foreground/50 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply text-sm leading-none font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== HUD 边角 ===== */
|
||||||
|
.hud-corner {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
pointer-events: none;
|
||||||
|
border-color: hsl(var(--acid-lime));
|
||||||
|
}
|
||||||
|
.hud-tl {
|
||||||
|
top: -1px;
|
||||||
|
left: -1px;
|
||||||
|
border-top: 2px solid;
|
||||||
|
border-left: 2px solid;
|
||||||
|
}
|
||||||
|
.hud-tr {
|
||||||
|
top: -1px;
|
||||||
|
right: -1px;
|
||||||
|
border-top: 2px solid;
|
||||||
|
border-right: 2px solid;
|
||||||
|
}
|
||||||
|
.hud-bl {
|
||||||
|
bottom: -1px;
|
||||||
|
left: -1px;
|
||||||
|
border-bottom: 2px solid;
|
||||||
|
border-left: 2px solid;
|
||||||
|
}
|
||||||
|
.hud-br {
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
border-bottom: 2px solid;
|
||||||
|
border-right: 2px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 氛围层 ===== */
|
||||||
|
.atmo-grid {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.55;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, hsl(var(--foreground) / 0.06) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, hsl(var(--foreground) / 0.06) 1px, transparent 1px);
|
||||||
|
background-size: 72px 72px;
|
||||||
|
-webkit-mask-image: radial-gradient(130% 90% at 50% -10%, #000 25%, transparent 78%);
|
||||||
|
mask-image: radial-gradient(130% 90% at 50% -10%, #000 25%, transparent 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.atmo-glow {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 46vmax;
|
||||||
|
height: 46vmax;
|
||||||
|
border-radius: 9999px;
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
.atmo-glow-a {
|
||||||
|
top: -24vmax;
|
||||||
|
left: -12vmax;
|
||||||
|
background: hsl(var(--acid-lime) / 0.6);
|
||||||
|
animation: drift-a 26s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.atmo-glow-b {
|
||||||
|
right: -16vmax;
|
||||||
|
bottom: -26vmax;
|
||||||
|
background: hsl(var(--acid-cyan) / 0.55);
|
||||||
|
animation: drift-b 32s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.dark .atmo-glow {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.dark .atmo-glow-b {
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanline-sweep {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 24%;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to bottom, transparent, hsl(var(--acid-lime) / 0.06), transparent);
|
||||||
|
animation: sweep 10s linear infinite;
|
||||||
|
}
|
||||||
|
.dark .scanline-sweep {
|
||||||
|
background: linear-gradient(to bottom, transparent, hsl(var(--acid-lime) / 0.09), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noise {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.04;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.dark .noise {
|
||||||
|
opacity: 0.06;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(
|
||||||
|
640px circle at var(--mx, 72%) var(--my, 24%),
|
||||||
|
hsl(var(--acid-lime) / 0.1),
|
||||||
|
transparent 65%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.dark .spotlight {
|
||||||
|
background: radial-gradient(
|
||||||
|
640px circle at var(--mx, 72%) var(--my, 24%),
|
||||||
|
hsl(var(--acid-lime) / 0.14),
|
||||||
|
transparent 65%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 跑马灯 ===== */
|
||||||
|
.marquee {
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||||
|
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||||
|
}
|
||||||
|
.marquee-track {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
animation: marquee 32s linear infinite;
|
||||||
|
}
|
||||||
|
.marquee:hover .marquee-track {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 信号条 / 呼吸点 ===== */
|
||||||
|
.meter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
.meter i {
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: hsl(var(--acid-lime));
|
||||||
|
transform-origin: bottom;
|
||||||
|
animation: meter-bar 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.meter i:nth-child(2) {
|
||||||
|
animation-delay: 0.12s;
|
||||||
|
}
|
||||||
|
.meter i:nth-child(3) {
|
||||||
|
animation-delay: 0.24s;
|
||||||
|
}
|
||||||
|
.meter i:nth-child(4) {
|
||||||
|
animation-delay: 0.36s;
|
||||||
|
}
|
||||||
|
.meter i:nth-child(5) {
|
||||||
|
animation-delay: 0.48s;
|
||||||
|
}
|
||||||
|
.meter.idle i {
|
||||||
|
animation: none;
|
||||||
|
transform: scaleY(0.22);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse-ring 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
.pulse-acid {
|
||||||
|
animation: pulse-ring-acid 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 滚动入场 ===== */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(22px);
|
||||||
|
transition:
|
||||||
|
opacity 0.65s cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||||
|
transform 0.65s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
transition-delay: calc(var(--i, 0) * 80ms);
|
||||||
|
}
|
||||||
|
.reveal.is-in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 导航下划线 ===== */
|
||||||
|
.nav-link {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-link::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -3px;
|
||||||
|
height: 2px;
|
||||||
|
background: hsl(var(--acid-lime));
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
}
|
||||||
|
.nav-link:hover::after,
|
||||||
|
.nav-link.is-active::after {
|
||||||
|
transform: scaleX(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.focus-ring {
|
.focus-ring {
|
||||||
@apply focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none;
|
@apply focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acid-frame {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.acid-frame::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
border-radius: inherit;
|
|
||||||
padding: 1px;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
hsl(var(--acid-lime) / 0.75),
|
|
||||||
hsl(var(--acid-cyan) / 0.45) 38%,
|
|
||||||
hsl(var(--acid-violet) / 0.18) 68%,
|
|
||||||
hsl(var(--acid-magenta) / 0.5)
|
|
||||||
);
|
|
||||||
-webkit-mask:
|
|
||||||
linear-gradient(#000 0 0) content-box,
|
|
||||||
linear-gradient(#000 0 0);
|
|
||||||
-webkit-mask-composite: xor;
|
|
||||||
mask:
|
|
||||||
linear-gradient(#000 0 0) content-box,
|
|
||||||
linear-gradient(#000 0 0);
|
|
||||||
mask-composite: exclude;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-line::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
opacity: 0.55;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
hsl(var(--acid-lime)),
|
|
||||||
hsl(var(--acid-cyan)) 40%,
|
|
||||||
hsl(var(--acid-violet)) 72%,
|
|
||||||
hsl(var(--acid-magenta))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 弹幕 ===== */
|
/* ===== 弹幕 ===== */
|
||||||
|
|
@ -282,9 +516,9 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 100%;
|
left: 100%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0.02em;
|
||||||
text-shadow: 0 1px 2px rgb(0 0 0 / 0.9), 0 0 14px rgb(0 0 0 / 0.5);
|
text-shadow: 0 1px 2px rgb(0 0 0 / 0.9), 0 0 14px rgb(0 0 0 / 0.5);
|
||||||
animation-name: danmaku-move;
|
animation-name: danmaku-move;
|
||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
|
|
@ -292,6 +526,82 @@
|
||||||
will-change: left;
|
will-change: left;
|
||||||
}
|
}
|
||||||
@keyframes danmaku-move {
|
@keyframes danmaku-move {
|
||||||
from { left: 100%; }
|
from {
|
||||||
to { left: -100%; }
|
left: 100%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes meter-bar {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleY(0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 hsl(var(--success) / 0.5);
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 7px hsl(var(--success) / 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes pulse-ring-acid {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 hsl(var(--acid-lime) / 0.6);
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 7px hsl(var(--acid-lime) / 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes drift-a {
|
||||||
|
to {
|
||||||
|
transform: translate(8vmax, 6vmax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes drift-b {
|
||||||
|
to {
|
||||||
|
transform: translate(-7vmax, -5vmax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sweep {
|
||||||
|
from {
|
||||||
|
transform: translateY(-30vh);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(130vh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
.marquee-track,
|
||||||
|
.meter i,
|
||||||
|
.pulse,
|
||||||
|
.pulse-acid,
|
||||||
|
.atmo-glow-a,
|
||||||
|
.atmo-glow-b,
|
||||||
|
.scanline-sweep,
|
||||||
|
.danmaku-item {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.reveal {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,21 @@ import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
// 构建产物直接落到 Go 的 //go:embed 目录,保持单体仓库发布形态。
|
// 构建产物直接落到 Go 的 //go:embed 目录,保持单体仓库发布形态。
|
||||||
// base 设为 /static/ 让 Vite 资源路径命中 Go 的 /static/ 文件服务。
|
// base 设为 /static/ 让 Vite 资源路径命中 Go 的 /static/ 文件服务。
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => ({
|
||||||
plugins: [solid(), tailwindcss()],
|
plugins: [solid(), tailwindcss()],
|
||||||
base: '/static/',
|
// dev 在根路径服务(与 TanStack Router 的路由路径对齐);build 落到 /static/ 供 Go embed
|
||||||
|
base: mode === 'development' ? '/' : '/static/',
|
||||||
build: {
|
build: {
|
||||||
outDir: fileURLToPath(new URL('../internal/server/static', import.meta.url)),
|
outDir: fileURLToPath(new URL('../internal/server/static', import.meta.url)),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
server: { port: 5173 },
|
server: {
|
||||||
})
|
port: 5173,
|
||||||
|
// 开发态把控制面 / RTC / 直播流反代到本地 Go 服务(不影响构建产物)
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8088',
|
||||||
|
'/rtc': 'http://localhost:8088',
|
||||||
|
'/live': 'http://localhost:8088',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue