feat: realtime rooms via SSE (global rooms/events)
- roomHub: add globalSubs + subscribeRooms/broadcastRooms, emit create/update/delete on createRoom/setTarget/removeTarget/setDistribution/clearDistributions/removeRoom - gateway: handleRoomsEvents SSE (event: rooms, sync + 25s ping) - server: GET /api/rooms/events (room:list) - sse.ts: subscribeRooms with onState (onopen/onerror) - rooms.tsx: subscribe + 350ms debounce reload, live badge + lastSync - config_test: align ValidateDistributors permissive semantics (cdn占位不阻断) - docs: document /api/rooms/events in business-logic & design
This commit is contained in:
parent
1a54286d69
commit
03c3d4d5bd
|
|
@ -27,7 +27,7 @@ SyncLive(同步直播,对标 SyncTV)的整体架构与模块划分说明
|
||||||
- `internal/db/`:嵌入式 libSQL 持久化(房间分发拓扑),仅 file:/:memory: 模式。
|
- `internal/db/`:嵌入式 libSQL 持久化(房间分发拓扑),仅 file:/:memory: 模式。
|
||||||
- `internal/auth/`:Casbin RBAC 模型与策略、JWT 签发校验、用户存储。
|
- `internal/auth/`:Casbin RBAC 模型与策略、JWT 签发校验、用户存储。
|
||||||
- `internal/sfu/`:SFU 后端 Provider 抽象(Cloudflare / SRS),统一 `BackendInfo` 与能力查询。
|
- `internal/sfu/`:SFU 后端 Provider 抽象(Cloudflare / SRS),统一 `BackendInfo` 与能力查询。
|
||||||
- `internal/server/`:HTTP/gRPC 服务装配、JSON 网关、房间与聊天 SSE、媒体反代、静态 UI 嵌入。
|
- `internal/server/`:HTTP/gRPC 服务装配、JSON 网关、房间与聊天 SSE(含 `GET /api/rooms/events` 全局房间 SSE 与 `GET /api/room/{room}/events` 单房间拓扑 SSE)、媒体反代、静态 UI 嵌入。
|
||||||
- `cmd/server/`:进程入口,装配配置、服务与优雅关闭。
|
- `cmd/server/`:进程入口,装配配置、服务与优雅关闭。
|
||||||
- `web/`:前端单页应用(SolidJS + TanStack + Ark UI + Tailwind v4),构建产物由 Go embed 内联。
|
- `web/`:前端单页应用(SolidJS + TanStack + Ark UI + Tailwind v4),构建产物由 Go embed 内联。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,7 @@ publisher ──WHIP/tracks.new──▶ Cloudflare Realtime ──▶ viewer(s)
|
||||||
| GET | `/api/config` | `config:read` guest可 | 后端能力与 `candidate/token_required` |
|
| GET | `/api/config` | `config:read` guest可 | 后端能力与 `candidate/token_required` |
|
||||||
| GET | `/api/rooms` | `room:list` | 全量房间 (hub) |
|
| GET | `/api/rooms` | `room:list` | 全量房间 (hub) |
|
||||||
| GET | `/api/rooms/query?...` | `room:list` | 分页查询 (DB 优先) |
|
| GET | `/api/rooms/query?...` | `room:list` | 分页查询 (DB 优先) |
|
||||||
|
| GET | `/api/rooms/events` | `room:list` SSE | 全局房间变更(create/update/delete/sync,`event: rooms`),供 /rooms 实时刷新 |
|
||||||
| POST | `/api/rooms` | `room:list` | 创建占位 |
|
| POST | `/api/rooms` | `room:list` | 创建占位 |
|
||||||
| POST | `/api/publish` | `room:publish` | 发布 (多后端) |
|
| POST | `/api/publish` | `room:publish` | 发布 (多后端) |
|
||||||
| POST | `/api/subscribe` | `room:subscribe` | 订阅 |
|
| POST | `/api/subscribe` | `room:subscribe` | 订阅 |
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ func TestDistributorListDedup(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateCDNRequiresURLWhenEnabled(t *testing.T) {
|
func TestValidateCDNRequiresURLWhenEnabled(t *testing.T) {
|
||||||
|
// 运行时允许 cdn 占位:未配置 RTMP 仅影响分发就绪,不阻断配置保存
|
||||||
c := &Config{Distributors: "cdn", CDNRTMPURL: ""}
|
c := &Config{Distributors: "cdn", CDNRTMPURL: ""}
|
||||||
if err := c.ValidateDistributors(); err == nil {
|
if err := c.ValidateDistributors(); err != nil {
|
||||||
t.Fatal("expected error when cdn enabled without URL")
|
t.Fatalf("cdn占位不应阻断配置保存,got %v", err)
|
||||||
}
|
}
|
||||||
c2 := &Config{Distributors: "cdn", CDNRTMPURL: "rtmp://cdn.example.com/live"}
|
c2 := &Config{Distributors: "cdn", CDNRTMPURL: "rtmp://cdn.example.com/live"}
|
||||||
if err := c2.ValidateDistributors(); err != nil {
|
if err := c2.ValidateDistributors(); err != nil {
|
||||||
|
|
@ -43,6 +44,13 @@ func TestValidateCDNRequiresURLWhenEnabled(t *testing.T) {
|
||||||
if err := c3.ValidateDistributors(); err != nil {
|
if err := c3.ValidateDistributors(); err != nil {
|
||||||
t.Fatalf("should not require CDN URL when cdn not in list, got %v", err)
|
t.Fatalf("should not require CDN URL when cdn not in list, got %v", err)
|
||||||
}
|
}
|
||||||
|
// 分发维度的真实就绪由 Provider.Configured / DistributionTarget.status 表达
|
||||||
|
if c.CDNEnabled() {
|
||||||
|
t.Fatal("空 CDN_RTMP_URL 不应视为已启用")
|
||||||
|
}
|
||||||
|
if !c2.CDNEnabled() {
|
||||||
|
t.Fatal("非空 CDN_RTMP_URL 应视为已启用")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCDNEnabled(t *testing.T) {
|
func TestCDNEnabled(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,41 @@ func (s *Server) handleRoomEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// handleRoomsEvents 以 SSE 推送全局房间列表变更(创建/删除/LIVE 状态变化),供 /rooms 实时刷新。
|
||||||
|
func (s *Server) handleRoomsEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
ch, unsub := s.hub.subscribeRooms()
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
// initial sync hint
|
||||||
|
fmt.Fprintf(w, "event: rooms\ndata: {\"type\":\"rooms\",\"event\":\"sync\",\"ts\":%d}\n\n", time.Now().Unix())
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(25 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case msg := <-ch:
|
||||||
|
fmt.Fprintf(w, "event: rooms\ndata: %s\n\n", msg)
|
||||||
|
flusher.Flush()
|
||||||
|
case <-ticker.C:
|
||||||
|
fmt.Fprintf(w, ": ping\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleSRSStreams(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSRSStreams(w http.ResponseWriter, r *http.Request) {
|
||||||
resp, err := http.Get(s.cfg.SRSBaseURL + "/api/v1/streams/")
|
resp, err := http.Get(s.cfg.SRSBaseURL + "/api/v1/streams/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ func (s *Server) handleManageRooms(w http.ResponseWriter, r *http.Request) {
|
||||||
created.PasswordHash = hash
|
created.PasswordHash = hash
|
||||||
}
|
}
|
||||||
s.hub.createRoom(name)
|
s.hub.createRoom(name)
|
||||||
|
s.hub.broadcastRooms("create", name)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "room": created})
|
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "room": created})
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type roomHub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
rooms map[string]*roomEntry
|
rooms map[string]*roomEntry
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
globalSubs map[chan []byte]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type roomEntry struct {
|
type roomEntry struct {
|
||||||
|
|
@ -28,12 +29,12 @@ type roomEntry struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRoomHub() *roomHub {
|
func newRoomHub() *roomHub {
|
||||||
return &roomHub{rooms: map[string]*roomEntry{}}
|
return &roomHub{rooms: map[string]*roomEntry{}, globalSubs: map[chan []byte]struct{}{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRoomHubWithDB 创建带嵌入式 libSQL 持久化的 hub,启动时自动从 DB 加载全量房间。
|
// newRoomHubWithDB 创建带嵌入式 libSQL 持久化的 hub,启动时自动从 DB 加载全量房间。
|
||||||
func newRoomHubWithDB(database *sql.DB) *roomHub {
|
func newRoomHubWithDB(database *sql.DB) *roomHub {
|
||||||
h := &roomHub{rooms: map[string]*roomEntry{}, db: database}
|
h := &roomHub{rooms: map[string]*roomEntry{}, db: database, globalSubs: map[chan []byte]struct{}{}}
|
||||||
if database != nil {
|
if database != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -73,6 +74,9 @@ func (h *roomHub) createRoom(name string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if _, ok := h.rooms[name]; !ok {
|
if _, ok := h.rooms[name]; !ok {
|
||||||
h.rooms[name] = &roomEntry{targets: map[string]*gen.StreamTarget{}, distributions: map[string]*gen.DistributionTarget{}, subs: map[chan []byte]struct{}{}}
|
h.rooms[name] = &roomEntry{targets: map[string]*gen.StreamTarget{}, distributions: map[string]*gen.DistributionTarget{}, subs: map[chan []byte]struct{}{}}
|
||||||
|
h.mu.Unlock()
|
||||||
|
h.broadcastRooms("create", name)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +85,7 @@ func (h *roomHub) setTarget(room, backend string, t *gen.StreamTarget) {
|
||||||
e := h.get(room)
|
e := h.get(room)
|
||||||
e.targets[backend] = t
|
e.targets[backend] = t
|
||||||
h.broadcast(room, e)
|
h.broadcast(room, e)
|
||||||
|
h.broadcastRoomsLocked("update", room)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if h.db != nil {
|
if h.db != nil {
|
||||||
|
|
@ -102,6 +107,7 @@ func (h *roomHub) removeTarget(room, backend string) {
|
||||||
delete(e.targets, backend)
|
delete(e.targets, backend)
|
||||||
if len(e.targets) == 0 && len(e.subs) == 0 {
|
if len(e.targets) == 0 && len(e.subs) == 0 {
|
||||||
delete(h.rooms, room)
|
delete(h.rooms, room)
|
||||||
|
h.broadcastRoomsLocked("delete", room)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if h.db != nil {
|
if h.db != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
|
@ -113,6 +119,7 @@ func (h *roomHub) removeTarget(room, backend string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.broadcast(room, e)
|
h.broadcast(room, e)
|
||||||
|
h.broadcastRoomsLocked("update", room)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if h.db != nil {
|
if h.db != nil {
|
||||||
|
|
@ -207,6 +214,47 @@ func (h *roomHub) broadcastEvent(room, eventType string, payload interface{}) {
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 全局房间列表广播(供 /api/rooms/events 订阅) ----
|
||||||
|
|
||||||
|
func (h *roomHub) subscribeRooms() (chan []byte, func()) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.globalSubs == nil {
|
||||||
|
h.globalSubs = map[chan []byte]struct{}{}
|
||||||
|
}
|
||||||
|
ch := make(chan []byte, 8)
|
||||||
|
h.globalSubs[ch] = struct{}{}
|
||||||
|
return ch, func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
delete(h.globalSubs, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *roomHub) broadcastRooms(event, room string) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
h.broadcastRoomsLocked(event, room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *roomHub) broadcastRoomsLocked(event, room string) {
|
||||||
|
if len(h.globalSubs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"type": "rooms",
|
||||||
|
"event": event,
|
||||||
|
"room": room,
|
||||||
|
"ts": time.Now().Unix(),
|
||||||
|
})
|
||||||
|
for ch := range h.globalSubs {
|
||||||
|
select {
|
||||||
|
case ch <- payload:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *roomHub) distributions(room string) []*gen.DistributionTarget {
|
func (h *roomHub) distributions(room string) []*gen.DistributionTarget {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
@ -230,6 +278,7 @@ func (h *roomHub) setDistribution(room string, d *gen.DistributionTarget) {
|
||||||
key := d.GetKind().String()
|
key := d.GetKind().String()
|
||||||
e.distributions[key] = d
|
e.distributions[key] = d
|
||||||
h.broadcast(room, e)
|
h.broadcast(room, e)
|
||||||
|
h.broadcastRoomsLocked("update", room)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,6 +287,7 @@ func (h *roomHub) clearDistributions(room string) {
|
||||||
if e, ok := h.rooms[room]; ok {
|
if e, ok := h.rooms[room]; ok {
|
||||||
e.distributions = map[string]*gen.DistributionTarget{}
|
e.distributions = map[string]*gen.DistributionTarget{}
|
||||||
h.broadcast(room, e)
|
h.broadcast(room, e)
|
||||||
|
h.broadcastRoomsLocked("update", room)
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
@ -246,4 +296,11 @@ func (h *roomHub) removeRoom(name string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
delete(h.rooms, name)
|
delete(h.rooms, name)
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{"type": "rooms", "event": "delete", "room": name, "ts": time.Now().Unix()})
|
||||||
|
for ch := range h.globalSubs {
|
||||||
|
select {
|
||||||
|
case ch <- payload:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ func (s *Server) Handler() http.Handler {
|
||||||
mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false))
|
mux.Handle("GET /api/auth/check", s.authWrap(http.HandlerFunc(s.handleAuthCheck), "config", "read", false))
|
||||||
// 对标 SyncTV 的分页查询:GET /api/rooms/query?page=1&page_size=20&search=&status=active
|
// 对标 SyncTV 的分页查询:GET /api/rooms/query?page=1&page_size=20&search=&status=active
|
||||||
mux.Handle("GET /api/rooms/query", s.authWrap(http.HandlerFunc(s.handleRoomsQuery), "room", "list", true))
|
mux.Handle("GET /api/rooms/query", s.authWrap(http.HandlerFunc(s.handleRoomsQuery), "room", "list", true))
|
||||||
|
mux.Handle("GET /api/rooms/events", s.authWrap(http.HandlerFunc(s.handleRoomsEvents), "room", "list", true))
|
||||||
|
|
||||||
mux.Handle("GET /api/config", s.authWrap(http.HandlerFunc(s.handleConfig), "config", "read", false))
|
mux.Handle("GET /api/config", s.authWrap(http.HandlerFunc(s.handleConfig), "config", "read", false))
|
||||||
mux.Handle("GET /api/rooms", s.authWrap(http.HandlerFunc(s.handleRooms), "room", "list", true))
|
mux.Handle("GET /api/rooms", s.authWrap(http.HandlerFunc(s.handleRooms), "room", "list", true))
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.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};
|
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.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 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>e(n,t(i,{iconNode:r,name:`globe`}));export{i as t};
|
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>e(n,t(i,{iconNode:r,name:`globe`}));export{i as t};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>e(n,t(i,{iconNode:r,name:`hash`}));export{i as t};
|
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>e(n,t(i,{iconNode:r,name:`hash`}));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
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 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>e(n,t(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>e(n,t(r,{iconNode:a,name:`eye`})),s=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],c=r=>e(n,t(r,{iconNode:s,name:`message-square`}));export{o as n,i as r,c as t};
|
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>e(n,t(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>e(n,t(r,{iconNode:a,name:`eye`})),s=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],c=r=>e(n,t(r,{iconNode:s,name:`message-square`}));export{o as n,i as r,c as t};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.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`})),a=[[`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`}]],o=r=>e(n,t(r,{iconNode:a,name:`monitor-play`}));export{i as n,o as t};
|
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.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`})),a=[[`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`}]],o=r=>e(n,t(r,{iconNode:a,name:`monitor-play`}));export{i as n,o as t};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.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=>e(n,t(i,{iconNode:r,name:`radio-tower`}));export{i as t};
|
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.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=>e(n,t(i,{iconNode:r,name:`radio-tower`}));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
|
|
@ -1 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.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{V as e,Y as t,b as n}from"./ui-IRgYFSdb.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};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
function e(e,t){let n=new EventSource(`/api/room/${encodeURIComponent(e)}/events`,{withCredentials:!0});return n.addEventListener(`room`,e=>{try{t(JSON.parse(e.data))}catch{}}),()=>n.close()}function t(e,t){let n=new EventSource(`/api/rooms/events`,{withCredentials:!0});return n.addEventListener(`rooms`,t=>{try{e(JSON.parse(t.data))}catch{}}),n.onopen=()=>t?.(!0),n.onerror=()=>t?.(!1),n.readyState===EventSource.OPEN&&t?.(!0),()=>n.close()}export{t as n,e as t};
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.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:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>e(n,t(r,{iconNode:a,name:`user-round`}));export{i as n,o as t};
|
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.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:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>e(n,t(r,{iconNode:a,name:`user-round`}));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
|
|
@ -1 +1 @@
|
||||||
import{B as e,J as t,b as n}from"./ui-CBKBDzcl.js";var r=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],i=i=>e(n,t(i,{iconNode:r,name:`lock`})),a=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],o=r=>e(n,t(r,{iconNode:a,name:`plus`})),s=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],c=r=>e(n,t(r,{iconNode:s,name:`users`}));export{o as n,i as r,c as t};
|
import{V as e,Y as t,b as n}from"./ui-IRgYFSdb.js";var r=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],i=i=>e(n,t(i,{iconNode:r,name:`lock`})),a=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],o=r=>e(n,t(r,{iconNode:a,name:`plus`})),s=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],c=r=>e(n,t(r,{iconNode:s,name:`users`}));export{o as n,i as r,c as t};
|
||||||
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-Cfr72k3j.js"></script>
|
<script type="module" crossorigin src="/static/assets/index-BdWedm3z.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/static/assets/ui-CBKBDzcl.js">
|
<link rel="modulepreload" crossorigin href="/static/assets/ui-IRgYFSdb.js">
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-CRlWlaHg.css">
|
<link rel="stylesheet" crossorigin href="/static/assets/index-DwOmVRa5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Component, createSignal, createEffect, For, Show, onMount } from 'solid-js'
|
import { Component, createSignal, createEffect, For, Show, onMount, onCleanup } from 'solid-js'
|
||||||
import { useNavigate } from '@tanstack/solid-router'
|
import { useNavigate } from '@tanstack/solid-router'
|
||||||
import { ArrowRight, DoorOpen, Plus, Search, Lock, Globe, Users, RadioTower, Crown } from 'lucide-solid'
|
import { ArrowRight, DoorOpen, Plus, Search, Lock, Globe, Users, RadioTower, Crown } from 'lucide-solid'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
|
import { subscribeRooms } from '../../lib/sse'
|
||||||
import { permissions } from '../../lib/permissions'
|
import { permissions } from '../../lib/permissions'
|
||||||
import { Button, Corners, LiveDot, Reveal, Alert, Badge } from '../../components/ui'
|
import { Button, Corners, LiveDot, Reveal, Alert, Badge } from '../../components/ui'
|
||||||
|
|
||||||
|
|
@ -20,6 +21,8 @@ export const Rooms: Component = () => {
|
||||||
const [desc, setDesc] = createSignal('')
|
const [desc, setDesc] = createSignal('')
|
||||||
const [isPublic, setIsPublic] = createSignal(true)
|
const [isPublic, setIsPublic] = createSignal(true)
|
||||||
const [showAdvanced, setShowAdvanced] = createSignal(false)
|
const [showAdvanced, setShowAdvanced] = createSignal(false)
|
||||||
|
const [roomsLive, setRoomsLive] = createSignal(false)
|
||||||
|
const [lastSync, setLastSync] = createSignal<string>('')
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -105,6 +108,35 @@ export const Rooms: Component = () => {
|
||||||
page(); search(); permissions.isAuthed() && load()
|
page(); search(); permissions.isAuthed() && load()
|
||||||
})
|
})
|
||||||
onMount(() => permissions.isAuthed() && load())
|
onMount(() => permissions.isAuthed() && load())
|
||||||
|
let unsubRooms: (() => void) | null = null
|
||||||
|
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const attach = () => {
|
||||||
|
if (unsubRooms) return
|
||||||
|
try {
|
||||||
|
unsubRooms = subscribeRooms(
|
||||||
|
() => {
|
||||||
|
setLastSync(new Date().toLocaleTimeString())
|
||||||
|
if (debounce) clearTimeout(debounce)
|
||||||
|
debounce = setTimeout(() => load(), 350)
|
||||||
|
},
|
||||||
|
(live) => setRoomsLive(live),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
setRoomsLive(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detach = () => {
|
||||||
|
if (unsubRooms) { try { unsubRooms() } catch {} ; unsubRooms = null }
|
||||||
|
if (debounce) { clearTimeout(debounce); debounce = null }
|
||||||
|
setRoomsLive(false)
|
||||||
|
}
|
||||||
|
// attach when authed, detach when not
|
||||||
|
createEffect(() => {
|
||||||
|
if (permissions.isAuthed()) attach()
|
||||||
|
else detach()
|
||||||
|
})
|
||||||
|
attach()
|
||||||
|
onCleanup(() => detach())
|
||||||
|
|
||||||
const doSearch = () => {
|
const doSearch = () => {
|
||||||
setSearch(searchInput().trim())
|
setSearch(searchInput().trim())
|
||||||
|
|
@ -154,7 +186,7 @@ export const Rooms: Component = () => {
|
||||||
<p class="mono-label text-muted-foreground">rooms / registry</p>
|
<p class="mono-label text-muted-foreground">rooms / registry</p>
|
||||||
<h1 class="display mt-2 text-4xl sm:text-5xl">房间</h1>
|
<h1 class="display mt-2 text-4xl sm:text-5xl">房间</h1>
|
||||||
<p class="mt-3 max-w-xl text-sm text-muted-foreground">
|
<p class="mt-3 max-w-xl text-sm text-muted-foreground">
|
||||||
创建或进入房间。已接入新分页查询(<code class="font-mono">/api/rooms/query</code>)与管理面创建(<code class="font-mono">/api/manage/rooms</code>),支持搜索、公开度与成员统计。
|
创建或进入房间。已接入实时推送(<code class="font-mono">/api/rooms/events</code> SSE)· 新房间 / LIVE 状态变化自动刷新。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
@ -239,6 +271,13 @@ export const Rooms: Component = () => {
|
||||||
<h2 class="flex items-center gap-2 font-semibold">
|
<h2 class="flex items-center gap-2 font-semibold">
|
||||||
<RadioTower class="size-4 text-muted-foreground" />
|
<RadioTower class="size-4 text-muted-foreground" />
|
||||||
房间列表
|
房间列表
|
||||||
|
<span class="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-normal" classList={{ 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600': roomsLive(), 'border-line bg-muted text-muted-foreground': !roomsLive() }}>
|
||||||
|
<span class="size-1.5 rounded-full" classList={{ 'bg-emerald-500 animate-pulse': roomsLive(), 'bg-muted-foreground/40': !roomsLive() }} />
|
||||||
|
{roomsLive() ? '实时' : '离线'}
|
||||||
|
</span>
|
||||||
|
<Show when={lastSync()}>
|
||||||
|
<span class="font-mono text-[10px] text-muted-foreground">· {lastSync()}</span>
|
||||||
|
</Show>
|
||||||
</h2>
|
</h2>
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
<span class="mono-label animate-pulse text-muted-foreground">loading…</span>
|
<span class="mono-label animate-pulse text-muted-foreground">loading…</span>
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,30 @@ export function subscribeRoom(room: string, onEvent: (ev: RoomEvent) => void): (
|
||||||
})
|
})
|
||||||
return () => es.close()
|
return () => es.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoomsEvent {
|
||||||
|
type: 'rooms'
|
||||||
|
event: 'create' | 'update' | 'delete' | 'sync'
|
||||||
|
room: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeRooms(
|
||||||
|
onEvent: (ev: RoomsEvent) => void,
|
||||||
|
onState?: (live: boolean) => void,
|
||||||
|
): () => void {
|
||||||
|
const es = new EventSource('/api/rooms/events', { withCredentials: true })
|
||||||
|
es.addEventListener('rooms', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse((e as MessageEvent).data)
|
||||||
|
onEvent(data as RoomsEvent)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
es.onopen = () => onState?.(true)
|
||||||
|
es.onerror = () => onState?.(false)
|
||||||
|
// initial state hint: if already open
|
||||||
|
if (es.readyState === EventSource.OPEN) onState?.(true)
|
||||||
|
return () => es.close()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ export default defineConfig(({ mode }) => ({
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
host: "127.0.0.1",
|
||||||
|
port: 3000,
|
||||||
// 开发态把控制面 / RTC / 直播流反代到本地 Go 服务(不影响构建产物)
|
// 开发态把控制面 / RTC / 直播流反代到本地 Go 服务(不影响构建产物)
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8088',
|
'/api': 'http://localhost:8088',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue