feat: consolidate on the shadcn frontend and harden playback

Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
  keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
  one router and one design system to maintain.

Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
  single 4s segment, so playback ran at up to 1.10x and the forward buffer
  bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
  buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
  hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
  the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
  gesture that restored sound also toggled playback off. One owner now.

Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
  transcoded ones, so a source option exists even when every ladder entry
  downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
  MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.

Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
  never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
  left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
  JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
  and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
  and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
  httpx client open for the streamed body.

Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
  .venv; remove generated screenshots, load test scripts and stray files.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-09-22 18:07:56 +08:00
1 parent 186e32caf1
commit 996e3fea39
70 files changed
+4844 -3070

No files matched your search

+21 -2
View File
@@ -1,15 +1,34 @@
# dependencies and build output
node_modules/
dist/
__pycache__/
*.pyc
*.tsbuildinfo
# local state and secrets
*.db
venv/
.venv/
*.log
mediamtx/mediamtx.tar.gz
# OSS credentials (accessKeySecret lives here) — never commit
RainOSS
RainOSS.bak
# generated at runtime
runtime/
media/
.playwright-mcp/
*.crt
*.key
# MediaMTX binaries and archives are fetched, not committed
mediamtx/mediamtx
mediamtx/mediamtx.exe
mediamtx/mediamtx.tar.gz
# lockfiles are intentionally not tracked
package-lock.json
# edge-agent
edge-agent/dist/
edge-agent/mediamtx/
+14 -12
View File
@@ -14,43 +14,45 @@ Run these commands from PowerShell on the Windows node.
### 1. Install prerequisites
Required on PATH: Python 3.11+, Node.js LTS with npm, FFmpeg, and MediaMTX unless starting with -NoMediaMTX. Optional for GPU verification: NVIDIA driver and nvidia-smi.
Required on PATH: Python 3.11+, FFmpeg, and MediaMTX. MediaMTX may instead be left in the repository's `mediamtx\` directory, which is where the backend looks second. Node.js is only needed to build the frontend for deployment.
### 2. Install application dependencies
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1 -InstallDeps -NoMediaMTX
powershell -ExecutionPolicy Bypass -File .\start.ps1 -InstallDeps
```
### 3. Start the full Windows stack
### 3. Start the stack
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
This starts MediaMTX origin, FastAPI backend on 0.0.0.0:8080, Vite frontend on 0.0.0.0:5173, HLS output under runtime\hls, and logs under runtime\logs.
This starts the FastAPI backend on 0.0.0.0:8080. The backend owns everything else:
Open backend docs at http://127.0.0.1:8080/docs and frontend UI at http://127.0.0.1:5173/. Publish RTMP to rtmp://127.0.0.1:1935/live/<stream_key>. MediaMTX HLS is http://127.0.0.1:8888/<stream_key>/index.m3u8.
- **MediaMTX** is started at boot if nothing is already answering its API on :9997, and restarted by the supervisor if it dies. Set `LS_MEDIA_ORIGIN_AUTOSTART=false` to manage it externally; the backend then only supervises transcoders.
- **Transcoders** are started for every live stream whose MediaMTX path is publishing, and restarted within ~10s of an ffmpeg process exiting.
- **HLS** is written under `runtime\hls` and served through `GET /hls/...`, which falls back to proxying MediaMTX's own HLS when the local files are absent.
### 4. Useful Windows variants
Publish RTMP to rtmp://127.0.0.1:1935/live/<stream_key>. Backend docs are at http://127.0.0.1:8080/docs.
### 4. Useful variants
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1 -NoMediaMTX
powershell -ExecutionPolicy Bypass -File .\start.ps1 -NoFrontend
powershell -ExecutionPolicy Bypass -File .\start.ps1 -HostAddress 0.0.0.0 -BackendPort 8080 -FrontendPort 5173
powershell -ExecutionPolicy Bypass -File .\start.ps1 -HostAddress 0.0.0.0 -BackendPort 8080
```
### 5. Smoke test commands
```powershell
curl.exe -I http://127.0.0.1:5173/
curl.exe http://127.0.0.1:8080/api/health
curl.exe http://127.0.0.1:9997/v3/paths/list
ffmpeg -version
mediamtx --version
nvidia-smi
```
If browser or network tests run from another machine, open the Windows firewall for ports 5173, 8080, 1935, and 8888.
If browser or network tests run from another machine, open the Windows firewall for ports 8080, 1935, and 8888.
## Windows edge node launcher
@@ -82,4 +84,4 @@ Replay records store paths relative to LS_REPLAY_MEDIA_DIR. Absolute paths and p
## Linux and macOS
Linux and macOS are not the primary deployment path. For local development, run the backend with uvicorn from backend, run the frontend with npm run dev from frontend, and set LS_HLS_RUNTIME_DIR to a writable directory.
Linux and macOS are not the primary deployment path. For local development, run the backend with uvicorn from `backend`, run the frontend with `npm run dev` from `frontend`, and set `LS_HLS_RUNTIME_DIR` to a writable directory.
+20
View File
@@ -16,6 +16,15 @@ class Settings(BaseSettings):
media_origin_rtmp_port: int = 1935
media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888
# MediaMTX is supervised by the backend: started at boot if the origin is not
# already answering, and restarted if it dies. Set LS_MEDIA_ORIGIN_AUTOSTART=false
# to manage it externally (e.g. systemd, or an edge node's own launcher).
media_origin_autostart: bool = True
# "mediamtx" resolves via PATH; the repo-local copies are the fallback so a
# checkout works without a system-wide install. On Windows the binary sits in
# mediamtx\, on Linux/macOS it is usually a bare `mediamtx` on PATH.
media_origin_binary: str = "mediamtx"
media_origin_config: str = str(APP_DIR.parent / "mediamtx" / "mediamtx.yml")
replay_media_dir: str = str(APP_DIR.parent / "media")
hls_runtime_dir: str = str(APP_DIR.parent / "runtime" / "hls")
hls_segment_seconds: int = 4
@@ -43,6 +52,17 @@ class Settings(BaseSettings):
{"label": "360p", "width": 640, "height": 360, "bitrate": "600k"},
]
# Extra rendition that is published alongside the configured ones: the
# publisher's own video+audio stream remuxed into HLS without re-encoding.
# It costs almost no CPU (stream copy) and gives viewers a lossless
# "原画" (source) option even when every configured rendition downsizes.
source_rendition_label: str = "原画"
source_rendition_enabled: bool = True
# On-disk/URL name for that rendition. It IS the HLS path segment, and MediaMTX
# rejects any path containing non-alphanumeric characters, so the Chinese
# display label above cannot be used here — a CI would 500 trying to serve it.
source_rendition_slug: str = "source"
transcode_profiles: dict[str, list[dict]] = {
"standard": qualities,
"bandwidth-saver": [
+194 -41
View File
@@ -8,11 +8,13 @@ from starlette.background import BackgroundTask
import httpx
import asyncio
import time
import traceback
import psutil
from database import init_db, session_factory
from services.transcoder import transcode_mgr
from services.stream_service import StreamService
from services.media_service import media_service
from services.mediamtx_manager import mediamtx_mgr
from models.stream import StreamStatus
from routes import streams_router, nodes_router, auth_router
from routes.settings_route import router as settings_router
@@ -21,42 +23,124 @@ from services.load_balancer import lb
from services.node_store import load_nodes
async def _origin_paths() -> dict[str, dict]:
"""Fetch MediaMTX path list keyed by path name. Empty dict when origin is unreachable."""
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(
f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list"
)
response.raise_for_status()
return {item.get("name"): item for item in response.json().get("items", [])}
except Exception:
return {}
async def supervise_transcoders() -> list[str]:
"""Restart transcoders that died or drifted from the stream's config.
A live stream whose upstream is publishing (MediaMTX path ready) but whose
local ffmpeg processes have exited would otherwise stay black forever: the
HLS output stops updating and nothing re-triggers a start.
Returns the stream keys that were (re)started this tick.
"""
restarted: list[str] = []
paths = await _origin_paths()
if not paths:
return restarted
async with session_factory() as db:
service = StreamService(db)
for stream in await service.get_all_streams():
if stream.status != StreamStatus.live:
continue
path = paths.get(stream.stream_key) or paths.get(f"live/{stream.stream_key}")
# Only act when the publisher is actually connected: restarting against
# a missing source just burns ffmpeg processes that exit immediately.
if not path or not path.get("ready"):
continue
# Must match what start_transcode is given below: needs_restart
# compares against the signature recorded at the last start, so
# passing a different list here would restart the encoders every tick.
qualities = service.transcode_renditions(stream)
if transcode_mgr.needs_restart(stream.stream_key, qualities):
transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
qualities,
)
restarted.append(stream.stream_key)
return restarted
async def _metrics_tick(previous, previous_time):
current = psutil.net_io_counters()
current_time = time.monotonic()
elapsed = current_time - previous_time
network_in = ((current.bytes_recv - previous.bytes_recv) * 8 / elapsed / 1e6) if elapsed > 0 else None
network_out = ((current.bytes_sent - previous.bytes_sent) * 8 / elapsed / 1e6) if elapsed > 0 else None
viewer_count = None
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list")
response.raise_for_status()
payload = response.json()
# MediaMTX reports readers as an array of reader objects; there is no
# readersCount field in the version this project ships. Summing the
# array directly raised TypeError as soon as anyone was watching,
# which the bare except below turned into a permanent "no viewers".
viewer_count = sum(len(item.get("readers") or []) for item in payload.get("items", []))
except Exception:
pass
lb.report_health("default", {
"cpu_percent": psutil.cpu_percent(),
"memory_percent": psutil.virtual_memory().percent,
"bandwidth_mbps": network_in + network_out if network_in is not None and network_out is not None else None,
"network_in_mbps": network_in,
"network_out_mbps": network_out,
"active_connections": len(psutil.net_connections(kind="tcp")),
"hls_readers": viewer_count,
"viewer_count": viewer_count,
"frame_drop_indicators": None,
"packet_loss_indicators": None,
"media_metrics_available": False,
})
return current, current_time
async def monitor_origin():
"""Background supervisor loop.
Every step is individually guarded: this task is created with create_task, so
an exception escaping it would silently kill supervision forever with no
traceback reaching the logs. Each phase catches its own errors instead.
"""
previous = psutil.net_io_counters()
previous_time = time.monotonic()
while True:
await asyncio.sleep(10)
current = psutil.net_io_counters()
current_time = time.monotonic()
elapsed = current_time - previous_time
network_in = ((current.bytes_recv - previous.bytes_recv) * 8 / elapsed / 1e6) if elapsed > 0 else None
network_out = ((current.bytes_sent - previous.bytes_sent) * 8 / elapsed / 1e6) if elapsed > 0 else None
media_available = False
viewer_count = None
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list")
response.raise_for_status()
payload = response.json()
viewer_count = sum(item.get("readers", 0) for item in payload.get("items", []))
media_available = True
previous, previous_time = await _metrics_tick(previous, previous_time)
except Exception:
pass
lb.report_health("default", {
"cpu_percent": psutil.cpu_percent(),
"memory_percent": psutil.virtual_memory().percent,
"bandwidth_mbps": network_in + network_out if network_in is not None and network_out is not None else None,
"network_in_mbps": network_in,
"network_out_mbps": network_out,
"active_connections": len(psutil.net_connections(kind="tcp")),
"hls_readers": viewer_count,
"viewer_count": viewer_count,
"frame_drop_indicators": None,
"packet_loss_indicators": None,
"media_metrics_available": False,
})
previous = current
previous_time = current_time
traceback.print_exc()
try:
# MediaMTX first: the transcoder pass below only acts on a stream whose
# MediaMTX path is ready, so a dead origin would otherwise be reported
# as "nothing to do" forever.
if await mediamtx_mgr.ensure_running():
await mediamtx_mgr.wait_until_ready()
except asyncio.CancelledError:
raise
except Exception:
traceback.print_exc()
try:
restarted = await supervise_transcoders()
for stream_key in restarted:
print(f"[supervisor] restarted transcoder for {stream_key}", flush=True)
except asyncio.CancelledError:
raise
except Exception:
traceback.print_exc()
@asynccontextmanager
@@ -64,6 +148,12 @@ async def lifespan(app: FastAPI):
load_nodes()
await init_db()
os.makedirs(settings.hls_runtime_dir, exist_ok=True)
# Bring the media origin up before the transcoders: they immediately open RTMP
# connections to it, and against a dead origin every ffmpeg exits at once.
if settings.media_origin_autostart and not await mediamtx_mgr.is_healthy():
mediamtx_mgr.start()
if not await mediamtx_mgr.wait_until_ready():
print("[supervisor] MediaMTX not ready yet; it will be retried", flush=True)
async with session_factory() as db:
service = StreamService(db)
for stream in await service.get_all_streams():
@@ -71,12 +161,13 @@ async def lifespan(app: FastAPI):
transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
service.effective_renditions(stream),
service.transcode_renditions(stream),
)
origin_monitor = asyncio.create_task(monitor_origin())
yield
origin_monitor.cancel()
transcode_mgr.stop_all()
mediamtx_mgr.stop()
app = FastAPI(title="ProgramCore", lifespan=lifespan)
@@ -112,16 +203,78 @@ async def serve_hls(path: str):
return FileResponse(fs_path)
target = f"http://127.0.0.1:{settings.media_origin_hls_port}/{path}"
# MediaMTX gates HLS behind a "cookieCheck" round trip: it answers with a 302 to
# "<path>?cookieCheck=1" and sets a *Secure* cookie. Over plain HTTP (any
# non-TLS deployment, and every dev machine) browsers refuse to store a Secure
# cookie, so the redirect never terminates and the player loops until it gives
# up. The browser should never see that handshake at all, so complete it here
# and hand back only the final playlist.
#
# The client also has to outlive this function: the body is streamed after we
# return, so an `async with` would close the pool before the first chunk.
client = httpx.AsyncClient(timeout=httpx.Timeout(10), follow_redirects=True)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
req = client.build_request("GET", target)
resp = await client.send(req, stream=True)
from starlette.responses import StreamingResponse
return StreamingResponse(
resp.aiter_bytes(),
status_code=resp.status_code,
headers=dict(resp.headers),
media_type=resp.headers.get("content-type"),
)
request = client.build_request("GET", target)
response = await client.send(request, stream=True)
except Exception:
raise HTTPException(status_code=404)
await client.aclose()
raise HTTPException(status_code=404)
from starlette.responses import StreamingResponse
headers = {
key: value
for key, value in response.headers.items()
# Length/transfer headers describe the origin's framing, not ours, and
# hop-by-hop headers must not be relayed.
if key.lower() not in {"content-length", "transfer-encoding", "connection"}
}
async def relay():
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose()
await client.aclose()
return StreamingResponse(
relay(),
status_code=response.status_code,
headers=headers,
media_type=response.headers.get("content-type"),
)
from starlette.responses import StreamingResponse
headers = {
key: value
for key, value in response.headers.items()
# Length/transfer headers describe the origin's framing, not ours, and
# hop-by-hop headers must not be relayed.
if key.lower() not in {"content-length", "transfer-encoding", "connection"}
}
# MediaMTX answers HLS requests with a 302 to "<path>?cookieCheck=1" so the
# player picks up its session cookie. That Location is relative to the origin
# root, so relaying it unchanged sends the browser to /<path> on the backend
# host — not an /hls route, hence a 404 instead of a playlist. Re-anchor it.
location = response.headers.get("location")
if response.is_redirect and location:
parts = urlsplit(location)
if parts.path:
headers["location"] = f"/hls{parts.path}" + (f"?{parts.query}" if parts.query else "")
async def relay():
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose()
await client.aclose()
return StreamingResponse(
relay(),
status_code=response.status_code,
headers=headers,
media_type=response.headers.get("content-type"),
)
+25 -29
View File
@@ -2,6 +2,7 @@ import os
import shutil
from pathlib import Path
import psutil
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
@@ -88,33 +89,27 @@ async def update_transcode_profiles(
@router.get("/system-metrics")
async def get_system_metrics(_: bool = Depends(require_auth)):
load_1m, load_5m, load_15m = os.getloadavg()
memory_total = 0
memory_available = 0
meminfo = Path("/proc/meminfo")
if meminfo.exists():
values = {}
for line in meminfo.read_text(encoding="ascii").splitlines():
key, _, value = line.partition(":")
if value:
values[key] = int(value.strip().split()[0]) * 1024
memory_total = values.get("MemTotal", 0)
memory_available = values.get("MemAvailable", 0)
cpu_count = os.cpu_count() or 1
# There is no load average on Windows: os.getloadavg does not exist there, and
# psutil's emulation reads as 0.0 until its background sampler warms up (and
# never means quite the same thing). Report real CPU utilisation instead, so
# the dashboard's "CPU 使用率" card is not permanently stuck at zero.
if hasattr(os, "getloadavg"):
load_1m, load_5m, load_15m = os.getloadavg()
load_percent = round(min(100, load_1m / cpu_count * 100), 1)
else:
load_1m = load_5m = load_15m = None
# interval=None never blocks; it measures since the previous call anywhere
# in the process, which the supervisor's 10s metrics tick keeps fresh.
load_percent = round(psutil.cpu_percent(), 1)
memory = psutil.virtual_memory()
network = psutil.net_io_counters()
metrics_path = Path(settings.replay_media_dir).resolve()
disk_path = metrics_path if metrics_path.exists() else Path.cwd()
disk = shutil.disk_usage(disk_path)
cpu_count = os.cpu_count() or 1
network_in_bytes = 0
network_out_bytes = 0
netdev = Path("/proc/net/dev")
if netdev.exists():
for line in netdev.read_text(encoding="ascii").splitlines()[2:]:
_, _, counters = line.partition(":")
fields = counters.split()
if len(fields) >= 9:
network_in_bytes += int(fields[0])
network_out_bytes += int(fields[8])
return {
"cpu": {
@@ -122,12 +117,13 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
"load_1m": load_1m,
"load_5m": load_5m,
"load_15m": load_15m,
"load_percent": round(min(100, load_1m / cpu_count * 100), 1),
"load_percent": load_percent,
"percent": load_percent,
},
"memory": {
"total": memory_total,
"available": memory_available,
"used_percent": round((1 - memory_available / memory_total) * 100, 1) if memory_total else 0,
"total": memory.total,
"available": memory.available,
"used_percent": round((1 - memory.available / memory.total) * 100, 1) if memory.total else 0,
},
"disk": {
"total": disk.total,
@@ -135,7 +131,7 @@ async def get_system_metrics(_: bool = Depends(require_auth)):
"used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0,
},
"network": {
"bytes_recv": network_in_bytes,
"bytes_sent": network_out_bytes,
"bytes_recv": network.bytes_recv,
"bytes_sent": network.bytes_sent,
},
}
+55 -7
View File
@@ -9,10 +9,11 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from typing import Optional
from database import get_db
from database import get_db, session_factory
from routes.auth import require_auth
from services.stream_service import StreamService
from services.media_service import media_service
@@ -121,6 +122,21 @@ def _valid_replay_url(url: str | None) -> str | None:
return url
def _utc_iso(moment: datetime) -> str:
"""Render a stored timestamp as an explicit UTC instant for JSON.
Rows are written with datetime.utcnow(), so the value is UTC but carries no
tzinfo. `isoformat()` therefore emits "2026-09-22T07:47:11" with no offset,
and JavaScript's Date() reads that as *local* time — in UTC+8 every heartbeat
looked eight hours old and the live board's "active viewer" filter discarded
every session, so the count was permanently zero. The trailing Z is what makes
the client parse it as UTC.
"""
if moment is None:
return None
return moment.replace(microsecond=0).isoformat() + "Z"
def _status_payload(stream) -> dict:
return {
"stream_id": stream.id,
@@ -250,10 +266,33 @@ async def viewer_heartbeat(stream_id: int, body: ViewerHeartbeat,
raise HTTPException(status_code=422, detail="观众会话 ID 无效")
viewer = await db.get(ViewerSession, session_id)
if viewer and viewer.stream_id != stream_id:
raise HTTPException(status_code=409, detail="观众会话已用于其他节目")
# A session id lives in the viewer's browser, not per programme, so anyone
# who switches streams arrives still bound to the one they started on.
# Re-bind them rather than 409: "this session is watching something else"
# is not a conflict a viewer can act on, and leaving it stuck meant every
# later heartbeat and command poll for this viewer failed for good.
viewer.stream_id = stream_id
viewer.remote_adjusted = False
if not viewer:
viewer = ViewerSession(session_id=session_id, stream_id=stream_id)
db.add(viewer)
try:
await db.flush()
except IntegrityError:
# A first heartbeat can be sent twice in quick succession (React
# StrictMode runs mount effects twice in development), and both
# requests can miss the lookup above before either commits. The loser
# adopts the winner's row instead of returning a 500.
#
# Recovery runs on its own session: this one is now in a failed
# transaction, and a session that is still dirty at teardown makes
# get_db commit on exit and re-raise the very IntegrityError we just
# handled.
async with session_factory() as recovery:
viewer = await recovery.get(ViewerSession, session_id)
if not viewer:
raise HTTPException(status_code=409, detail="观众会话创建失败,请刷新页面")
return {"ok": True, "remote_adjusted": viewer.remote_adjusted}
viewer.node_id = body.node_id
viewer.node_name = body.node_name
viewer.quality = body.quality
@@ -282,7 +321,7 @@ async def list_viewer_sessions(stream_id: int, db: AsyncSession = Depends(get_db
"buffered_seconds": viewer.buffered_seconds,
"error": viewer.error,
"remote_adjusted": viewer.remote_adjusted,
"last_heartbeat": viewer.last_heartbeat.isoformat(),
"last_heartbeat": _utc_iso(viewer.last_heartbeat),
} for viewer in viewers]
@@ -300,8 +339,8 @@ async def control_audit(stream_id: int, db: AsyncSession = Depends(get_db),
"value": command.value,
"status": command.status,
"result": command.result,
"created_at": command.created_at.isoformat(),
"claimed_at": command.claimed_at.isoformat() if command.claimed_at else None,
"created_at": _utc_iso(command.created_at),
"claimed_at": _utc_iso(command.claimed_at),
} for command in commands]
@@ -552,8 +591,17 @@ async def send_remote_command(stream_id: int, body: RemoteCommand,
async def poll_remote_command(stream_id: int, session_id: str,
db: AsyncSession = Depends(get_db)):
viewer = await db.get(ViewerSession, session_id)
if not viewer or viewer.stream_id != stream_id:
if not viewer:
raise HTTPException(status_code=404, detail="观众会话不存在")
if viewer.stream_id != stream_id:
# A session id is stored per browser, not per programme, so a viewer who
# switches streams arrives with the id they registered on the old one.
# Remote control is meaningless if that 404s forever, so re-bind them
# instead — they are watching this stream now, which is the only fact the
# admin panel needs. Commands aimed at the old stream are not carried over.
viewer.stream_id = stream_id
viewer.remote_adjusted = False
await db.commit()
command = (await db.execute(select(ControlCommand).where(
ControlCommand.stream_id == stream_id,
ControlCommand.target_session_id == session_id,
@@ -566,7 +614,7 @@ async def poll_remote_command(stream_id: int, session_id: str,
viewer.remote_adjusted = command.action != "restore_auto"
await db.commit()
return {"id": command.id, "action": command.action, "value": command.value,
"created_at": command.created_at.isoformat()}
"created_at": _utc_iso(command.created_at)}
@router.post("/{stream_id}/go-live")
+10 -2
View File
@@ -22,8 +22,16 @@ class MediaMTXService:
if resp.status_code == 200:
data = resp.json()
for item in data.get("items", []):
if item.get("name") == stream_key:
return {"ready": item.get("ready", False), "source_ready": item.get("sourceReady", False)}
# Paths are named after the RTMP URL key, so a publisher on
# rtmp://host/live/<key> shows up as "live/<key>", never as
# the bare key. This MediaMTX exposes the reader list itself
# rather than a readersCount/sourceReady pair.
if item.get("name") in (stream_key, f"live/{stream_key}"):
return {
"ready": item.get("ready", False),
"source_ready": bool(item.get("source")),
"readers": len(item.get("readers") or []),
}
except Exception:
return None
return None
+158
View File
@@ -0,0 +1,158 @@
import asyncio
import os
import shutil
import subprocess
import httpx
from config import settings
def _log(message: str) -> None:
print(f"[mediamtx] {message}", flush=True)
def resolve_binary(configured: str) -> str | None:
"""Find the MediaMTX executable: the configured name on PATH, else the repo copy.
The repository ships both an ELF `mediamtx` and a Windows `mediamtx.exe`, so a
bare-name fallback has to prefer the platform's own extension first — picking
the other one fails at exec time with "not a valid Win32 application".
"""
found = shutil.which(configured)
if found:
return found
if os.path.dirname(configured):
return configured if os.path.isfile(configured) else None
repo_dir = os.path.dirname(os.path.abspath(settings.media_origin_config))
candidates = [f"{configured}.exe", configured] if os.name == "nt" else [configured, f"{configured}.exe"]
for candidate in candidates:
path = os.path.join(repo_dir, candidate)
if os.path.isfile(path):
return path
return None
class MediaMTXManager:
"""Keeps the MediaMTX origin alive.
The backend owns the media origin: it is the only thing that can tell whether
the origin is actually serving (its own /v3 API), and RTMP ingest is dead
without it while transcoders keep burning CPU against a missing source. When
an external supervisor already runs MediaMTX, the health probe below sees it
and this manager stays out of the way.
Only the process this manager started is ever stopped, so an externally
managed origin is never killed on shutdown.
"""
def __init__(self) -> None:
self._process: subprocess.Popen | None = None
self._failures = 0
self._binary_missing_logged = False
async def is_healthy(self) -> bool:
"""True when the origin API answers, regardless of who started it."""
try:
async with httpx.AsyncClient(timeout=2) as client:
response = await client.get(
f"http://{settings.media_origin_host}:{settings.media_origin_api_port}/v3/paths/list"
)
return response.status_code == 200
except Exception:
return False
def _started_process_alive(self) -> bool:
return self._process is not None and self._process.poll() is None
def start(self) -> None:
"""Launch MediaMTX unless one is already running. Never raises."""
if self._process is not None:
# A process we started exited; clear it so the next tick can retry.
if self._process.poll() is not None:
_log(f"process exited with code {self._process.returncode}")
self._process = None
else:
return
binary = resolve_binary(settings.media_origin_binary)
if binary is None:
# No MediaMTX anywhere is a deployment gap, not a crash: the API still
# serves, the admin UI can explain it, and RTMP ingest is simply down.
if not self._binary_missing_logged:
_log(f"'{settings.media_origin_binary}' not found on PATH or in mediamtx/; RTMP ingest unavailable")
self._binary_missing_logged = True
return
config_path = settings.media_origin_config
command = [binary]
if config_path and os.path.isfile(config_path):
command.append(config_path)
else:
_log(f"config not found at {config_path}; running with defaults")
config_path = ""
try:
self._process = subprocess.Popen(
command,
cwd=os.path.dirname(os.path.abspath(config_path)) if config_path else None,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
if not self._binary_missing_logged:
_log(f"failed to launch {binary}; RTMP ingest unavailable")
self._binary_missing_logged = True
self._process = None
except Exception as error: # pragma: no cover - defensive
_log(f"failed to start: {error!r}")
self._process = None
async def ensure_running(self) -> bool:
"""Start the origin if it is neither healthy nor already running.
Returns True when this call started it.
"""
if not settings.media_origin_autostart:
return False
if await self.is_healthy() or self._started_process_alive():
self._failures = 0
return False
# Back off after repeated failures so a broken binary cannot be relaunched
# every supervisor tick, which would spam processes and logs.
if self._failures:
self._failures -= 1
return False
self.start()
if self._process is None:
self._failures = 12
return False
self._failures = 0
_log(f"started (pid {self._process.pid})")
return True
async def wait_until_ready(self, timeout: float = 15.0) -> bool:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
if await self.is_healthy():
return True
await asyncio.sleep(0.5)
return False
def stop(self) -> None:
"""Stop only the process this manager started."""
process, self._process = self._process, None
if process is None or process.poll() is not None:
return
try:
process.terminate()
process.wait(timeout=5)
except Exception:
try:
process.kill()
except Exception:
pass
mediamtx_mgr = MediaMTXManager()
+73 -5
View File
@@ -7,9 +7,24 @@ from services.multicast_detector import multicast_detector
from services.transcoder import transcode_mgr
from config import settings
import json
import re
from datetime import datetime
# MediaMTX rejects any HLS path that is not alphanumeric ("invalid path name: can
# contain only alphanumeric characters"), and the label is also used as a directory
# name, so every rendition needs a path-safe slug of its own.
def hls_path_slug(label: str) -> str:
"""Filesystem- and MediaMTX-safe rendition path for a display label."""
slug = re.sub(r"[^A-Za-z0-9]", "", label or "")
return slug or "rendition"
def with_hls_path(rendition: dict) -> dict:
"""Attach the `path` an admin-configured rendition is stored and served under."""
return {**rendition, "path": hls_path_slug(rendition.get("label", ""))}
class StreamService:
def __init__(self, db: AsyncSession):
self.db = db
@@ -25,6 +40,56 @@ class StreamService:
pass
return settings.transcode_profiles.get(stream.transcode_profile, settings.qualities)
@staticmethod
def source_label() -> str:
"""Display name of the stream-copy rendition, or '' when it is disabled.
A blank label would leak into the HLS path and produce a URL like
/hls/<key>//index.m3u8, so it degrades to "off" instead.
"""
return settings.source_rendition_label.strip() if settings.source_rendition_enabled else ""
@classmethod
def source_slug(cls) -> str:
"""Path segment for that rendition, safe for MediaMTX and the filesystem."""
if not cls.source_label():
return ""
return hls_path_slug(settings.source_rendition_slug)
def watch_renditions(self, stream: Stream) -> list:
"""Renditions a viewer may pick: the stream-copy rendition first, then the configured ones.
Each entry carries both the label shown to viewers and the `path` used in
HLS URLs — MediaMTX only accepts alphanumeric HLS paths, so a Chinese label
such as "原画" cannot double as a directory name.
The source entry carries no size: the input resolution is only known to
ffmpeg, so the player measures it from the media it ends up decoding.
"""
renditions = [with_hls_path(r) for r in self.effective_renditions(stream)]
label, slug = self.source_label(), self.source_slug()
if not label or not slug or not renditions or renditions[0]["path"] == slug:
return renditions
return [{"label": label, "path": slug, "width": None, "height": None, "copy": True}] + renditions
def transcode_renditions(self, stream: Stream) -> list:
"""Renditions to hand to the transcoder, including the stream-copy source rendition.
Carries the same `path` values as watch_renditions so the files ffmpeg
writes are the files the player asks for.
The copy entry is the top rendition with its label replaced, so it carries
every key the transcoder reads. ffmpeg ignores those numbers for a stream
copy, but the supervisor in main.py compares TranscodeManager's recorded
signature against this list every tick — an entry missing a field would
both crash the start and make the signature drift.
"""
renditions = [with_hls_path(r) for r in self.effective_renditions(stream)]
label, slug = self.source_label(), self.source_slug()
if not label or not slug or not renditions or renditions[0]["path"] == slug:
return renditions
return [{**renditions[0], "label": label, "path": slug, "copy": True}] + renditions
async def create_stream(self, title: str, description: str | None = None,
cover_url: str | None = None,
scheduled_at: str | None = None,
@@ -32,6 +97,7 @@ class StreamService:
replay_url: str | None = None,
chapters: list[dict] | None = None,
transcode_profile: str = "standard",
renditions_json: str | None = None,
homepage_visible: bool = True,
remote_control_enabled: bool = False) -> Stream:
import uuid
@@ -49,6 +115,7 @@ class StreamService:
replay_url=replay_url,
chapters_json=json.dumps(chapters or [], ensure_ascii=False),
transcode_profile=transcode_profile,
renditions_json=renditions_json,
homepage_visible=homepage_visible,
remote_control_enabled=remote_control_enabled,
stream_key=uuid.uuid4().hex,
@@ -108,7 +175,7 @@ class StreamService:
started_at=datetime.utcnow(),
)
rtmp_url = media_service.get_rtmp_url(stream_key)
transcode_mgr.start_transcode(stream_key, rtmp_url, self.effective_renditions(stream))
transcode_mgr.start_transcode(stream_key, rtmp_url, self.transcode_renditions(stream))
async def set_stream_ended(self, stream_key: str):
stream = await self.get_stream_by_key(stream_key)
@@ -125,7 +192,7 @@ class StreamService:
stream = await self.get_stream_by_key(stream_key)
if not stream:
return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None}
qualities = self.effective_renditions(stream)
qualities = self.watch_renditions(stream)
best_node = lb.select_best_node(client_ip)
if not best_node:
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
@@ -142,12 +209,13 @@ class StreamService:
"qualities": [],
}
for quality in qualities:
label = quality["label"]
# The URL segment is the rendition's path slug, not its label:
# labels are display text and may be Chinese.
line_info["qualities"].append({
"label": label,
"label": quality["label"],
"width": quality["width"],
"height": quality["height"],
"url": f"{base_hls}/{label}/index.m3u8",
"url": f"{base_hls}/{quality['path']}/index.m3u8",
})
lines.append(line_info)
+97 -15
View File
@@ -5,15 +5,67 @@ from config import settings
class TranscodeManager:
_processes: dict[str, list[subprocess.Popen]] = {}
# Signature of the last successful start, so a supervisor can tell whether a
# running set of processes still matches the stream's current configuration.
_signatures: dict[str, str] = {}
_failed_starts: dict[str, int] = {}
@staticmethod
def _signature(qualities: list[dict]) -> str:
return "|".join(
f"{q.get('label')}:{q.get('width')}x{q.get('height')}:{q.get('bitrate')}:"
f"{'copy' if q.get('copy') else q.get('codec', 'libx264')}"
for q in qualities
)
def is_running(self, stream_key: str) -> bool:
"""True when at least one transcode process exists and none have exited."""
procs = self._processes.get(stream_key)
if not procs:
return False
return any(proc.poll() is None for proc in procs)
def needs_restart(self, stream_key: str, qualities: list[dict]) -> bool:
if not qualities:
return False
return self._signatures.get(stream_key) != self._signature(qualities) or not self.is_running(stream_key)
def _clear_stale_output(self, hls_dir: str) -> None:
"""Drop the previous run's playlist and segments.
ffmpeg writes a live playlist with no #EXT-X-ENDLIST, so a playlist left
behind by an encoder that died looks to hls.js exactly like a stream that
simply stopped: it keeps reloading a file that will never change and
buffers forever. Removing it makes the player see a missing manifest
instead, which it retries until the supervisor has the encoder back up.
"""
if not os.path.isdir(hls_dir):
return
for name in os.listdir(hls_dir):
if name.endswith(".ts") or name.endswith(".m3u8") or name.endswith(".m3u8.tmp"):
try:
os.remove(os.path.join(hls_dir, name))
except OSError:
# A player holding the file open (Windows) is fine: it will be
# overwritten by the restarting encoder.
pass
def start_transcode(self, stream_key: str, input_url: str, qualities: list[dict]):
if stream_key in self._processes:
if stream_key in self._processes and self.is_running(stream_key):
return
self.stop_transcode(stream_key)
procs = []
segment_seconds = max(1, settings.hls_segment_seconds)
for quality in qualities:
label = quality["label"]
# The stream-copy ("原画") rendition carries the top rendition's numbers
# only so the signature stays complete; ffmpeg copies the input rather
# than encoding to them. See StreamService.transcode_renditions.
is_copy = bool(quality.get("copy"))
# Files land under the rendition's path slug, never its label: MediaMTX
# refuses non-alphanumeric HLS paths and the label may be Chinese.
path_slug = quality.get("path") or label
width = quality["width"]
height = quality["height"]
fps = quality.get("fps", 30)
@@ -24,20 +76,10 @@ class TranscodeManager:
audio_bitrate = quality.get("audio_bitrate", "128k")
keyframe_seconds = quality.get("keyframe_seconds", segment_seconds)
keyframe_frames = keyframe_seconds * fps
hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, label)
hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, path_slug)
os.makedirs(hls_dir, exist_ok=True)
cmd = [
"ffmpeg", "-y",
"-i", input_url,
"-c:v", codec, "-preset", preset,
"-r", str(fps), "-b:v", bitrate, "-crf", str(crf),
"-s", f"{width}x{height}",
"-sc_threshold", "0",
"-g", str(keyframe_frames),
"-keyint_min", str(keyframe_frames),
"-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})",
"-copyts", "-start_at_zero",
"-c:a", "aac", "-b:a", audio_bitrate,
self._clear_stale_output(hls_dir)
hls_output = [
"-f", "hls",
"-hls_time", str(segment_seconds),
"-hls_list_size", "6",
@@ -45,6 +87,44 @@ class TranscodeManager:
"-hls_segment_filename", os.path.join(hls_dir, "segment_%03d.ts"),
os.path.join(hls_dir, "index.m3u8"),
]
# NOTE: ffmpeg's -reconnect* flags are HTTP/HTTPS-only protocol
# options and make RTMP inputs fail with "Option not found".
# RTMP drop recovery is handled by the supervisor in main.py,
# which restarts exited encoders on its next tick.
hls_input = ["-rw_timeout", "10000000", "-i", input_url]
if is_copy:
# Source ("原画") rendition: keep the publisher's video as-is and only
# re-wrap it, so the output keeps the exact incoming resolution,
# frame rate and bitrate. Video copy costs no CPU; audio is re-encoded
# because MPEG-TS cannot carry every codec a publisher might send, and
# one AAC track is a rounding error next to the video encoders.
cmd = (
["ffmpeg", "-y"] + hls_input
+ [
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-copyts", "-start_at_zero",
"-map", "0:v:0", "-map", "0:a:0?",
]
+ hls_output
)
else:
cmd = (
["ffmpeg", "-y"] + hls_input
+ [
"-c:v", codec, "-preset", preset,
"-r", str(fps), "-b:v", bitrate, "-crf", str(crf),
"-s", f"{width}x{height}",
"-sc_threshold", "0",
"-g", str(keyframe_frames),
"-keyint_min", str(keyframe_frames),
"-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})",
"-copyts", "-start_at_zero",
"-c:a", "aac", "-b:a", audio_bitrate,
]
+ hls_output
)
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
@@ -54,6 +134,8 @@ class TranscodeManager:
pass
self._processes[stream_key] = procs
self._signatures[stream_key] = self._signature(qualities)
self._failed_starts[stream_key] = 0 if procs else self._failed_starts.get(stream_key, 0) + 1
def stop_transcode(self, stream_key: str):
procs = self._processes.pop(stream_key, [])
@@ -72,4 +154,4 @@ class TranscodeManager:
self.stop_transcode(key)
transcode_mgr = TranscodeManager()
transcode_mgr = TranscodeManager()
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+2 -4
View File
@@ -1,12 +1,10 @@
<!DOCTYPE html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StreamCore</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,500;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+26 -6
View File
@@ -4,25 +4,45 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
"dev": "vite --host 0.0.0.0 --port 5175",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 5175"
},
"dependencies": {
"@ant-design/icons": "^5.4.0",
"antd": "^5.21.0",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hls.js": "^1.5.14",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.5.6",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2",
"vite": "^5.4.7"
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+72 -61
View File
@@ -1,24 +1,23 @@
import { Component, lazy, Suspense, useState, useEffect, type ErrorInfo, type ReactNode } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { Button, ConfigProvider, Result } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import theme from './theme/anthropic'
import './pageTransitions.css'
import { BrandProvider, useBrand } from './brand/BrandContext'
import { verifyToken } from './api/auth'
import { getToken } from './api/client'
import { Component, lazy, Suspense, useEffect, useState, type ErrorInfo, type ReactNode } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'sonner'
import { Button } from '@/components/ui/button'
import { BrandProvider, useBrand } from '@/brand/BrandContext'
import { verifyToken } from '@/api/auth'
import { getToken } from '@/api/client'
const HomePage = lazy(() => import('./viewer/pages/HomePage'))
const WatchPage = lazy(() => import('./viewer/pages/WatchPage'))
const AdminLayout = lazy(() => import('./admin/AdminLayout'))
const LoginPage = lazy(() => import('./admin/LoginPage'))
const Dashboard = lazy(() => import('./admin/pages/Dashboard'))
const LiveBoard = lazy(() => import('./admin/pages/LiveBoard'))
const StreamList = lazy(() => import('./admin/pages/StreamList'))
const StreamForm = lazy(() => import('./admin/pages/StreamForm'))
const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor'))
const NodeManager = lazy(() => import('./admin/pages/NodeManager'))
const SettingsPage = lazy(() => import('./admin/pages/Settings'))
const HomePage = lazy(() => import('@/viewer/pages/HomePage'))
const WatchPage = lazy(() => import('@/viewer/pages/WatchPage'))
const AdminLayout = lazy(() => import('@/admin/AdminLayout'))
const LoginPage = lazy(() => import('@/admin/LoginPage'))
const Dashboard = lazy(() => import('@/admin/pages/Dashboard'))
const LiveBoard = lazy(() => import('@/admin/pages/LiveBoard'))
const StreamList = lazy(() => import('@/admin/pages/StreamList'))
const StreamForm = lazy(() => import('@/admin/pages/StreamForm'))
const ReplayEditor = lazy(() => import('@/admin/pages/ReplayEditor'))
const NodeManager = lazy(() => import('@/admin/pages/NodeManager'))
const SettingsPage = lazy(() => import('@/admin/pages/Settings'))
const SwaggerPage = lazy(() => import('@/admin/pages/SwaggerPage'))
class PageErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
state = { failed: false }
@@ -34,24 +33,26 @@ class PageErrorBoundary extends Component<{ children: ReactNode }, { failed: boo
render() {
if (this.state.failed) {
return (
<Result
status="error"
title="页面加载失败"
subTitle="页面遇到异常,请重新加载后再试。"
extra={<Button type="primary" onClick={() => window.location.reload()}>重新加载</Button>}
/>
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-semibold">页面加载失败</h1>
<p className="text-muted-foreground">页面遇到异常,请重新加载后再试。</p>
<Button onClick={() => window.location.reload()}>重新加载</Button>
</div>
)
}
return this.props.children
}
}
function AdminGuard({ children }: { children: React.ReactNode }) {
function AdminGuard({ children }: { children: ReactNode }) {
const [authed, setAuthed] = useState<boolean | null>(null)
useEffect(() => {
const token = getToken()
if (!token) { setAuthed(false); return }
if (!token) {
setAuthed(false)
return
}
verifyToken().then(setAuthed)
}, [])
@@ -60,45 +61,55 @@ function AdminGuard({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
function AppRoutes() {
const location = useLocation()
const routeTransitionKey = location.pathname.startsWith('/admin') ? '/admin' : location.pathname
function RouteShell() {
const { brand } = useBrand()
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
useEffect(() => {
document.title = brand.brand_name
}, [brand.brand_name])
return (
<PageErrorBoundary>
<Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中"><span /></div>}>
<Routes>
<Route path="/watch/:id" element={<WatchPage />} />
<Route path="/admin/login" element={<LoginPage />} />
<Route path="/admin" element={
<AdminGuard>
<AdminLayout />
</AdminGuard>
}>
<Route index element={<Dashboard />} />
<Route path="board" element={<LiveBoard />} />
<Route path="streams" element={<StreamList />} />
<Route path="streams/new" element={<StreamForm />} />
<Route path="streams/:id/edit" element={<ReplayEditor />} />
<Route path="nodes" element={<NodeManager />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</PageErrorBoundary>
<PageErrorBoundary>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
}
>
<Routes>
<Route path="/watch/:id" element={<WatchPage />} />
<Route path="/admin/login" element={<LoginPage />} />
<Route
path="/admin"
element={
<AdminGuard>
<AdminLayout />
</AdminGuard>
}
>
<Route index element={<Dashboard />} />
<Route path="board" element={<LiveBoard />} />
<Route path="streams" element={<StreamList />} />
<Route path="streams/new" element={<StreamForm />} />
<Route path="streams/:id/edit" element={<ReplayEditor />} />
<Route path="nodes" element={<NodeManager />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="swagger" element={<SwaggerPage />} />
</Route>
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</PageErrorBoundary>
)
}
export default function App() {
return (
<ConfigProvider theme={theme} locale={zhCN}>
<BrandProvider>
<BrowserRouter>
<AppRoutes />
<BrandProvider>
<BrowserRouter>
<RouteShell />
<Toaster position="top-right" richColors closeButton />
</BrowserRouter>
</BrandProvider>
</ConfigProvider>
</BrandProvider>
)
}
}
+103 -66
View File
@@ -1,33 +1,28 @@
import { Suspense, useEffect, useState } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { Layout, Menu, Button, Typography } from 'antd'
import { useState } from 'react'
import { Outlet, useNavigate, useLocation, NavLink } from 'react-router-dom'
import {
DashboardOutlined, VideoCameraOutlined, PlusOutlined, MonitorOutlined,
CloudServerOutlined, SettingOutlined, LogoutOutlined,
} from '@ant-design/icons'
import { clearToken } from '../api/client'
import { useBrand } from '../brand/BrandContext'
LayoutDashboard,
MonitorPlay,
Video,
Plus,
Server,
Settings,
LogOut,
FileCode2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { clearToken } from '@/api/client'
import { useBrand } from '@/brand/BrandContext'
import { cn } from '@/lib/utils'
const { Sider, Content, Header } = Layout
const { Text } = Typography
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/board', icon: <MonitorOutlined />, label: '直播看板' },
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
]
const adminPageImports = [
() => import('./pages/Dashboard'),
() => import('./pages/LiveBoard'),
() => import('./pages/StreamList'),
() => import('./pages/StreamForm'),
() => import('./pages/ReplayEditor'),
() => import('./pages/NodeManager'),
() => import('./pages/Settings'),
const navItems = [
{ to: '/admin', label: '概览', icon: LayoutDashboard, end: true },
{ to: '/admin/board', label: '直播看板', icon: MonitorPlay },
{ to: '/admin/streams', label: '节目列表', icon: Video },
{ to: '/admin/streams/new', label: '创建节目', icon: Plus },
{ to: '/admin/nodes', label: '边缘节点', icon: Server },
{ to: '/admin/settings', label: '系统设置', icon: Settings },
{ to: '/admin/swagger', label: 'API 文档', icon: FileCode2 },
]
export default function AdminLayout() {
@@ -35,49 +30,91 @@ export default function AdminLayout() {
const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate()
const location = useLocation()
function navigateWithTransition(path: string) {
if (path !== location.pathname) navigate(path)
const handleLogout = () => {
clearToken()
navigate('/admin/login')
}
const selectedKey = menuItems.find(
item => location.pathname === item.key || (location.pathname === '/' && item.key === '/admin')
)?.key || '/admin'
const handleLogout = () => { clearToken(); navigate('/admin/login') }
const currentLabel =
navItems
.filter((item) => location.pathname === item.to || (!item.end && location.pathname.startsWith(item.to + '/')))
.sort((a, b) => b.to.length - a.to.length)[0]?.label || '管理控制台'
return (
<Layout className="admin-shell apple-material-shell">
<Sider
collapsible collapsed={collapsed} onCollapse={setCollapsed}
width={220}
className="admin-sider"
theme="dark"
<div className="flex min-h-screen bg-background">
{/* Sidebar */}
<aside
className={cn(
'flex shrink-0 flex-col border-r border-border bg-card transition-all duration-200',
collapsed ? 'w-16' : 'w-56',
)}
>
<div className="admin-brand">
<span className="admin-brand-mark">{brand.brand_name.slice(0, 1)}</span>
<Text strong style={{
display: collapsed ? 'none' : 'block', fontSize: 16, letterSpacing: '-0.02em',
color: '#F4F7FB', whiteSpace: 'nowrap',
}}>
{brand.brand_name}
</Text>
<div className="flex h-14 items-center gap-2 border-b border-border px-4">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-sm font-bold text-primary-foreground">
{brand.brand_name.slice(0, 1)}
</span>
{!collapsed && (
<span className="truncate text-sm font-semibold text-foreground">{brand.brand_name}</span>
)}
</div>
<Menu mode="inline" selectedKeys={[selectedKey]} items={menuItems}
onClick={({ key }) => navigateWithTransition(String(key))}
style={{ border: 'none', marginTop: 8 }} />
</Sider>
<Layout>
<Header className="admin-header">
<div><Text strong style={{ color: '#F4F7FB' }}>直播控制中心</Text><Text style={{ color: '#748096', marginLeft: 12 }}>{brand.brand_tagline}</Text></div>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退出登录</Button>
</Header>
<Content className="admin-content">
<div className="admin-page-transition" key={location.pathname}>
<Outlet />
</div>
</Content>
</Layout>
</Layout>
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
{navItems.map((item) => {
const Icon = item.icon
const active = item.end
? location.pathname === item.to
: location.pathname === item.to || location.pathname.startsWith(item.to + '/')
return (
<NavLink
key={item.to}
to={item.to}
title={collapsed ? item.label : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
active
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
</NavLink>
)
})}
</nav>
<div className="border-t border-border p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-3"
onClick={() => setCollapsed((v) => !v)}
>
<Settings className="h-4 w-4 shrink-0" />
{!collapsed && <span>{collapsed ? '展开' : '收起侧栏'}</span>}
</Button>
</div>
</aside>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-card px-6">
<nav className="flex items-center gap-1.5 text-sm">
<span className="text-muted-foreground">管理控制台</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{currentLabel}</span>
</nav>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="h-4 w-4" />
退出登录
</Button>
</header>
<main className="min-w-0 flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
)
}
+56 -50
View File
@@ -1,70 +1,76 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Typography, message } from 'antd'
import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import { login } from '../api/auth'
import { useBrand } from '../brand/BrandContext'
const { Title, Text } = Typography
import { Lock } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { login } from '@/api/auth'
import { useBrand } from '@/brand/BrandContext'
export default function LoginPage() {
const { brand } = useBrand()
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const handleSubmit = async (values: { password: string }) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!password) {
toast.error('请输入管理员密码')
return
}
setLoading(true)
try { await login(values.password); message.success('登录成功'); navigate('/admin') }
catch { message.error('密码错误') }
finally { setLoading(false) }
try {
await login(password)
toast.success('登录成功')
navigate('/admin')
} catch {
toast.error('密码错误')
} finally {
setLoading(false)
}
}
return (
<div style={{
minHeight: '100vh',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#F9F7F4',
}}>
<div style={{ width: '100%', maxWidth: 420, padding: 24 }}>
<div style={{
background: 'rgba(18, 24, 36, 0.88)',
borderRadius: 14,
border: '1px solid rgba(255, 255, 255, 0.1)',
padding: 56,
}}>
<div style={{ textAlign: 'center', marginBottom: 40 }}>
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="mb-8 text-center">
{brand.brand_logo_url && (
<img src={brand.brand_logo_url} alt={brand.brand_name} style={{ height: 36, marginBottom: 20 }} />
<img
src={brand.brand_logo_url}
alt={brand.brand_name}
className="mx-auto mb-5 h-9 object-contain"
/>
)}
<Title style={{ margin: '0 0 8px', fontSize: 28, fontWeight: 600, letterSpacing: '-0.02em' }}>
{brand.brand_name}
</Title>
<Text style={{ color: '#5E5A52', fontSize: 15 }}>
{brand.brand_tagline || '管理控制台'}
</Text>
<h1 className="text-2xl font-semibold tracking-tight">{brand.brand_name}</h1>
<p className="mt-1 text-sm text-muted-foreground">{brand.brand_tagline || '管理控制台'}</p>
</div>
<Form onFinish={handleSubmit} layout="vertical" size="large">
<Form.Item name="password" rules={[{ required: true, message: '请输入管理员密码' }]}
style={{ marginBottom: 28 }}>
<Input.Password
prefix={<LockOutlined style={{ color: '#949088' }} />}
placeholder="管理员密码"
style={{ height: 48, borderRadius: 8 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block
style={{ height: 48, fontSize: 16, fontWeight: 600, borderRadius: 8 }}>
登录管理控制台
</Button>
</Form.Item>
</Form>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="password">管理员密码</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入管理员密码"
className="h-11 pl-9"
autoFocus
/>
</div>
</div>
<Button type="submit" size="lg" className="w-full" disabled={loading}>
{loading ? '登录中…' : '登录管理控制台'}
</Button>
</form>
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button type="text" icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{ color: '#949088', fontSize: 13 }}>
<div className="mt-6 text-center">
<Button variant="ghost" size="sm" onClick={() => navigate('/')}>
返回首页
</Button>
</div>
+85 -82
View File
@@ -1,96 +1,99 @@
import { useState, useEffect } from 'react'
import { Row, Col, Statistic, Table, Tag, Typography } from 'antd'
import { SignalFilled, ClockCircleOutlined, PlayCircleOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography
import { Radio, Clock, PlayCircle } from 'lucide-react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { PageHeader } from '@/components/page-header'
import { StatCard } from '@/components/stat-card'
import { StatusBadge } from '@/components/status-badge'
import { fetchStreams, type StreamInfo } from '@/api/streams'
import { formatDateTime } from '@/lib/status'
export default function Dashboard() {
const { brand } = useBrand()
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
useEffect(() => {
let active = true
const loadData = async () => {
try {
const data = await fetchStreams(true)
if (active) setStreams(data)
} catch {
/* ignore */
}
if (active) setLoading(false)
}
void loadData()
const t = setInterval(loadData, 10000)
return () => {
active = false
clearInterval(t)
}
}, [])
async function loadData() {
try { setStreams(await fetchStreams(true)) } catch { /* */ }
setLoading(false)
}
const liveCount = streams.filter(s => s.status === 'live').length
const pendingCount = streams.filter(s => s.status === 'pending').length
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: '待开播' },
live: { color: 'green', label: '直播中' },
ended: { color: 'default', label: '已结束' },
cancelled: { color: 'red', label: '已取消' },
}
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (s: string) => <Tag color={(statusMap[s] || {}).color}>{(statusMap[s] || { label: s }).label}</Tag> },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.slice(0, 16), width: 170 },
]
const liveCount = streams.filter((s) => s.status === 'live').length
const pendingCount = streams.filter((s) => s.status === 'pending').length
return (
<div className="admin-page admin-dashboard">
<div className="admin-page-heading">
<Title level={1} style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 32, letterSpacing: '-0.03em' }}>
{brand.brand_name}
</Title>
<Text className="admin-page-kicker">管理控制台 · 实时刷新</Text>
<div className="space-y-6">
<PageHeader title="概览" kicker="Dashboard" description="节目状态与近期动态,每 10 秒自动刷新" />
<div className="grid gap-4 sm:grid-cols-3">
<StatCard label="播出中" value={loading ? '—' : liveCount} icon={Radio} tone="success" />
<StatCard label="待开播" value={loading ? '—' : pendingCount} icon={Clock} />
<StatCard label="节目总数" value={loading ? '—' : streams.length} icon={PlayCircle} />
</div>
<Row gutter={[24, 24]} style={{ marginBottom: 36 }}>
<Col xs={24} sm={8}>
<div style={{
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #5B8C5A',
}}>
<Statistic title="播出中" value={liveCount} loading={loading}
prefix={<SignalFilled style={{ color: '#5B8C5A', fontSize: 20 }} />} />
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #6B8CA8',
}}>
<Statistic title="待开播" value={pendingCount} loading={loading}
prefix={<ClockCircleOutlined style={{ color: '#6B8CA8', fontSize: 20 }} />} />
</div>
</Col>
<Col xs={24} sm={8}>
<div style={{
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #C2693A',
}}>
<Statistic title="节目总数" value={streams.length} loading={loading}
prefix={<PlayCircleOutlined style={{ color: '#C2693A', fontSize: 20 }} />} />
</div>
</Col>
</Row>
<div className="admin-glass-panel admin-table-panel" style={{
padding: '28px 32px',
}}>
<Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近节目</Title>
<Table dataSource={streams.slice(0, 10)} columns={columns}
rowKey="id" loading={loading} pagination={false} size="middle"
locale={{ emptyText: '暂无节目数据' }} />
</div>
<Card>
<CardHeader>
<CardTitle>最近节目</CardTitle>
<CardDescription>最新创建的 10 个节目</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>标题</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="w-44">创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : streams.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center text-muted-foreground">
暂无节目数据
</TableCell>
</TableRow>
) : (
streams.slice(0, 10).map((s) => (
<TableRow key={s.id}>
<TableCell className="max-w-0 truncate font-medium">{s.title}</TableCell>
<TableCell>
<StatusBadge status={s.status} />
</TableCell>
<TableCell className="text-muted-foreground">{formatDateTime(s.created_at)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
+214 -100
View File
@@ -1,8 +1,26 @@
import { useEffect, useState } from 'react'
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
import { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
import api from '../../api/client'
import { getSystemMetrics, type SystemMetrics } from '../../api/system'
import { RefreshCw, Maximize2, Minimize2, Users, Signal, TriangleAlert, Cpu, MemoryStick, HardDrive, Download, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import api from '@/api/client'
import { getSystemMetrics, type SystemMetrics } from '@/api/system'
import {
createRemoteSession,
fetchStreams,
@@ -10,9 +28,10 @@ import {
sendRemoteCommand,
type StreamInfo,
type ViewerSessionInfo,
} from '../../api/streams'
const { Title, Text } = Typography
} from '@/api/streams'
import { formatBytes } from '@/lib/status'
import { PageHeader } from '@/components/page-header'
import { StatCard } from '@/components/stat-card'
interface NodeMetric {
id: string
@@ -27,19 +46,13 @@ interface NodeMetric {
} | null
}
const formatBytes = (value = 0) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let amount = Math.max(0, value)
let index = 0
while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1 }
return `${amount.toFixed(index ? 1 : 0)} ${units[index]}`
}
const QUALITIES = ['1080p', '720p', '480p', '360p']
export default function LiveBoard() {
const [streams, setStreams] = useState<StreamInfo[]>([])
const [streamId, setStreamId] = useState<number>()
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
const [selected, setSelected] = useState<React.Key[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
const [nodes, setNodes] = useState<NodeMetric[]>([])
@@ -51,7 +64,7 @@ export default function LiveBoard() {
try {
const programs = await fetchStreams(true)
setStreams(programs)
const nextId = streamId || programs.find(program => program.status === 'live')?.id
const nextId = streamId || programs.find((p) => p.status === 'live')?.id
if (nextId) {
setStreamId(nextId)
setSessions(await fetchViewerSessions(nextId))
@@ -59,7 +72,7 @@ export default function LiveBoard() {
setSessions([])
}
} catch {
message.error('看板数据加载失败')
toast.error('看板数据加载失败')
} finally {
try {
setSystemMetrics(await getSystemMetrics())
@@ -83,102 +96,203 @@ export default function LiveBoard() {
}, [streamId])
async function controlViewers() {
if (!streamId || selected.length === 0) return
if (!streamId || selected.size === 0) return
try {
const session = await createRemoteSession(streamId)
const targets = selected.map(String)
const targets = Array.from(selected)
if (nodeId !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_node', targets, nodeId)
if (quality !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_quality', targets, quality)
if (nodeId === 'auto' && quality === 'auto') await sendRemoteCommand(streamId, session.token, 'restore_auto', targets)
message.success(`已向 ${selected.length} 个观众下发节点与清晰度策略`)
toast.success(`已向 ${selected.size} 个观众下发节点与清晰度策略`)
} catch {
message.error('控制命令下发失败,请确认节目已启用审计式播放控制')
toast.error('控制命令下发失败,请确认节目已启用审计式播放控制')
}
}
const activeSessions = sessions.filter(item => Date.now() - new Date(item.last_heartbeat).getTime() < 30000)
const buffering = activeSessions.filter(item => item.playback_state === 'buffering' || Number(item.buffered_seconds || 0) < 1)
const totalCardsLoading = !systemMetrics
const activeSessions = sessions.filter((s) => Date.now() - new Date(s.last_heartbeat).getTime() < 30000)
const buffering = activeSessions.filter(
(s) => s.playback_state === 'buffering' || Number(s.buffered_seconds || 0) < 1,
)
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div>
<Title level={2} style={{ margin: 0 }}>直播看板</Title>
<Text type="secondary">实时检查节目、观众会话、线路与播放健康度</Text>
<div className={wallMode ? 'fixed inset-0 z-50 overflow-auto bg-background p-6' : 'space-y-6'}>
<PageHeader title="直播看板" kicker="Live Board" description="实时检查节目、观众会话、线路与播放健康度">
<Select value={streamId ? String(streamId) : undefined} onValueChange={(v) => { setStreamId(Number(v)); setSelected(new Set()) }}>
<SelectTrigger className="w-56">
<SelectValue placeholder="选择直播节目" />
</SelectTrigger>
<SelectContent>
{streams.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.title} · {p.status}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={() => setWallMode((v) => !v)}>
{wallMode ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
{wallMode ? '退出大屏' : '大屏模式'}
</Button>
<Button variant="outline" onClick={load}>
<RefreshCw className="h-4 w-4" />
刷新
</Button>
</PageHeader>
<div className="grid gap-4 sm:grid-cols-3">
<StatCard label="活跃观众" value={activeSessions.length} icon={Users} />
<StatCard label="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} icon={Signal} tone="success" />
<StatCard label="疑似卡顿" value={buffering.length} icon={TriangleAlert} tone={buffering.length ? 'warning' : 'default'} />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard label="CPU 使用率" value={`${systemMetrics?.cpu_percent.toFixed(1) ?? '—'}%`} icon={Cpu} />
<StatCard label="内存使用率" value={`${systemMetrics?.memory_percent.toFixed(1) ?? '—'}%`} icon={MemoryStick} />
<StatCard label="存储使用率" value={`${systemMetrics?.disk_percent.toFixed(1) ?? '—'}%`} icon={HardDrive} />
<StatCard label="下载总量" value={formatBytes(systemMetrics?.network_received_bytes)} icon={Download} />
<StatCard label="上传总量" value={formatBytes(systemMetrics?.network_sent_bytes)} icon={Upload} />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">节点实时指标</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{nodes.map((node) => (
<div key={node.id} className="rounded-md border border-border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="font-medium">{node.name}</span>
<Badge variant={node.online ? 'default' : 'secondary'}>{node.online ? '在线' : '离线'}</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div>CPU:{node.metrics?.cpu_percent?.toFixed(1) ?? '-'}%</div>
<div>内存:{node.metrics?.memory_percent?.toFixed(1) ?? '-'}%</div>
<div>NET IN:{node.metrics?.network_in_mbps?.toFixed(2) ?? '-'} Mbps</div>
<div>NET OUT:{node.metrics?.network_out_mbps?.toFixed(2) ?? '-'} Mbps</div>
<div>观众:{node.metrics?.viewer_count ?? 0}</div>
</div>
</div>
))}
</div>
<Space wrap>
<Select
value={streamId}
placeholder="选择直播节目"
style={{ minWidth: 220 }}
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
onChange={value => { setStreamId(value); setSelected([]) }}
/>
<Button icon={wallMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />} onClick={() => setWallMode(value => !value)}>{wallMode ? '退出大屏' : '大屏模式'}</Button>
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
</Space>
</div>
</CardContent>
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}><Card><Statistic title="活跃观众" value={activeSessions.length} prefix={<TeamOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} prefix={<SignalFilled />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" loading={totalCardsLoading} prefix={<DashboardOutlined />} /></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" loading={totalCardsLoading} prefix={<DatabaseOutlined />} /><Text type="secondary">{formatBytes(systemMetrics?.memory_used_bytes)} / {formatBytes(systemMetrics?.memory_total_bytes)}</Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="存储使用率" value={systemMetrics?.disk_percent ?? 0} precision={1} suffix="%" loading={totalCardsLoading} prefix={<DatabaseOutlined />} /><Text type="secondary">{formatBytes(systemMetrics?.disk_used_bytes)} / {formatBytes(systemMetrics?.disk_total_bytes)}</Text></Card></Col>
<Col xs={24} md={12}><Card><Statistic title="NET I/O 下载总量" value={formatBytes(systemMetrics?.network_received_bytes)} loading={totalCardsLoading} /></Card></Col>
<Col xs={24} md={12}><Card><Statistic title="NET I/O 上传总量" value={formatBytes(systemMetrics?.network_sent_bytes)} loading={totalCardsLoading} /></Card></Col>
</Row>
<Card title="节点实时指标">
<Row gutter={[12, 12]}>
{nodes.map(node => <Col xs={24} md={12} xl={8} key={node.id}><Card size="small" title={<Space>{node.name}<Tag color={node.online ? 'green' : 'default'}>{node.online ? '在线' : '离线'}</Tag></Space>}><Space direction="vertical"><Text>CPU:{node.metrics?.cpu_percent?.toFixed(1) ?? '-'}%</Text><Text>内存:{node.metrics?.memory_percent?.toFixed(1) ?? '-'}%</Text><Text>NET IN:{node.metrics?.network_in_mbps?.toFixed(2) ?? '-'} Mbps</Text><Text>NET OUT:{node.metrics?.network_out_mbps?.toFixed(2) ?? '-'} Mbps</Text><Text>观众:{node.metrics?.viewer_count ?? 0}</Text></Space></Card></Col>)}
</Row>
{streamId && (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">审计式观众会话</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Select value={nodeId} onValueChange={setNodeId}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">节点:保持自动</SelectItem>
{nodes.filter((n) => n.online).map((n) => (
<SelectItem key={n.id} value={n.id}>
节点:{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={quality} onValueChange={setQuality}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">恢复自动</SelectItem>
{QUALITIES.map((q) => (
<SelectItem key={q} value={q}>
{q}
</SelectItem>
))}
</SelectContent>
</Select>
<Button disabled={selected.size === 0} onClick={controlViewers}>
批量切换
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={sessions.length > 0 && selected.size === sessions.length}
onChange={(e) => setSelected(e.target.checked ? new Set(sessions.map((s) => s.session_id)) : new Set())}
className="h-4 w-4 accent-primary"
/>
</TableHead>
<TableHead>会话</TableHead>
<TableHead>节点</TableHead>
<TableHead>清晰度</TableHead>
<TableHead>状态</TableHead>
<TableHead>缓冲</TableHead>
<TableHead>远程调整</TableHead>
<TableHead>最后心跳</TableHead>
<TableHead>错误</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && sessions.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : sessions.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center text-muted-foreground">
暂无观众会话
</TableCell>
</TableRow>
) : (
sessions.map((s) => (
<TableRow key={s.session_id}>
<TableCell>
<input
type="checkbox"
checked={selected.has(s.session_id)}
onChange={() => toggleSelect(s.session_id)}
className="h-4 w-4 accent-primary"
/>
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{s.session_id.slice(0, 12)}…</code>
</TableCell>
<TableCell>{s.node_name || '未知'}</TableCell>
<TableCell>{s.quality || '自动'}</TableCell>
<TableCell>
<Badge variant={s.playback_state === 'playing' ? 'default' : s.playback_state === 'buffering' ? 'secondary' : 'outline'}>
{s.playback_state}
</Badge>
</TableCell>
<TableCell>{(Number(s.buffered_seconds || 0)).toFixed(1)}s</TableCell>
<TableCell>{s.remote_adjusted ? <Badge className="bg-amber-600">已调整</Badge> : '否'}</TableCell>
<TableCell className="text-muted-foreground">{new Date(s.last_heartbeat).toLocaleTimeString()}</TableCell>
<TableCell className="max-w-0 truncate text-muted-foreground">{s.error || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
<Card
title="审计式观众会话"
extra={
<Space wrap>
<Select value={nodeId} onChange={setNodeId} style={{ width: 180 }} options={[{ value: 'auto', label: '节点:保持自动' }, ...nodes.filter(node => node.online).map(node => ({ value: node.id, label: `节点:${node.name}` }))]} />
<Select value={quality} onChange={setQuality} style={{ width: 160 }} options={[
{ value: 'auto', label: '恢复自动' },
{ value: '1080p', label: '1080p' },
{ value: '720p', label: '720p' },
{ value: '480p', label: '480p' },
{ value: '360p', label: '360p' },
]} />
<Button type="primary" disabled={!selected.length} onClick={controlViewers}>批量切换</Button>
</Space>
}
>
<Table
rowKey="session_id"
loading={loading}
dataSource={sessions}
scroll={{ x: 850 }}
rowSelection={{ selectedRowKeys: selected, onChange: setSelected }}
pagination={{ pageSize: 20 }}
columns={[
{ title: '会话', dataIndex: 'session_id', ellipsis: true, width: 180 },
{ title: '节点', dataIndex: 'node_name', render: value => value || '未知' },
{ title: '清晰度', dataIndex: 'quality', render: value => value || '自动' },
{ title: '状态', dataIndex: 'playback_state', render: value => <Tag color={value === 'playing' ? 'green' : value === 'buffering' ? 'orange' : 'default'}>{value}</Tag> },
{ title: '缓冲', dataIndex: 'buffered_seconds', render: value => `${Number(value || 0).toFixed(1)}s` },
{ title: '远程调整', dataIndex: 'remote_adjusted', render: value => value ? <Tag color="gold">已调整</Tag> : '否' },
{ title: '最后心跳', dataIndex: 'last_heartbeat', render: value => new Date(value).toLocaleTimeString() },
{ title: '错误', dataIndex: 'error', ellipsis: true, render: value => value || '-' },
]}
/>
</Card>
)}
</Space>
)}
</div>
)
}
}
+233 -98
View File
@@ -1,129 +1,264 @@
import { useState, useEffect } from 'react'
import { Table, Input, InputNumber, Switch, Typography, message, Tag, Statistic, Row, Col, Button, Progress } from 'antd'
import { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons'
import api from '../../api/client'
import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Progress } from '@/components/ui/progress'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import api from '@/api/client'
import { PageHeader } from '@/components/page-header'
import { StatCard } from '@/components/stat-card'
const { Title, Text } = Typography
interface NodeMetrics {
cpu_percent: number | null
memory_percent: number | null
bandwidth_mbps: number | null
active_connections: number | null
hls_readers: number | null
network_in_mbps: number | null
network_out_mbps: number | null
viewer_count: number | null
frame_drop_indicators: number | null
packet_loss_indicators: number | null
media_metrics_available: boolean
}
interface NodeInfo {
id: string
name: string
host: string
hls_port: number
api_port: number
weight: number
enabled: boolean
subnet: string
is_origin: boolean
online: boolean
metrics: NodeMetrics | null
}
interface StrategyInfo {
strategy: string
multicast_enabled: boolean
multicast_available: boolean
}
interface NodeMetrics { cpu_percent: number | null; memory_percent: number | null; bandwidth_mbps: number | null; active_connections: number | null; hls_readers: number | null; network_in_mbps: number | null; network_out_mbps: number | null; viewer_count: number | null; frame_drop_indicators: number | null; packet_loss_indicators: number | null; media_metrics_available: boolean }
interface NodeInfo { id: string; name: string; host: string; hls_port: number; api_port: number; weight: number; enabled: boolean; subnet: string; is_origin: boolean; online: boolean; metrics: NodeMetrics | null }
interface StrategyInfo { strategy: string; multicast_enabled: boolean; multicast_available: boolean }
const sL: Record<string, string> = { 'subnet': '子网优先', 'round-robin': '轮询', 'weighted': '权重', 'least-conn': '最少连接' }
const STRATEGY_LABEL: Record<string, string> = {
subnet: '子网优先',
'round-robin': '轮询',
weighted: '权重',
'least-conn': '最少连接',
}
export default function NodeManager() {
const [nodes, setNodes] = useState<NodeInfo[]>([])
const [strategy, setStrategy] = useState<StrategyInfo | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
useEffect(() => {
void loadData()
const t = setInterval(loadData, 10000)
return () => clearInterval(t)
}, [])
async function loadData() {
setLoading(true)
try { const [nr, sr] = await Promise.all([api.get('/nodes'), api.get('/nodes/strategy')]); setNodes(nr.data); setStrategy(sr.data) }
catch { message.error('加载失败') }
try {
const [nr, sr] = await Promise.all([api.get('/nodes'), api.get('/nodes/strategy')])
setNodes(nr.data)
setStrategy(sr.data)
} catch {
toast.error('加载失败')
}
setLoading(false)
}
async function updateWeight(nid: string, w: number) {
try { await api.put(`/nodes/${nid}`, { weight: w }); message.success('已更新') } catch { message.error('更新失败') }
try {
await api.put(`/nodes/${nid}`, { weight: w })
toast.success('已更新')
} catch {
toast.error('更新失败')
}
}
async function toggleNode(nid: string, en: boolean) {
const node = nodes.find(n => n.id === nid); if (!node) return
try { await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en }); setNodes(p => p.map(n => n.id === nid ? { ...n, enabled: en } : n)) } catch { message.error('更新失败') }
const node = nodes.find((n) => n.id === nid)
if (!node) return
try {
await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en })
setNodes((p) => p.map((n) => (n.id === nid ? { ...n, enabled: en } : n)))
} catch {
toast.error('更新失败')
}
}
async function updateName(nid: string, name: string) {
const trimmed = name.trim()
if (!trimmed) { message.error('节点名称不能为空'); loadData(); return }
try { await api.put(`/nodes/${nid}`, { name: trimmed }); setNodes(p => p.map(n => n.id === nid ? { ...n, name: trimmed } : n)); message.success('名称已更新') }
catch { message.error('名称更新失败'); loadData() }
if (!trimmed) {
toast.error('节点名称不能为空')
void loadData()
return
}
try {
await api.put(`/nodes/${nid}`, { name: trimmed })
setNodes((p) => p.map((n) => (n.id === nid ? { ...n, name: trimmed } : n)))
toast.success('名称已更新')
} catch {
toast.error('名称更新失败')
void loadData()
}
}
const onlineCount = nodes.filter(n => n.online).length
const columns = [
{
title: '', dataIndex: 'online', key: 'online', width: 40,
render: (v: boolean) => (
<div style={{ width: 10, height: 10, borderRadius: '50%', background: v ? '#5B8C5A' : '#B54A4A',
boxShadow: `0 0 6px ${v ? 'rgba(91,140,90,0.3)' : 'rgba(181,74,74,0.3)'}` }} />
),
},
{ title: '名称', dataIndex: 'name', key: 'name', width: 190,
render: (v: string, r: NodeInfo) => <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Input defaultValue={v} size="small" onPressEnter={e => updateName(r.id, e.currentTarget.value)} onBlur={e => { if (e.currentTarget.value !== v) updateName(r.id, e.currentTarget.value) }} />{r.is_origin && <Tag color="warning" style={{ margin: 0 }}>主节点</Tag>}</span> },
{ title: '地址', dataIndex: 'host', key: 'host', width: 130 },
{ title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => <Text code style={{ fontSize: 12 }}>{v}</Text> },
{ title: 'CPU', key: 'cpu', width: 80,
render: (_: any, r: NodeInfo) => r.metrics?.cpu_percent != null
? <Progress percent={Math.round(r.metrics.cpu_percent)} size="small" showInfo={false}
strokeColor={r.metrics.cpu_percent > 80 ? '#B54A4A' : r.metrics.cpu_percent > 60 ? '#C28B3A' : '#C2693A'} />
: <Text type="secondary">--</Text> },
{ title: '内存', key: 'mem', width: 80,
render: (_: any, r: NodeInfo) => r.metrics?.memory_percent != null
? <Progress percent={Math.round(r.metrics.memory_percent)} size="small" showInfo={false}
strokeColor={r.metrics.memory_percent > 80 ? '#B54A4A' : r.metrics.memory_percent > 60 ? '#C28B3A' : '#C2693A'} />
: <Text type="secondary">--</Text> },
{ title: '带宽', key: 'bw', width: 85,
render: (_: any, r: NodeInfo) => r.metrics?.bandwidth_mbps != null ? <Text style={{ fontSize: 12 }}>{r.metrics.bandwidth_mbps.toFixed(1)} Mbps</Text> : <Text type="secondary">--</Text> },
{ title: '网络入/出', key: 'network', width: 125,
render: (_: any, r: NodeInfo) => r.metrics?.network_in_mbps != null && r.metrics?.network_out_mbps != null ? <Text style={{ fontSize: 12 }}>{r.metrics.network_in_mbps.toFixed(1)} / {r.metrics.network_out_mbps.toFixed(1)} Mbps</Text> : <Text type="secondary">不可用</Text> },
{ title: '观众', key: 'viewers', width: 65,
render: (_: any, r: NodeInfo) => r.metrics?.viewer_count != null ? r.metrics.viewer_count : <Text type="secondary">不可用</Text> },
{ title: '媒体异常', key: 'media', width: 120,
render: (_: any, r: NodeInfo) => r.metrics?.media_metrics_available ? <Text style={{ fontSize: 12 }}>丢帧 {r.metrics.frame_drop_indicators ?? 0} / 丢包 {r.metrics.packet_loss_indicators ?? 0}</Text> : <Text type="secondary">不可用</Text> },
{ title: '连接', key: 'conn', width: 60,
render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null ? r.metrics.active_connections : <Text type="secondary">--</Text> },
{ title: '权重', dataIndex: 'weight', key: 'weight', width: 120,
render: (_: number, r: NodeInfo) => <InputNumber min={0} max={1000} value={r.weight} size="small" style={{ width: 80 }} disabled={r.is_origin}
onChange={v => { const val = v || 0; setNodes(p => p.map(n => n.id === r.id ? { ...n, weight: val } : n)); updateWeight(r.id, val) }} /> },
{ title: '启用', dataIndex: 'enabled', key: 'enabled', width: 60,
render: (_: any, r: NodeInfo) => <Switch checked={r.enabled} size="small" disabled={r.is_origin} onChange={v => toggleNode(r.id, v)} /> },
]
const onlineCount = nodes.filter((n) => n.online).length
return (
<div>
<div style={{ marginBottom: 32 }}>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28 }}>边缘节点</Title>
<Text style={{ color: '#949088' }}>管理 CDN 边缘节点集群,监控实时状态</Text>
</div>
<div className="space-y-6">
<PageHeader title="边缘节点" kicker="Edge Nodes" description="管理 CDN 边缘节点集群,监控实时状态" />
{strategy && (
<Row gutter={[24, 24]} style={{ marginBottom: 32 }}>
<Col xs={24} sm={6}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="均衡策略" value={sL[strategy.strategy] || strategy.strategy} valueStyle={{ fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="组播状态" value={strategy.multicast_available ? '可用' : '不可用'}
valueStyle={{ color: strategy.multicast_available ? '#5B8C5A' : '#949088', fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="在线节点" value={onlineCount} suffix={<Text style={{ fontSize: 14, color: '#949088' }}>/ {nodes.length}</Text>}
valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="离线节点" value={nodes.length - onlineCount}
valueStyle={{ color: (nodes.length - onlineCount) > 0 ? '#B54A4A' : '#949088', fontSize: 20 }}
prefix={(nodes.length - onlineCount) > 0 ? <WarningOutlined style={{ fontSize: 18 }} /> : undefined} />
</div>
</Col>
</Row>
<div className="grid gap-4 sm:grid-cols-4">
<StatCard label="均衡策略" value={STRATEGY_LABEL[strategy.strategy] || strategy.strategy} />
<StatCard
label="组播状态"
value={strategy.multicast_available ? '可用' : '不可用'}
tone={strategy.multicast_available ? 'success' : 'default'}
/>
<StatCard
label="在线节点"
value={onlineCount}
description={`共 ${nodes.length} 个节点`}
tone={onlineCount > 0 ? 'success' : 'default'}
/>
<StatCard
label="离线节点"
value={nodes.length - onlineCount}
tone={nodes.length - onlineCount > 0 ? 'destructive' : 'default'}
/>
</div>
)}
<div className="admin-glass-panel admin-table-panel">
<div style={{ padding: '16px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 16 }}>节点列表</Text>
<Button icon={<ReloadOutlined />} onClick={loadData} type="text">刷新</Button>
</div>
<Table dataSource={nodes} columns={columns} rowKey="id" loading={loading}
pagination={false} size="small" scroll={{ x: 1150 }}
locale={{ emptyText: '暂无边缘节点' }} />
</div>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">节点列表</CardTitle>
<Button variant="ghost" size="icon" onClick={loadData}>
<RefreshCw className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>名称</TableHead>
<TableHead>地址</TableHead>
<TableHead>子网</TableHead>
<TableHead className="w-24">CPU</TableHead>
<TableHead className="w-24">内存</TableHead>
<TableHead className="w-28">网络入/出</TableHead>
<TableHead className="w-16">观众</TableHead>
<TableHead className="w-24">权重</TableHead>
<TableHead className="w-16">启用</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && nodes.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : nodes.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center text-muted-foreground">
暂无边缘节点
</TableCell>
</TableRow>
) : (
nodes.map((n) => (
<TableRow key={n.id}>
<TableCell>
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ background: n.online ? '#10b981' : '#ef4444' }}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Input
defaultValue={n.name}
className="h-8 w-40"
onBlur={(e) => {
if (e.currentTarget.value !== n.name) updateName(n.id, e.currentTarget.value)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
}}
/>
{n.is_origin && <Badge variant="secondary">主节点</Badge>}
</div>
</TableCell>
<TableCell className="text-muted-foreground">{n.host}</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{n.subnet}</code>
</TableCell>
<TableCell>
{n.metrics?.cpu_percent != null ? (
<Progress value={Math.round(n.metrics.cpu_percent)} className="h-1.5 w-16" />
) : (
<span className="text-muted-foreground">--</span>
)}
</TableCell>
<TableCell>
{n.metrics?.memory_percent != null ? (
<span className="text-xs text-muted-foreground">{n.metrics.memory_percent.toFixed(0)}%</span>
) : (
<span className="text-muted-foreground">--</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{n.metrics?.network_in_mbps != null && n.metrics?.network_out_mbps != null
? `${n.metrics.network_in_mbps.toFixed(1)} / ${n.metrics.network_out_mbps.toFixed(1)} Mbps`
: '不可用'}
</TableCell>
<TableCell>
{n.metrics?.viewer_count != null ? n.metrics.viewer_count : <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell>
<Input
type="number"
min={0}
max={1000}
value={n.weight}
disabled={n.is_origin}
className="h-8 w-20"
onChange={(e) => {
const val = Number(e.target.value || 0)
setNodes((p) => p.map((x) => (x.id === n.id ? { ...x, weight: val } : x)))
updateWeight(n.id, val)
}}
/>
</TableCell>
<TableCell>
<Switch checked={n.enabled} disabled={n.is_origin} onCheckedChange={(v) => toggleNode(n.id, v)} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)
}
+188 -109
View File
@@ -1,24 +1,41 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Button, Input, InputNumber, Segmented, Select, Space, Spin, Typography, message } from 'antd'
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams'
const { Title, Text } = Typography
import { ArrowLeft, ArrowDown, ArrowUp, Trash2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
fetchMediaFiles,
getStream,
updateStream,
type Chapter,
type StreamInfo,
} from '@/api/streams'
import VideoPlayer, { VideoPlayerHandle } from '@/viewer/components/VideoPlayer'
import { PageHeader } from '@/components/page-header'
export default function ReplayEditor() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const videoRef = useRef<HTMLVideoElement>(null)
const playerRef = useRef<VideoPlayerHandle>(null)
const [stream, setStream] = useState<StreamInfo | null>(null)
const [mediaFiles, setMediaFiles] = useState<string[]>([])
const [replaySource, setReplaySource] = useState<'local' | 'remote'>('local')
const [replayFile, setReplayFile] = useState<string>()
const [replayUrl, setReplayUrl] = useState('')
const [previewUrl, setPreviewUrl] = useState<string>()
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState('')
const previewObjectUrlRef = useRef<string>()
const [chapters, setChapters] = useState<Chapter[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -35,66 +52,27 @@ export default function ReplayEditor() {
setChapters(program.chapters || [])
setMediaFiles(files)
})
.catch(() => message.error('无法载入节目回放配置'))
.catch(() => toast.error('无法载入节目回放配置'))
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current)
previewObjectUrlRef.current = undefined
}
setPreviewUrl(undefined)
setPreviewError('')
if (replaySource === 'local') {
setPreviewLoading(false)
if (stream && replayFile) {
setPreviewUrl(`/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}`)
}
if (!stream) {
setPreviewUrl(undefined)
return
}
const remoteUrl = replayUrl.trim()
if (!remoteUrl) {
setPreviewLoading(false)
return
}
const controller = new AbortController()
let disposed = false
setPreviewLoading(true)
void fetch(remoteUrl, { signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.blob()
})
.then(blob => {
if (disposed) return
const objectUrl = URL.createObjectURL(blob)
previewObjectUrlRef.current = objectUrl
setPreviewUrl(objectUrl)
})
.catch(error => {
if (error instanceof DOMException && error.name === 'AbortError') return
if (!disposed) setPreviewError('远端回放拉取失败,请检查地址及其 CORS 配置')
})
.finally(() => {
if (!disposed) setPreviewLoading(false)
})
return () => {
disposed = true
controller.abort()
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current)
previewObjectUrlRef.current = undefined
}
if (replaySource === 'local' && replayFile) {
setPreviewUrl(`/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}`)
} else if (replaySource === 'remote' && replayUrl.trim()) {
setPreviewUrl(replayUrl.trim())
} else {
setPreviewUrl(undefined)
}
}, [replaySource, replayFile, replayUrl, stream])
const move = (index: number, offset: number) => {
setChapters(current => {
setChapters((current) => {
const target = index + offset
if (target < 0 || target >= current.length) return current
const next = [...current]
@@ -104,74 +82,175 @@ export default function ReplayEditor() {
}
const addChapter = () => {
const time = Math.max(0, Math.round((videoRef.current?.currentTime || 0) * 10) / 10)
setChapters(current => [...current, { title: `章节 ${current.length + 1}`, time }])
const video = playerRef.current?.getVideo()
const time = Math.max(0, Math.round((video?.currentTime || 0) * 10) / 10)
setChapters((current) => [...current, { title: `章节 ${current.length + 1}`, time }])
}
const save = async () => {
if (!stream || !replayFile) return message.warning('请先选择回放文件')
if (!stream) return
const remoteUrl = replayUrl.trim()
if (replaySource === 'local' && !replayFile) return toast.warning('请先选择回放文件')
if (replaySource === 'remote' && !remoteUrl) return toast.warning('请输入外部回放 URL')
if (replaySource === 'remote' && !/^https?:\/\//i.test(remoteUrl)) {
return toast.warning('外部回放 URL 必须以 http:// 或 https:// 开头')
}
setSaving(true)
try {
await updateStream(stream.id, { replay_file: replayFile, chapters })
message.success('回放和章节已保存')
await updateStream(stream.id, {
replay_file: replaySource === 'local' ? replayFile : null,
replay_url: replaySource === 'remote' ? remoteUrl : null,
chapters,
})
toast.success('回放和章节已保存')
} catch {
message.error('保存失败,请检查章节标题、时间和媒体文件')
toast.error('保存失败,请检查回放来源、URL 和章节内容')
} finally {
setSaving(false)
}
}
if (loading) return <div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>
if (!stream) return <Alert type="error" message="节目不存在" />
if (loading) {
return (
<div className="flex justify-center py-24">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)
}
if (!stream) return <Alert variant="destructive"><AlertTitle>节目不存在</AlertTitle></Alert>
const isEditable = stream.status === 'ended'
return (
<div className="replay-editor" style={{ maxWidth: 980, margin: '0 auto', padding: '8px 0 48px', minWidth: 0 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')} style={{ marginBottom: 20 }}>返回节目列表</Button>
<Title level={2} style={{ marginBottom: 4 }}>回放与章节</Title>
<Text type="secondary">{stream.title}</Text>
{stream.status !== 'ended' ? (
<Alert style={{ marginTop: 24 }} type="warning" showIcon message="节目结束后才能配置回放和章节" />
<div className="mx-auto max-w-4xl">
<Button variant="ghost" className="mb-5" onClick={() => navigate('/admin/streams')}>
<ArrowLeft className="h-4 w-4" />
返回节目列表
</Button>
<PageHeader title="回放与章节" kicker="Replay" description={stream.title} />
{!isEditable ? (
<Alert className="mt-6" variant="default">
<AlertTitle>不可编辑</AlertTitle>
<AlertDescription>节目结束后才能配置回放和章节</AlertDescription>
</Alert>
) : (
<Space direction="vertical" size={24} style={{ width: '100%', marginTop: 24 }}>
<Select
showSearch
value={replayFile}
placeholder="选择媒体目录中的回放文件"
options={mediaFiles.map(file => ({ value: file, label: file }))}
onChange={setReplayFile}
style={{ width: '100%' }}
/>
<video
ref={videoRef}
controls
src={replayFile ? `/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}` : undefined}
style={{ width: '100%', maxHeight: 520, borderRadius: 12, background: '#111' }}
/>
<Button icon={<PlusOutlined />} onClick={addChapter} disabled={!replayFile}>按当前播放时间添加章节</Button>
<Space direction="vertical" style={{ width: '100%' }}>
{chapters.map((chapter, index) => (
<Space key={index} wrap className="replay-chapter-row" style={{ width: '100%', minWidth: 0 }}>
<div className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">回放来源</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Tabs value={replaySource} onValueChange={(v) => setReplaySource(v as 'local' | 'remote')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="local">媒体目录文件</TabsTrigger>
<TabsTrigger value="remote">外部回放 URL</TabsTrigger>
</TabsList>
</Tabs>
{replaySource === 'local' ? (
<Select value={replayFile} onValueChange={setReplayFile}>
<SelectTrigger>
<SelectValue placeholder="选择媒体目录中的回放文件" />
</SelectTrigger>
<SelectContent>
{mediaFiles.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">媒体目录中暂无可用视频文件</div>
)}
{mediaFiles.map((file) => (
<SelectItem key={file} value={file}>
{file}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={chapter.title}
placeholder="章节标题"
onChange={event => setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))}
style={{ width: 320, maxWidth: '100%', flex: '1 1 220px' }}
value={replayUrl}
placeholder="https://example.com/path/replay.mp4"
onChange={(e) => setReplayUrl(e.target.value)}
/>
<InputNumber
min={0}
step={0.1}
value={chapter.time}
addonAfter="秒"
onChange={value => setChapters(current => current.map((item, i) => i === index ? { ...item, time: Number(value || 0) } : item))}
/>
<Button icon={<ArrowUpOutlined />} disabled={index === 0} onClick={() => move(index, -1)} />
<Button icon={<ArrowDownOutlined />} disabled={index === chapters.length - 1} onClick={() => move(index, 1)} />
<Button danger icon={<DeleteOutlined />} onClick={() => setChapters(current => current.filter((_, i) => i !== index))} />
</Space>
))}
</Space>
<Button type="primary" loading={saving} onClick={save}>保存回放与章节</Button>
</Space>
)}
{replaySource === 'remote' && (
<Alert variant="default">
<AlertTitle>跨域提示</AlertTitle>
<AlertDescription>外部地址需允许浏览器跨域访问(CORS),播放器将直接流式加载。</AlertDescription>
</Alert>
)}
{previewError && (
<Alert variant="destructive">
<AlertTitle>播放出错</AlertTitle>
<AlertDescription>{previewError}</AlertDescription>
</Alert>
)}
<VideoPlayer
ref={playerRef}
live={false}
replayUrl={previewUrl}
lines={[]}
qualities={[]}
activeLine=""
activeQuality=""
onLineChange={() => {}}
onQualityChange={() => {}}
onError={setPreviewError}
/>
<Button variant="outline" onClick={addChapter} disabled={!previewUrl}>
<Plus className="h-4 w-4" />
按当前播放时间添加章节
</Button>
</CardContent>
</Card>
{chapters.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">章节列表</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{chapters.map((chapter, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={chapter.title}
placeholder="章节标题"
onChange={(e) =>
setChapters((cur) => cur.map((item, i) => (i === index ? { ...item, title: e.target.value } : item)))
}
className="flex-1"
/>
<Input
type="number"
min={0}
step={0.1}
value={chapter.time}
onChange={(e) =>
setChapters((cur) => cur.map((item, i) => (i === index ? { ...item, time: Number(e.target.value || 0) } : item)))
}
className="w-28"
/>
<Button variant="ghost" size="icon" disabled={index === 0} onClick={() => move(index, -1)}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" disabled={index === chapters.length - 1} onClick={() => move(index, 1)}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setChapters((cur) => cur.filter((_, i) => i !== index))}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</CardContent>
</Card>
)}
<Button onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存回放与章节'}
</Button>
</div>
)}
</div>
)
-11
View File
@@ -1,11 +0,0 @@
.settings-page { width: 100%; max-width: 960px; min-width: 0; }
.settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: linear-gradient(145deg, rgba(26, 33, 47, .78), rgba(12, 17, 27, .72)); border: 1px solid rgba(255, 255, 255, .095); border-radius: 18px; box-shadow: 0 18px 48px rgba(0, 0, 0, .26); backdrop-filter: blur(24px) saturate(135%); }
.profile-block { display: grid; gap: 12px; margin: 20px 0; min-width: 0; }
.quality-row { display: grid; grid-template-columns: minmax(130px, 1.4fr) repeat(2, minmax(115px, .8fr)) minmax(120px, 1fr) auto; gap: 10px; align-items: center; min-width: 0; }
.quality-row > * { min-width: 0; max-width: 100%; }
@media (max-width: 720px) {
.settings-card { padding: 16px; }
.quality-row { grid-template-columns: 1fr 1fr; }
.quality-row > :first-child, .quality-row > :nth-child(4), .quality-row > :last-child { grid-column: 1 / -1; width: 100%; }
.settings-page .ant-select, .settings-page .ant-input-number { width: 100% !important; }
}
+145 -57
View File
@@ -1,15 +1,27 @@
import { useEffect, useState } from 'react'
import { Button, Form, Input, InputNumber, Select, Switch, Typography, message } from 'antd'
import api from '../../api/client'
import './Settings.css'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Trash2, Plus } from 'lucide-react'
import api from '@/api/client'
import { useBrand } from '@/brand/BrandContext'
import { PageHeader } from '@/components/page-header'
type Quality = { label: string; width: number; height: number; bitrate: string }
type Profiles = Record<string, Quality[]>
const strategies = [
const STRATEGIES = [
{ value: 'subnet', label: '子网优先(推荐)' },
{ value: 'weighted', label: '权重均衡' },
{ value: 'round-robin', label: '轮询' },
@@ -35,7 +47,7 @@ export default function SettingsPage() {
setMulticastAvailable(nodes.data.multicast_available)
setProfiles(transcode.data.profiles || {})
})
.catch(() => message.error('系统设置加载失败'))
.catch(() => toast.error('系统设置加载失败'))
}, [])
useEffect(() => {
@@ -44,20 +56,25 @@ export default function SettingsPage() {
setBrandLogoUrl(brand.brand_logo_url)
}, [brand])
function replaceProfile(profile: string, qualities: Quality[]) {
setProfiles(current => Object.assign({}, current, Object.fromEntries([[profile, qualities]])))
}
function updateQuality(profile: string, index: number, patch: Partial<Quality>) {
replaceProfile(profile, profiles[profile].map((quality, i) => i === index ? { ...quality, ...patch } : quality))
setProfiles((current) => ({
...current,
[profile]: current[profile].map((quality, i) => (i === index ? { ...quality, ...patch } : quality)),
}))
}
function addQuality(profile: string) {
replaceProfile(profile, [...profiles[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }])
setProfiles((current) => ({
...current,
[profile]: [...current[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }],
}))
}
function removeQuality(profile: string, index: number) {
replaceProfile(profile, profiles[profile].filter((_, i) => i !== index))
setProfiles((current) => ({
...current,
[profile]: current[profile].filter((_, i) => i !== index),
}))
}
async function saveAll() {
@@ -69,59 +86,130 @@ export default function SettingsPage() {
api.put('/settings/transcode-profiles', { profiles }),
])
await refresh()
message.success('设置已保存')
toast.success('设置已保存')
} catch {
message.error('保存失败,请检查转码参数')
toast.error('保存失败,请检查转码参数')
} finally {
setSaving(false)
}
}
return (
<div className="settings-page">
<Title level={2}>系统设置</Title>
<Text type="secondary">配置分发策略、转码清晰度与平台品牌</Text>
<div className="space-y-6">
<PageHeader title="系统设置" kicker="Settings" description="配置分发策略、转码清晰度与平台品牌" />
<section className="settings-card">
<Title level={4}>负载均衡</Title>
<Form layout="vertical">
<Form.Item label="均衡策略"><Select value={strategy} onChange={setStrategy} options={strategies} /></Form.Item>
<Form.Item label="组播">
<Switch checked={multicastEnabled} onChange={setMulticastEnabled} />
<Text type="secondary"> {multicastAvailable ? '可用' : '不可用,将回退至单播'}</Text>
</Form.Item>
</Form>
</section>
<section className="settings-card">
<Title level={4}>自定义转码配置</Title>
{Object.entries(profiles).map(([profile, qualities]) => (
<div className="profile-block" key={profile}>
<Text strong>{profile}</Text>
{qualities.map((quality, index) => (
<div className="quality-row" key={profile + '-' + index}>
<Input value={quality.label} onChange={e => updateQuality(profile, index, { label: e.target.value })} />
<InputNumber value={quality.width} min={160} max={7680} addonAfter="宽" onChange={v => updateQuality(profile, index, { width: v || 160 })} />
<InputNumber value={quality.height} min={90} max={4320} addonAfter="高" onChange={v => updateQuality(profile, index, { height: v || 90 })} />
<Input value={quality.bitrate} placeholder="2000k" onChange={e => updateQuality(profile, index, { bitrate: e.target.value })} />
<Button danger disabled={qualities.length <= 1} onClick={() => removeQuality(profile, index)}>删除</Button>
</div>
))}
<Button onClick={() => addQuality(profile)}>添加清晰度</Button>
<Card>
<CardHeader>
<CardTitle className="text-base">负载均衡</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>均衡策略</Label>
<Select value={strategy} onValueChange={setStrategy}>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STRATEGIES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</section>
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<Label>组播</Label>
<p className="text-xs text-muted-foreground">{multicastAvailable ? '可用' : '不可用,将回退至单播'}</p>
</div>
<Switch checked={multicastEnabled} onCheckedChange={setMulticastEnabled} />
</div>
</CardContent>
</Card>
<section className="settings-card">
<Title level={4}>品牌设置</Title>
<Form layout="vertical">
<Form.Item label="平台名称"><Input value={brandName} onChange={e => setBrandName(e.target.value)} /></Form.Item>
<Form.Item label="标语"><Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} /></Form.Item>
<Form.Item label="Logo 图片地址"><Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} /></Form.Item>
</Form>
</section>
<Card>
<CardHeader>
<CardTitle className="text-base">自定义转码配置</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{Object.entries(profiles).map(([profile, qualities]) => (
<div key={profile} className="space-y-3">
<div className="flex items-center gap-2">
<span className="font-medium">{profile}</span>
<Separator className="flex-1" />
</div>
{qualities.map((quality, index) => (
<div key={`${profile}-${index}`} className="flex flex-wrap items-center gap-2">
<Input
value={quality.label}
onChange={(e) => updateQuality(profile, index, { label: e.target.value })}
className="w-32"
/>
<Input
type="number"
min={160}
max={7680}
value={quality.width}
onChange={(e) => updateQuality(profile, index, { width: Number(e.target.value || 160) })}
className="w-24"
/>
<Input
type="number"
min={90}
max={4320}
value={quality.height}
onChange={(e) => updateQuality(profile, index, { height: Number(e.target.value || 90) })}
className="w-24"
/>
<Input
value={quality.bitrate}
placeholder="2000k"
onChange={(e) => updateQuality(profile, index, { bitrate: e.target.value })}
className="w-28"
/>
<Button
variant="ghost"
size="icon"
disabled={qualities.length <= 1}
onClick={() => removeQuality(profile, index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => addQuality(profile)}>
<Plus className="h-4 w-4" />
添加清晰度
</Button>
</div>
))}
</CardContent>
</Card>
<Button type="primary" size="large" loading={saving} onClick={saveAll}>保存全部设置</Button>
<Card>
<CardHeader>
<CardTitle className="text-base">品牌设置</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>平台名称</Label>
<Input value={brandName} onChange={(e) => setBrandName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>标语</Label>
<Input value={brandTagline} onChange={(e) => setBrandTagline(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Logo 图片地址</Label>
<Input value={brandLogoUrl} onChange={(e) => setBrandLogoUrl(e.target.value)} />
</div>
</CardContent>
</Card>
<Button size="lg" onClick={saveAll} disabled={saving}>
{saving ? '保存中…' : '保存全部设置'}
</Button>
</div>
)
}
+226 -111
View File
@@ -1,151 +1,266 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Form, Input, InputNumber, Button, DatePicker, Typography, message, Space, Alert, Select, Switch, Card, Row, Col } from 'antd'
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { createStream } from '../../api/streams'
import { Copy, ArrowLeft, CheckCircle2, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import axios from 'axios'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { createStream, type RenditionConfig } from '@/api/streams'
const { Title, Text } = Typography
const { TextArea } = Input
const DEFAULT_RENDITIONS: RenditionConfig[] = [
{ label: '1080p', enabled: true, width: 1920, height: 1080, fps: 30, codec: 'libx264', bitrate: '5000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
{ label: '720p', enabled: true, width: 1280, height: 720, fps: 30, codec: 'libx264', bitrate: '2500k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
]
const PRESETS = ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow']
export default function StreamForm() {
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [coverUrl, setCoverUrl] = useState('')
const [scheduledAt, setScheduledAt] = useState('')
const [transcodeProfile, setTranscodeProfile] = useState('standard')
const [homepageVisible, setHomepageVisible] = useState(true)
const [remoteControl, setRemoteControl] = useState(false)
const [renditions, setRenditions] = useState<RenditionConfig[]>(DEFAULT_RENDITIONS)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ rtmp_url: string; stream_key: string } | null>(null)
const navigate = useNavigate()
const handleSubmit = async (values: any) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
toast.error('请输入节目标题')
return
}
setLoading(true)
try {
const res = await createStream({
title: values.title,
description: values.description,
cover_url: values.cover_url,
scheduled_at: values.scheduled_at?.toISOString(),
transcode_profile: values.transcode_profile,
homepage_visible: values.homepage_visible,
remote_control_enabled: values.remote_control_enabled,
renditions: values.renditions,
title: title.trim(),
description: description || undefined,
cover_url: coverUrl || undefined,
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
transcode_profile: transcodeProfile,
homepage_visible: homepageVisible,
remote_control_enabled: remoteControl,
renditions,
})
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
message.success('直播已创建')
toast.success('直播已创建')
} catch (error) {
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : undefined
message.error(typeof detail === 'string' ? detail : '创建失败,请稍后重试')
toast.error(typeof detail === 'string' ? detail : '创建失败,请稍后重试')
} finally {
setLoading(false)
}
}
const updateRendition = (index: number, patch: Partial<RenditionConfig>) => {
setRenditions((cur) => cur.map((r, i) => (i === index ? { ...r, ...patch } : r)))
}
if (result) {
return (
<div style={{ maxWidth: 680 }}>
<div style={{ marginBottom: 32 }}>
<CheckCircleOutlined style={{ fontSize: 32, color: '#5B8C5A', marginBottom: 16 }} />
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>直播已创建</Title>
<Text style={{ color: '#5E5A52' }}>将以下信息提供给推流端即可开始直播</Text>
<div className="mx-auto max-w-2xl">
<div className="mb-8">
<CheckCircle2 className="mb-4 h-8 w-8 text-emerald-500" />
<h2 className="text-2xl font-semibold">直播已创建</h2>
<p className="text-sm text-muted-foreground">将以下信息提供给推流端即可开始直播</p>
</div>
<div className="admin-glass-panel admin-form-panel" style={{ marginBottom: 24 }}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Card className="mb-6">
<CardContent className="space-y-6 pt-6">
<div>
<Text style={{ color: '#949088', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>推流码</Text>
<div style={{ marginTop: 8, padding: '16px 20px', background: '#F7F5F1', borderRadius: 8, fontFamily: 'monospace', fontSize: 14, wordBreak: 'break-all' }}>
{result.stream_key}
</div>
<Label className="text-xs uppercase tracking-wide text-muted-foreground">推流码</Label>
<div className="mt-2 break-all rounded-md bg-muted p-4 font-mono text-sm">{result.stream_key}</div>
</div>
<div>
<Text style={{ color: '#949088', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>RTMP 推流地址</Text>
<div style={{ marginTop: 8, padding: '16px 20px', background: '#F7F5F1', borderRadius: 8, fontFamily: 'monospace', fontSize: 14, wordBreak: 'break-all' }}>
{result.rtmp_url}
</div>
<Label className="text-xs uppercase tracking-wide text-muted-foreground">RTMP 推流地址</Label>
<div className="mt-2 break-all rounded-md bg-muted p-4 font-mono text-sm">{result.rtmp_url}</div>
</div>
</Space>
</div>
</CardContent>
</Card>
<Space size={12}>
<Button size="large" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(`推流码: ${result.stream_key}\nRTMP 地址: ${result.rtmp_url}`)
message.success('已复制')
}}>复制全部信息</Button>
<Button type="primary" size="large" onClick={() => navigate('/admin/streams')}>返回列表</Button>
</Space>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => {
navigator.clipboard.writeText(`推流码: ${result.stream_key}\nRTMP 地址: ${result.rtmp_url}`)
toast.success('已复制')
}}
>
<Copy className="h-4 w-4" />
复制全部信息
</Button>
<Button onClick={() => navigate('/admin/streams')}>返回列表</Button>
</div>
</div>
)
}
return (
<div style={{ maxWidth: 680 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')}
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
<div className="mx-auto max-w-2xl">
<Button variant="ghost" className="mb-5" onClick={() => navigate('/admin/streams')}>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
<div className="admin-glass-panel admin-form-panel">
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title>
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
</Text>
<Card>
<CardHeader>
<CardTitle className="text-2xl">创建节目</CardTitle>
<p className="text-sm text-muted-foreground">填写节目信息并预约播出,系统将生成推流码与 RTMP 地址</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="title">节目标题</Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="输入节目标题" />
</div>
<div className="space-y-2">
<Label htmlFor="description">简介</Label>
<Textarea id="description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="直播内容简介(可选)" />
</div>
<div className="space-y-2">
<Label htmlFor="cover">封面图片 URL</Label>
<Input id="cover" value={coverUrl} onChange={(e) => setCoverUrl(e.target.value)} placeholder="https://..." />
</div>
<div className="space-y-2">
<Label htmlFor="scheduled">预定开播时间</Label>
<Input id="scheduled" type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} />
</div>
<div className="space-y-2">
<Label>预设配置</Label>
<Select value={transcodeProfile} onValueChange={setTranscodeProfile}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">标准多档</SelectItem>
<SelectItem value="bandwidth-saver">带宽节省</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">可直接使用预设,也可在下方逐档调整参数</p>
</div>
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{
transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false,
renditions: [
{ label: '1080p', enabled: true, width: 1920, height: 1080, fps: 30, codec: 'libx264', bitrate: '5000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
{ label: '720p', enabled: true, width: 1280, height: 720, fps: 30, codec: 'libx264', bitrate: '2500k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
],
}}>
<Form.Item name="title" label="节目标题"
rules={[{ required: true, message: '请输入节目标题' }]}>
<Input placeholder="输入节目标题" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item name="description" label="简介">
<TextArea rows={3} placeholder="直播内容简介(可选)" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item name="cover_url" label="封面图片 URL">
<Input placeholder="https://..." style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item name="scheduled_at" label="预定开播时间">
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
</Form.Item>
<Form.Item name="transcode_profile" label="预设配置" extra="可直接使用预设,也可在下方逐档调整参数">
<Select options={[{ value: 'standard', label: '标准多档' }, { value: 'bandwidth-saver', label: '带宽节省' }]} />
</Form.Item>
<Form.List name="renditions">
{(fields, { add, remove }) => (
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 24 }}>
<Text strong>自定义清晰度与码率</Text>
{fields.map(({ key, name, ...restField }) => (
<Card key={key} size="small" extra={<Button danger type="link" onClick={() => remove(name)}>删除</Button>}>
<Row gutter={[12, 0]}>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'enabled']} label="启用" valuePropName="checked"><Switch /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'label']} label="标签" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'width']} label="宽度" rules={[{ required: true }]}><InputNumber min={160} max={7680} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'height']} label="高度" rules={[{ required: true }]}><InputNumber min={90} max={4320} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'fps']} label="FPS"><InputNumber min={1} max={120} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'codec']} label="编码"><Select options={[{ value: 'libx264', label: 'H.264' }, { value: 'libx265', label: 'H.265' }]} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'bitrate']} label="视频码率"><Input placeholder="2500k" /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'crf']} label="CRF"><InputNumber min={0} max={51} style={{ width: '100%' }} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'preset']} label="预设"><Select options={['ultrafast','superfast','veryfast','faster','fast','medium','slow'].map(value => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'audio_bitrate']} label="音频码率"><Input placeholder="128k" /></Form.Item></Col>
<Col xs={12} md={4}><Form.Item {...restField} name={[name, 'keyframe_seconds']} label="关键帧秒数"><InputNumber min={1} max={10} style={{ width: '100%' }} /></Form.Item></Col>
</Row>
</Card>
))}
<Button onClick={() => add({ label: '480p', enabled: true, width: 854, height: 480, fps: 30, codec: 'libx264', bitrate: '1000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 })}>添加清晰度</Button>
</Space>
)}
</Form.List>
<Form.Item name="homepage_visible" label="首页公开" valuePropName="checked">
<Switch checkedChildren="公开" unCheckedChildren="仅私有链接" />
</Form.Item>
<Form.Item name="remote_control_enabled" label="允许审计式观众播放控制" valuePropName="checked" extra="默认关闭,仅在明确启用后允许创建限时控制会话">
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item>
<div className="admin-section-divider" />
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
创建节目
<div className="space-y-3">
<Label className="font-medium">自定义清晰度与码率</Label>
{renditions.map((r, index) => (
<div key={index} className="rounded-md border border-border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch checked={r.enabled} onCheckedChange={(v) => updateRendition(index, { enabled: v })} />
<Input
className="h-8 w-24"
value={r.label}
onChange={(e) => updateRendition(index, { label: e.target.value })}
/>
</div>
<Button variant="ghost" size="icon" onClick={() => setRenditions((cur) => cur.filter((_, i) => i !== index))}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="space-y-1">
<Label className="text-xs">宽度</Label>
<Input type="number" min={160} max={7680} value={r.width} onChange={(e) => updateRendition(index, { width: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">高度</Label>
<Input type="number" min={90} max={4320} value={r.height} onChange={(e) => updateRendition(index, { height: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">FPS</Label>
<Input type="number" min={1} max={120} value={r.fps} onChange={(e) => updateRendition(index, { fps: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">码率</Label>
<Input value={r.bitrate} onChange={(e) => updateRendition(index, { bitrate: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">编码</Label>
<Select value={r.codec} onValueChange={(v) => updateRendition(index, { codec: v as 'libx264' | 'libx265' })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="libx264">H.264</SelectItem>
<SelectItem value="libx265">H.265</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">预设</Label>
<Select value={r.preset} onValueChange={(v) => updateRendition(index, { preset: v as RenditionConfig['preset'] })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRESETS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">CRF</Label>
<Input type="number" min={0} max={51} value={r.crf} onChange={(e) => updateRendition(index, { crf: Number(e.target.value) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">音频码率</Label>
<Input value={r.audio_bitrate} onChange={(e) => updateRendition(index, { audio_bitrate: e.target.value })} />
</div>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() =>
setRenditions((cur) => [
...cur,
{ label: '480p', enabled: true, width: 854, height: 480, fps: 30, codec: 'libx264', bitrate: '1000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 },
])
}
>
<Plus className="h-4 w-4" />
添加清晰度
</Button>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<Label>首页公开</Label>
<p className="text-xs text-muted-foreground">公开则出现在首页,关闭则仅私有链接可访问</p>
</div>
<Switch checked={homepageVisible} onCheckedChange={setHomepageVisible} />
</div>
<div className="flex items-center justify-between rounded-md border border-border p-3">
<div>
<Label>审计式观众播放控制</Label>
<p className="text-xs text-muted-foreground">仅在明确启用后允许创建限时控制会话</p>
</div>
<Switch checked={remoteControl} onCheckedChange={setRemoteControl} />
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading ? '创建中…' : '创建节目'}
</Button>
</Form.Item>
</Form>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
+163 -67
View File
@@ -1,90 +1,186 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, Button, Tag, Space, Typography, message, Tooltip, Popconfirm } from 'antd'
import { PlusOutlined, PlayCircleOutlined, StopOutlined, DeleteOutlined, LinkOutlined, EyeOutlined, EditOutlined } from '@ant-design/icons'
import { fetchStreams, deleteStream, goLive, endStream, type StreamInfo } from '../../api/streams'
const { Title, Text } = Typography
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: '待开播' },
live: { color: 'green', label: '直播中' },
ended: { color: 'default', label: '已结束' },
cancelled: { color: 'red', label: '已取消' },
}
import { Plus, Play, Square, Trash2, Link, Eye, Pencil } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { fetchStreams, deleteStream, goLive, endStream, type StreamInfo } from '@/api/streams'
import { formatDateTime } from '@/lib/status'
import { PageHeader } from '@/components/page-header'
import { StatusBadge } from '@/components/status-badge'
export default function StreamList() {
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => { loadData() }, [])
useEffect(() => {
void loadData()
}, [])
async function loadData() {
setLoading(true)
try { setStreams(await fetchStreams(true)) } catch { message.error('加载失败') }
try {
setStreams(await fetchStreams(true))
} catch {
toast.error('加载失败')
}
setLoading(false)
}
async function handleDelete(id: number) {
try { await deleteStream(id); message.success('已删除'); loadData() } catch { message.error('删除失败') }
}
async function handleGoLive(id: number) {
try { await goLive(id); message.success('已开播'); loadData() } catch { message.error('操作失败') }
}
async function handleEnd(id: number) {
try { await endStream(id); message.success('已结束'); loadData() } catch { message.error('操作失败') }
try {
await deleteStream(id)
toast.success('已删除')
void loadData()
} catch {
toast.error('删除失败')
}
}
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (s: string) => <Tag color={(statusMap[s] || {}).color}>{(statusMap[s] || { label: s }).label}</Tag> },
{ title: '推流码', dataIndex: 'stream_key', key: 'stream_key', width: 120,
render: (v: string) => <Text code style={{ fontSize: 12 }}>{v.slice(0, 12)}...</Text> },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.slice(0, 16), width: 160 },
{
title: '操作', key: 'actions', width: 240,
render: (_: any, r: StreamInfo) => (
<Space size={4}>
<Tooltip title="复制推流码">
<Button size="small" icon={<LinkOutlined />}
onClick={() => { if (r.stream_key) navigator.clipboard.writeText(r.stream_key); message.success('已复制') }} />
</Tooltip>
{r.status !== 'live' ? (
<Tooltip title="开播"><Button size="small" type="primary" ghost icon={<PlayCircleOutlined />} onClick={() => handleGoLive(r.id)} /></Tooltip>
) : (
<Tooltip title="结束"><Button size="small" danger icon={<StopOutlined />} onClick={() => handleEnd(r.id)} /></Tooltip>
)}
<Tooltip title="观看"><Button size="small" icon={<EyeOutlined />} onClick={() => window.open(`/watch/${r.id}`, '_blank')} /></Tooltip>
<Tooltip title="编辑"><Button size="small" icon={<EditOutlined />} onClick={() => navigate(`/admin/streams/${r.id}/edit`)} /></Tooltip>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Tooltip title="删除"><Button size="small" danger icon={<DeleteOutlined />} /></Tooltip>
</Popconfirm>
</Space>
),
},
]
async function handleGoLive(id: number) {
try {
await goLive(id)
toast.success('已开播')
void loadData()
} catch {
toast.error('操作失败')
}
}
async function handleEnd(id: number) {
try {
await endStream(id)
toast.success('已结束')
void loadData()
} catch {
toast.error('操作失败')
}
}
return (
<div className="admin-page admin-stream-list">
<div className="admin-page-heading admin-page-heading--actions">
<div>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title>
<Text className="admin-page-kicker">管理直播、排期与回放节目</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}>
<div className="space-y-6">
<PageHeader title="节目列表" kicker="Streams" description="管理直播、排期与回放节目">
<Button onClick={() => navigate('/admin/streams/new')}>
<Plus className="h-4 w-4" />
创建节目
</Button>
</div>
<div style={{
background: 'rgba(18, 24, 36, 0.72)', borderRadius: 16,
border: '1px solid rgba(255, 255, 255, 0.095)', padding: '8px 8px 0',
}}>
<Table dataSource={streams} columns={columns} rowKey="id" loading={loading}
pagination={{ pageSize: 15, showSizeChanger: false }} size="middle"
locale={{ emptyText: '暂无节目,创建第一个节目吧' }} />
</div>
</PageHeader>
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base">全部节目</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-14">ID</TableHead>
<TableHead>标题</TableHead>
<TableHead className="w-24">状态</TableHead>
<TableHead className="w-32">推流码</TableHead>
<TableHead className="w-40">创建时间</TableHead>
<TableHead className="w-52 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
加载中…
</TableCell>
</TableRow>
) : streams.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
暂无节目,创建第一个节目吧
</TableCell>
</TableRow>
) : (
streams.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground">{s.id}</TableCell>
<TableCell className="max-w-0 truncate font-medium">{s.title}</TableCell>
<TableCell>
<StatusBadge status={s.status} />
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{s.stream_key?.slice(0, 12)}…</code>
</TableCell>
<TableCell className="text-muted-foreground">{formatDateTime(s.created_at)}</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
title="复制推流码"
onClick={() => {
if (s.stream_key) navigator.clipboard.writeText(s.stream_key)
toast.success('已复制')
}}
>
<Link className="h-4 w-4" />
</Button>
{s.status !== 'live' ? (
<Button variant="ghost" size="icon" title="开播" onClick={() => handleGoLive(s.id)}>
<Play className="h-4 w-4 text-emerald-500" />
</Button>
) : (
<Button variant="ghost" size="icon" title="结束" onClick={() => handleEnd(s.id)}>
<Square className="h-4 w-4 text-destructive" />
</Button>
)}
<Button variant="ghost" size="icon" title="观看" onClick={() => window.open(`/watch/${s.id}`, '_blank')}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="编辑" onClick={() => navigate(`/admin/streams/${s.id}/edit`)}>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" title="删除">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除?</AlertDialogTitle>
<AlertDialogDescription>删除后节目与回放配置将不可恢复。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(s.id)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect } from 'react'
import { ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default function SwaggerPage() {
useEffect(() => {
// Load Swagger UI assets from CDN and mount against the backend OpenAPI spec.
const css = document.createElement('link')
css.rel = 'stylesheet'
css.href = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css'
document.head.appendChild(css)
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js'
script.onload = () => {
const swaggerUI = (window as any).SwaggerUIBundle
if (!swaggerUI || !document.getElementById('swagger-container')) return
swaggerUI({
url: '/openapi.json',
dom_id: '#swagger-container',
deepLinking: true,
presets: [swaggerUI.presets.apis],
layout: 'BaseLayout',
})
}
document.body.appendChild(script)
return () => {
document.head.removeChild(css)
document.body.removeChild(script)
}
}, [])
return (
<div className="space-y-6">
<div className="flex items-end justify-between">
<div>
<h2 className="text-2xl font-semibold">API 文档</h2>
<p className="text-sm text-muted-foreground">基于 OpenAPI 规范的接口调试与说明(Swagger UI)</p>
</div>
<Button variant="outline" asChild>
<a href="/docs" target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
原生 Swagger
</a>
</Button>
</div>
<Card className="overflow-hidden">
<CardHeader className="border-b border-border">
<CardTitle className="text-base">Swagger UI</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div id="swagger-container" className="min-h-[70vh]" />
</CardContent>
</Card>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
description?: string
kicker?: string
children?: ReactNode
className?: string
}
export function PageHeader({ title, description, kicker, children, className }: PageHeaderProps) {
return (
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
<div className="space-y-2">
{kicker && (
<p className="text-xs font-bold uppercase tracking-[0.14em] text-primary">{kicker}</p>
)}
<h2 className="font-display text-3xl font-semibold leading-tight tracking-tight sm:text-4xl">
{title}
</h2>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
type Tone = 'default' | 'success' | 'warning' | 'destructive'
const toneClass: Record<Tone, string> = {
default: 'text-muted-foreground',
success: 'text-emerald-500',
warning: 'text-amber-500',
destructive: 'text-destructive',
}
interface StatCardProps {
label: string
value: ReactNode
icon?: LucideIcon
description?: string
tone?: Tone
}
export function StatCard({ label, value, icon: Icon, description, tone = 'default' }: StatCardProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
{Icon && <Icon className={cn('h-4 w-4', toneClass[tone])} />}
</div>
<p className="mt-2 text-2xl font-semibold tabular-nums">{value}</p>
{description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
</CardContent>
</Card>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
const STATUS_META: Record<string, { label: string; dot: string }> = {
pending: { label: '待开播', dot: 'bg-amber-500' },
live: { label: '直播中', dot: 'bg-emerald-500' },
ended: { label: '已结束', dot: 'bg-zinc-500' },
cancelled: { label: '已取消', dot: 'bg-red-500' },
}
export function statusLabel(status: string): string {
return STATUS_META[status]?.label ?? status
}
export function StatusBadge({ status, className }: { status: string; className?: string }) {
const meta = STATUS_META[status]
if (!meta) return <Badge variant="outline">{status}</Badge>
return (
<Badge variant="outline" className={cn('gap-1.5 font-normal text-muted-foreground', className)}>
<span className={cn('h-1.5 w-1.5 rounded-full', meta.dot)} />
{meta.label}
</Badge>
)
}
+139
View File
@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border/60 bg-card text-card-foreground shadow-warm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,199 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+157
View File
@@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+29
View File
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
-675
View File
@@ -1,675 +0,0 @@
:root {
--bg-page: #070A10;
--bg-surface: #10151F;
--bg-surface-raised: #171D29;
--bg-muted: #1D2432;
--bg-brand: #5B8CFF;
--bg-brand-light: #17264A;
--bg-dark: #04060A;
--text-primary: #F4F7FB;
--text-secondary: #AAB4C5;
--text-tertiary: #748096;
--text-inverse: #05070B;
--border-default: #273044;
--border-light: #1D2636;
--border-brand: #5B8CFF;
--shadow-sm: 0 2px 8px rgba(0,0,0,0.24);
--shadow-md: 0 12px 32px rgba(0,0,0,0.28);
--shadow-lg: 0 24px 64px rgba(0,0,0,0.36);
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--font-sans: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
--space-9: 96px;
--space-10: 128px;
}
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.65;
color: var(--text-primary);
background:
radial-gradient(circle at 85% -20%, rgba(91,140,255,0.18), transparent 34rem),
var(--bg-page);
min-width: 320px;
}
a { color: inherit; }
button, input, select, textarea { font: inherit; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans);
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.02em;
}
::selection {
background: #5B8CFF;
color: #FFFFFF;
}
.admin-shell { min-height: 100vh; background: transparent; }
.admin-sider { background: #0B1018 !important; border-right: 1px solid var(--border-light); }
.admin-brand { height: 72px; display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid var(--border-light); }
.admin-brand-mark { width: 34px; height: 34px; border-radius: 11px; display: grid; place-items: center; background: linear-gradient(135deg,#5B8CFF,#7B61FF); color: white; font-weight: 800; box-shadow: 0 8px 24px rgba(91,140,255,.35); }
.admin-header { height: 72px !important; padding: 0 32px !important; display: flex; align-items: center; justify-content: space-between; background: rgba(10,14,22,.86) !important; border-bottom: 1px solid var(--border-light); backdrop-filter: blur(18px); }
.admin-content { padding: 32px !important; overflow: auto; }
.admin-content .ant-card { border-color: var(--border-default); box-shadow: var(--shadow-sm); }
.admin-page-transition { max-width: 1540px; margin: 0 auto; animation: admin-enter .24s ease-out; }
@keyframes admin-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (max-width: 768px) { .admin-header { padding: 0 16px !important; } .admin-content { padding: 16px !important; } }
@media (max-width: 600px) {
.replay-editor { overflow: hidden; }
.replay-editor video { max-width: 100%; }
.replay-chapter-row { display: flex !important; }
.replay-chapter-row .ant-input-number { flex: 1 1 140px; max-width: 100%; }
}
/* Unified admin dark surfaces */
.admin-content .ant-card,
.admin-content .ant-table,
.admin-content .ant-table-container,
.admin-content .ant-table-thead > tr > th,
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-view,
.admin-content .ant-list,
.admin-content .ant-modal-content,
.admin-content .ant-drawer-content {
background: #10151f;
border-color: #273044;
color: #f4f7fb;
}
.admin-content .ant-table-tbody > tr:hover > td,
.admin-content .ant-table-row:hover > td {
background: #171d29 !important;
}
.admin-content .ant-typography,
.admin-content .ant-statistic-title,
.admin-content .ant-statistic-content,
.admin-content .ant-table-cell,
.admin-header .ant-btn {
color: #f4f7fb;
}
.admin-content .ant-typography-secondary,
.admin-content .ant-form-item-label > label,
.admin-content .ant-empty-description {
color: #8d99ad;
}
.admin-content .ant-input,
.admin-content .ant-input-affix-wrapper,
.admin-content .ant-select-selector,
.admin-content .ant-picker,
.admin-content .ant-input-number,
.admin-content .ant-segmented {
background: #0b0f17 !important;
border-color: #273044 !important;
color: #f4f7fb !important;
}
.admin-content .ant-card,
.admin-content .ant-table-wrapper,
.admin-content .ant-descriptions-view {
border-radius: 16px;
overflow: hidden;
}
.admin-content .ant-divider {
border-color: #273044;
}
@media (max-width: 768px) {
.admin-content { padding: 16px; }
.admin-header { padding-inline: 16px; }
.admin-content .ant-table-wrapper { overflow-x: auto; }
}
/* Global dark overlays and feedback */
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner,
.ant-picker-dropdown .ant-picker-panel-container,
.ant-message-notice-content,
.ant-notification-notice,
.ant-modal-content,
.ant-modal-header,
.ant-drawer-content,
.ant-drawer-header { background: #171d29 !important; color: #f4f7fb !important; border-color: #273044 !important; }
.ant-dropdown-menu-item,
.ant-dropdown-menu-submenu-title,
.ant-select-item,
.ant-popover-inner-content,
.ant-modal-title,
.ant-drawer-title,
.ant-message-notice-content,
.ant-notification-notice-message,
.ant-notification-notice-description { color: #f4f7fb !important; }
.ant-dropdown-menu-item:hover,
.ant-dropdown-menu-submenu-title:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled) { background: #1d2636 !important; }
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) { background: rgba(91,140,255,.2) !important; color: #fff !important; }
.ant-alert { background: #171d29 !important; border-color: #273044 !important; }
.ant-alert-message { color: #f4f7fb !important; }
.ant-alert-description { color: #aab4c5 !important; }
.ant-alert-error { background: rgba(255,100,124,.1) !important; border-color: rgba(255,100,124,.34) !important; }
.ant-alert-warning { background: rgba(246,185,74,.1) !important; border-color: rgba(246,185,74,.34) !important; }
.ant-alert-success { background: rgba(45,212,167,.1) !important; border-color: rgba(45,212,167,.34) !important; }
.ant-menu-dark,
.ant-menu-dark > .ant-menu,
.admin-sider .ant-menu { background: #10151f !important; color: #aab4c5 !important; }
/* Apple dark material system */
:root {
color-scheme: dark;
--apple-canvas: #08090d;
--apple-surface: rgba(31, 32, 38, 0.72);
--apple-surface-raised: rgba(42, 43, 50, 0.86);
--apple-fill: rgba(255, 255, 255, 0.065);
--apple-fill-hover: rgba(255, 255, 255, 0.09);
--apple-separator: rgba(255, 255, 255, 0.11);
--apple-separator-strong: rgba(255, 255, 255, 0.18);
--apple-label: rgba(255, 255, 255, 0.94);
--apple-secondary-label: rgba(235, 238, 245, 0.62);
--apple-accent: #80aaff;
--apple-focus: rgba(128, 170, 255, 0.38);
--apple-shadow: 0 18px 48px rgba(0, 0, 0, 0.34);
--apple-shadow-raised: 0 28px 80px rgba(0, 0, 0, 0.5);
--apple-material: blur(24px) saturate(145%);
--apple-ease: cubic-bezier(0.2, 0, 0, 1);
--apple-ease-settle: cubic-bezier(0.2, 0.8, 0.2, 1);
}
body {
color: var(--apple-label);
background:
radial-gradient(circle at 16% -8%, rgba(82, 119, 194, 0.2), transparent 32rem),
radial-gradient(circle at 90% 110%, rgba(80, 67, 142, 0.12), transparent 34rem),
var(--apple-canvas);
}
.ant-card,
.ant-table-wrapper .ant-table,
.ant-descriptions,
.ant-list,
.ant-tabs,
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content,
.ant-dropdown .ant-dropdown-menu,
.ant-select-dropdown,
.ant-picker-dropdown .ant-picker-panel-container {
border-color: var(--apple-separator);
background: var(--apple-surface);
box-shadow: var(--apple-shadow);
-webkit-backdrop-filter: var(--apple-material);
backdrop-filter: var(--apple-material);
}
.ant-card,
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content,
.ant-dropdown .ant-dropdown-menu,
.ant-select-dropdown,
.ant-picker-dropdown .ant-picker-panel-container {
border: 1px solid var(--apple-separator);
}
.ant-card,
.ant-table-wrapper,
.ant-list,
.ant-descriptions,
.ant-tabs,
.ant-btn,
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-pagination-item,
.ant-pagination-prev,
.ant-pagination-next,
.ant-dropdown-menu-item,
.ant-tabs-tab {
transition-property: transform, opacity, color, border-color, background-color, box-shadow, filter;
transition-duration: 160ms;
transition-timing-function: var(--apple-ease);
}
.ant-card {
overflow: hidden;
border-radius: 20px;
}
.ant-card .ant-card-head,
.ant-modal .ant-modal-header,
.ant-drawer .ant-drawer-header {
border-color: var(--apple-separator);
background: transparent;
}
.ant-table-wrapper .ant-table-container,
.ant-table-wrapper .ant-table-cell,
.ant-table-wrapper .ant-table-thead > tr > th,
.ant-table-wrapper .ant-table-tbody > tr > td {
border-color: var(--apple-separator);
background: transparent;
}
.ant-table-wrapper .ant-table-thead > tr > th {
color: var(--apple-secondary-label);
background: rgba(255, 255, 255, 0.045);
font-size: 12px;
letter-spacing: 0.04em;
}
.ant-table-wrapper .ant-table-tbody > tr.ant-table-row:hover > td {
background: var(--apple-fill);
}
.ant-form-item-label > label,
.ant-descriptions .ant-descriptions-item-label,
.ant-list .ant-list-item-meta-description,
.ant-empty .ant-empty-description {
color: var(--apple-secondary-label);
}
.ant-descriptions .ant-descriptions-view,
.ant-list-bordered {
border-color: var(--apple-separator);
}
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-input-search-button {
border-color: var(--apple-separator);
background: var(--apple-fill);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.ant-input:hover,
.ant-input-affix-wrapper:hover,
.ant-input-number:hover,
.ant-select:not(.ant-select-disabled):hover .ant-select-selector,
.ant-picker:hover {
border-color: var(--apple-separator-strong);
}
.ant-input:focus,
.ant-input-focused,
.ant-input-affix-wrapper-focused,
.ant-input-number-focused,
.ant-select-focused:not(.ant-select-disabled) .ant-select-selector,
.ant-picker-focused {
border-color: var(--apple-accent);
box-shadow: 0 0 0 3px var(--apple-focus);
}
.ant-dropdown-menu-item:hover,
.ant-dropdown-menu-submenu-title:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled),
.ant-pagination-item:hover,
.ant-tabs-tab:hover {
color: var(--apple-label);
border-color: var(--apple-separator-strong);
background: var(--apple-fill-hover);
}
.ant-pagination-item-active,
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
border-color: rgba(128, 170, 255, 0.48);
background: rgba(128, 170, 255, 0.16);
}
.ant-modal-mask,
.ant-drawer-mask {
background: rgba(2, 3, 7, 0.64) !important;
-webkit-backdrop-filter: blur(12px) saturate(115%);
backdrop-filter: blur(12px) saturate(115%);
transition-property: opacity, background-color, backdrop-filter;
transition-duration: 220ms;
transition-timing-function: var(--apple-ease);
}
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content {
background: var(--apple-surface-raised);
box-shadow: var(--apple-shadow-raised);
}
.ant-empty-image {
opacity: 0.62;
filter: grayscale(0.2) contrast(0.9);
}
.ant-btn:focus-visible,
.ant-menu-item:focus-visible,
.ant-tabs-tab:focus-visible,
.ant-pagination-item:focus-visible,
.ant-dropdown-menu-item:focus-visible {
outline: 3px solid var(--apple-focus);
outline-offset: 2px;
}
.apple-material-shell .ant-layout-sider,
.apple-material-shell .ant-layout-header,
.admin-login-panel {
border: 1px solid var(--apple-separator);
background: var(--apple-surface);
box-shadow: var(--apple-shadow);
-webkit-backdrop-filter: var(--apple-material);
backdrop-filter: var(--apple-material);
}
.admin-login-panel {
border-radius: 24px;
padding: 32px;
}
@media (hover: hover) and (pointer: fine) {
.ant-card:hover {
transform: translateY(-2px);
border-color: var(--apple-separator-strong);
box-shadow: var(--apple-shadow-raised);
}
.ant-btn:not(:disabled):hover,
.ant-pagination-item:hover {
transform: translateY(-1px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
}
.ant-card,
.ant-table-wrapper,
.ant-list,
.ant-descriptions,
.ant-tabs,
.ant-btn,
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-pagination-item,
.ant-pagination-prev,
.ant-pagination-next,
.ant-dropdown-menu-item,
.ant-tabs-tab,
.ant-modal-mask,
.ant-drawer-mask {
animation: none !important;
transition-duration: 0.01ms !important;
}
.ant-card:hover,
.ant-btn:not(:disabled):hover,
.ant-pagination-item:hover {
transform: none;
}
}
/* Apple-inspired dark control-room surfaces */
.admin-shell,
.admin-content,
.admin-page-transition {
color-scheme: dark;
}
.admin-glass-panel,
.admin-content .ant-card,
.admin-content .ant-table-wrapper,
.admin-content .ant-descriptions,
.admin-content .ant-list,
.admin-content .ant-tabs-content-holder,
.admin-content .ant-collapse {
background: linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72));
border: 1px solid rgba(255, 255, 255, 0.095);
border-radius: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045);
-webkit-backdrop-filter: blur(24px) saturate(135%);
backdrop-filter: blur(24px) saturate(135%);
}
.admin-form-panel { padding: 32px; }
.admin-stat-panel { padding: 24px 28px; }
.admin-table-panel { padding: 8px 8px 0; overflow: hidden; }
.admin-content .ant-card,
.admin-content .ant-card-head,
.admin-content .ant-card-body,
.admin-content .ant-table,
.admin-content .ant-table-container,
.admin-content .ant-table-content,
.admin-content .ant-table-thead > tr > th,
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-view,
.admin-content .ant-descriptions-row > th,
.admin-content .ant-descriptions-row > td,
.admin-content .ant-list-item,
.admin-content .ant-tabs-nav,
.admin-content .ant-collapse,
.admin-content .ant-collapse-item,
.admin-content .ant-collapse-header,
.admin-content .ant-collapse-content,
.admin-content .ant-collapse-content-box {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.085) !important;
color: var(--text-primary) !important;
}
.admin-content .ant-table-thead > tr > th {
color: var(--text-secondary) !important;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-item-content,
.admin-content .ant-list-item-meta-description,
.admin-content .ant-form-item-extra,
.admin-content .ant-form-item-explain {
color: var(--text-secondary) !important;
}
.admin-content .ant-table-tbody > tr.ant-table-row:hover > td {
background: rgba(91, 140, 255, 0.075) !important;
}
.admin-content .ant-form-item-label > label,
.admin-content .ant-descriptions-item-label,
.admin-content .ant-list-item-meta-title,
.admin-content .ant-tabs-tab,
.admin-content .ant-collapse-header-text,
.admin-content .ant-empty-description {
color: var(--text-secondary) !important;
}
.admin-content .ant-tabs-tab-active .ant-tabs-tab-btn,
.admin-content .ant-list-item-meta-title,
.admin-content .ant-descriptions-title {
color: var(--text-primary) !important;
}
.admin-content :where(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select-selector, .ant-cascader-picker, .ant-mentions, .ant-upload-drag) {
background: rgba(7, 11, 18, 0.68) !important;
border-color: rgba(255, 255, 255, 0.11) !important;
color: var(--text-primary) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
transition: border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1), transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.admin-content :where(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select-selector):focus,
.admin-content :where(.ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select).ant-input-affix-wrapper-focused,
.admin-content .ant-select-focused .ant-select-selector {
border-color: rgba(116, 158, 255, 0.78) !important;
box-shadow: 0 0 0 3px rgba(91, 140, 255, 0.16) !important;
}
.ant-modal-mask,
.ant-drawer-mask {
background: rgba(0, 0, 0, 0.62) !important;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.ant-modal-content,
.ant-modal-header,
.ant-drawer-content,
.ant-drawer-header,
.ant-drawer-body,
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner,
.ant-tooltip-inner,
.ant-message-notice-content,
.ant-notification-notice {
background: rgba(18, 24, 36, 0.9) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
color: var(--text-primary) !important;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.055) !important;
-webkit-backdrop-filter: blur(28px) saturate(145%);
backdrop-filter: blur(28px) saturate(145%);
}
.ant-modal-content,
.ant-drawer-content,
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner {
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ant-dropdown-menu-item,
.ant-select-item,
.ant-pagination-item,
.ant-pagination-prev .ant-pagination-item-link,
.ant-pagination-next .ant-pagination-item-link {
color: var(--text-secondary) !important;
transition: color 160ms cubic-bezier(0.23, 1, 0.32, 1), background-color 160ms cubic-bezier(0.23, 1, 0.32, 1), border-color 160ms cubic-bezier(0.23, 1, 0.32, 1), transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.ant-pagination-item,
.ant-pagination-prev .ant-pagination-item-link,
.ant-pagination-next .ant-pagination-item-link {
background: rgba(18, 24, 36, 0.72) !important;
border-color: rgba(255, 255, 255, 0.09) !important;
}
.ant-pagination-item-active {
background: rgba(91, 140, 255, 0.19) !important;
border-color: rgba(116, 158, 255, 0.66) !important;
}
.ant-empty-image svg ellipse,
.ant-empty-image svg path {
fill: rgba(111, 124, 149, 0.28) !important;
stroke: rgba(154, 168, 194, 0.44) !important;
}
.admin-content .ant-btn,
.admin-header .ant-btn,
.login-page .ant-btn {
transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1), border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1), opacity 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.admin-content .ant-btn:active,
.admin-header .ant-btn:active,
.login-page .ant-btn:active {
transform: scale(0.97);
}
.admin-content .ant-card,
.admin-glass-panel,
.admin-sider .ant-menu-item,
.admin-content .ant-collapse-content,
.ant-modal-content,
.ant-drawer-content,
.ant-dropdown-menu,
.ant-select-dropdown {
transition: transform 220ms cubic-bezier(0.23, 1, 0.32, 1), opacity 180ms cubic-bezier(0.23, 1, 0.32, 1), border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 220ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
@media (hover: hover) and (pointer: fine) {
.admin-content .ant-card:hover,
.admin-glass-panel:hover {
transform: translate3d(0, -2px, 0);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 24px 58px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ant-dropdown-menu-item:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
background: rgba(91, 140, 255, 0.12) !important;
}
}
@media (max-width: 768px) {
.admin-form-panel,
.admin-stat-panel { padding: 20px; }
.admin-content .ant-card,
.admin-glass-panel { border-radius: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.admin-content *,
.admin-header *,
.admin-sider *,
.login-page *,
.ant-modal-root *,
.ant-drawer *,
.ant-dropdown *,
.ant-select-dropdown * {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 80ms !important;
}
.admin-content .ant-card:hover,
.admin-glass-panel:hover,
.admin-content .ant-btn:active,
.admin-header .ant-btn:active,
.login-page .ant-btn:active {
transform: none !important;
}
}
.admin-section-divider { margin: 8px 0 24px; border-top: 1px solid rgba(255,255,255,.095); }
+126
View File
@@ -0,0 +1,126 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 45 8% 11%;
--foreground: 42 24% 93%;
--card: 45 7% 14%;
--card-foreground: 42 24% 93%;
--popover: 45 7% 15%;
--popover-foreground: 42 24% 93%;
--primary: 43 90% 62%;
--primary-foreground: 40 30% 12%;
--secondary: 45 7% 18%;
--secondary-foreground: 42 24% 93%;
--muted: 45 7% 17%;
--muted-foreground: 43 12% 62%;
--accent: 45 7% 19%;
--accent-foreground: 42 24% 93%;
--destructive: 16 72% 60%;
--destructive-foreground: 42 24% 93%;
--border: 45 8% 21%;
--input: 45 8% 22%;
--ring: 43 90% 62%;
--radius: 0.625rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: 'rlig' 1, 'calt' 1;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
}
@layer utilities {
.font-display {
font-family: Georgia, 'Times New Roman', ui-serif, serif;
}
.shadow-warm {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 12px 32px rgba(0, 0, 0, 0.22);
}
.shadow-warm-lg {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 22px 58px rgba(0, 0, 0, 0.28);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 8px;
}
}
/* Markdown content rendered by the viewer (description / chapters intro). */
.md-content {
font-size: 0.875rem;
line-height: 1.7;
color: hsl(var(--foreground));
}
.md-content h1,
.md-content h2,
.md-content h3,
.md-content h4 {
font-weight: 600;
margin: 1rem 0 0.5rem;
}
.md-content h1 {
font-size: 1.25rem;
}
.md-content h2 {
font-size: 1.125rem;
}
.md-content h3 {
font-size: 1rem;
}
.md-content p {
margin: 0.5rem 0;
}
.md-content ul,
.md-content ol {
padding-left: 1.25rem;
margin: 0.5rem 0;
}
.md-content ul {
list-style: disc;
}
.md-content ol {
list-style: decimal;
}
.md-content a {
color: hsl(var(--primary));
text-decoration: underline;
}
.md-content code {
background: hsl(var(--muted));
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.8rem;
}
.md-content pre {
background: hsl(var(--muted));
padding: 0.75rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.md-content blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 0.75rem;
color: hsl(var(--muted-foreground));
}
/* Swagger UI sits inside a dark card; keep its own theme independent. */
#swagger-container .swagger-ui {
font-family: inherit;
}
+41
View File
@@ -0,0 +1,41 @@
export type StreamStatus = 'pending' | 'live' | 'ended' | 'cancelled'
export const STATUS_LABEL: Record<string, string> = {
pending: '待开播',
live: '直播中',
ended: '已结束',
cancelled: '已取消',
}
export const STATUS_BADGE: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
live: 'default',
ended: 'outline',
cancelled: 'destructive',
}
export const STATUS_ORDER: Record<string, number> = {
live: 0,
pending: 1,
ended: 2,
cancelled: 3,
}
export function formatDateTime(value?: string | null): string {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function formatBytes(value = 0): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let amount = Math.max(0, value)
let index = 0
while (amount >= 1024 && index < units.length - 1) {
amount /= 1024
index += 1
}
return `${amount.toFixed(index ? 1 : 0)} ${units[index]}`
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './global.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
-116
View File
@@ -1,116 +0,0 @@
.app-route-transition,
.admin-page-transition {
min-height: 100%;
animation: page-soft-enter 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: transform, opacity, filter;
background: transparent;
}
.admin-page-transition {
transform-origin: top center;
}
.route-loading {
min-height: 280px;
display: grid;
place-items: center;
border-radius: 24px;
background: #070a10;
}
.route-loading span {
width: 28px;
height: 28px;
border-radius: 999px;
border: 3px solid rgba(91, 140, 255, 0.18);
border-top-color: #5B8CFF;
animation: route-loading-spin 760ms linear infinite;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
::view-transition-old(root) {
animation-name: view-soft-out;
}
::view-transition-new(root) {
animation-name: view-soft-in;
}
@keyframes page-soft-enter {
from {
opacity: 0.94;
transform: translate3d(18px, 0, 0) scale(0.998);
filter: blur(0.2px);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
filter: blur(0);
}
}
@keyframes view-soft-out {
from { opacity: 1; transform: translate3d(0, 0, 0); }
to { opacity: 0.96; transform: translate3d(-8px, 0, 0); }
}
@keyframes view-soft-in {
from { opacity: 0.96; transform: translate3d(10px, 0, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
@keyframes route-loading-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.app-route-transition,
.admin-page-transition,
.route-loading span,
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
/* Apple route motion */
.app-route-transition,
.admin-page-transition {
transform-origin: 50% 24%;
animation: apple-page-enter 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.route-loading {
transition-property: opacity, filter;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
@keyframes apple-page-enter {
from {
opacity: 0;
transform: translate3d(0, 8px, 0) scale(0.995);
filter: blur(3px);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.app-route-transition,
.admin-page-transition,
.route-loading {
animation: none !important;
transform: none !important;
filter: none !important;
transition-duration: 0.01ms !important;
}
}
-78
View File
@@ -1,78 +0,0 @@
import type { ThemeConfig } from 'antd'
const theme: ThemeConfig = {
token: {
fontFamily: `Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`,
fontSize: 14,
lineHeight: 1.6,
colorPrimary: '#5B8CFF',
colorSuccess: '#2DD4A7',
colorWarning: '#F6B94A',
colorError: '#FF647C',
colorInfo: '#62B5FF',
colorTextBase: '#F4F7FB',
colorTextSecondary: '#AAB4C5',
colorTextTertiary: '#748096',
colorBgBase: '#070A10',
colorBgContainer: '#10151F',
colorBgLayout: '#070A10',
colorBgElevated: '#171D29',
colorBorder: '#273044',
colorBorderSecondary: '#1D2636',
colorFill: '#1D2432',
colorFillSecondary: '#171D29',
borderRadius: 10,
borderRadiusLG: 16,
borderRadiusSM: 4,
wireframe: false,
fontWeightStrong: 600,
controlHeight: 40,
controlHeightLG: 48,
controlHeightSM: 32,
paddingLG: 32,
paddingMD: 24,
paddingSM: 16,
boxShadow: '0 1px 2px rgba(28,26,23,0.03)',
boxShadowSecondary: '0 4px 12px rgba(28,26,23,0.04)',
boxShadowTertiary: '0 12px 32px rgba(28,26,23,0.06)',
},
components: {
Layout: {
headerBg: '#10151F',
headerHeight: 60,
headerPadding: '0 48px',
siderBg: '#10151F',
bodyBg: '#070A10',
},
Menu: {
itemBg: 'transparent',
itemSelectedBg: '#1D2636',
itemSelectedColor: '#F4F7FB',
itemActiveBg: '#171D29',
itemColor: '#AAB4C5',
itemHeight: 44,
},
Card: {
paddingLG: 28,
borderRadiusLG: 10,
},
Button: {
borderRadius: 6,
fontWeight: 500,
primaryShadow: '0 2px 6px rgba(194,105,58,0.1)',
},
Table: {
headerBg: '#171D29',
headerColor: '#AAB4C5',
rowHoverBg: '#171D29',
borderColor: '#273044',
},
Tag: { borderRadiusSM: 4 },
Modal: { borderRadiusLG: 12, paddingLG: 28 },
Tabs: { inkBarColor: '#5B8CFF', itemActiveColor: '#5B8CFF', itemHoverColor: '#8AABFF' },
Select: { borderRadius: 6 },
Input: { borderRadius: 6 },
},
}
export default theme
+542 -121
View File
@@ -1,14 +1,7 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import Hls from 'hls.js'
import {
FullscreenExitOutlined,
FullscreenOutlined,
PauseOutlined,
PlayCircleOutlined,
SoundOutlined,
} from '@ant-design/icons'
import type { Chapter, LineInfo, QualityInfo } from '../../api/streams'
import './player.css'
import { Loader2, Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react'
import type { Chapter, LineInfo, QualityInfo } from '@/api/streams'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
@@ -42,46 +35,161 @@ function formatTime(seconds: number) {
: `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function VideoPlayer({
live,
replayUrl,
lines,
qualities,
activeLine,
activeQuality,
autoQuality,
chapters = [],
onLineChange,
onQualityChange,
onError,
}, ref) {
/** "2.5Mbps" / "600kbps" for the source tier, whose rate is only known at runtime. */
function formatBitrate(value: number) {
if (!Number.isFinite(value) || value <= 0) return ''
return value >= 1000000 ? `${(value / 1000000).toFixed(1)}Mbps` : `${Math.round(value / 1000)}kbps`
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function VideoPlayer(
{
live,
replayUrl,
lines,
qualities,
activeLine,
activeQuality,
autoQuality,
chapters = [],
onLineChange,
onQualityChange,
onError,
},
ref,
) {
const rootRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const objectUrlRef = useRef<string>()
const [loading, setLoading] = useState(false)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [buffered, setBuffered] = useState(0)
const [volume, setVolume] = useState(1)
const [muted, setMuted] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
// Autoplay gate: a browser that refuses to start playback on its own can only
// be unblocked by a real click, so the prompt has to be impossible to miss.
// `ready` keeps it hidden until there is actually something to play.
const [needsGesture, setNeedsGesture] = useState(false)
const [ready, setReady] = useState(false)
// Set when autoplay only succeeded after muting, so the click that clears the
// gate knows to restore sound.
const [mutedAttempt, setMutedAttempt] = useState(false)
// Measured size/bitrate of the source ("原画") stream. Nothing about it is known
// up front, and reading it off a different rendition would mislabel the option,
// so it is only filled in while the source playlist is the one playing.
const [sourceMeta, setSourceMeta] = useState('')
const sourceMetaRef = useRef({ isSource: false })
const sourceBitrateRef = useRef(0)
// Controls start visible, then fade after inactivity. Hiding them purely on
// :hover meant entering fullscreen showed a bare video with no controls until
// the pointer happened to move.
const [controlsVisible, setControlsVisible] = useState(true)
const hideTimerRef = useRef<number | null>(null)
useImperativeHandle(ref, () => ({
seek(seconds) {
if (videoRef.current) videoRef.current.currentTime = Math.max(0, seconds)
},
async play() {
await videoRef.current?.play()
},
pause() {
videoRef.current?.pause()
},
getVideo() {
return videoRef.current
},
}), [])
// Refresh the source-option label from what is actually decoding. A component
// function so both the once-registered media listeners and the source-switching
// effect can call it.
const measureSource = useCallback(() => {
const video = videoRef.current
if (!video || !sourceMetaRef.current.isSource) return
setSourceMeta(
[formatBitrate(sourceBitrateRef.current), video.videoWidth && video.videoHeight ? `${video.videoWidth}×${video.videoHeight}` : '']
.filter(Boolean)
.join(' · '),
)
}, [])
// Attempt autoplay, silently muting first if the browser only allows muted
// playback. Rejections other than NotAllowedError (AbortError from a source
// swap, etc.) leave the element alone — the 'canplay' handler covers those.
const attemptAutoplay = useCallback(async (video: HTMLVideoElement) => {
try {
await video.play()
setNeedsGesture(false)
} catch (error) {
if ((error as DOMException)?.name === 'NotAllowedError' && !video.muted) {
video.muted = true
setMuted(true)
setMutedAttempt(true)
try {
// Muted playback is granted, so the stream starts — but silently. Keep
// the prompt up until the viewer interacts, both to offer the sound and
// so the muted state is never mistaken for a broken stream.
await video.play()
setNeedsGesture(true)
return
} catch {
// fall through to the gate
}
} else if ((error as DOMException)?.name !== 'NotAllowedError') {
return
}
if (!video.paused) return
setNeedsGesture(true)
}
}, [])
const unmute = useCallback(() => {
const video = videoRef.current
if (!video) return
video.muted = false
if (video.volume === 0) video.volume = 1
setMuted(false)
setMutedAttempt(false)
}, [])
// The click that unblocks playback also has to unmute — otherwise the viewer
// sees a "muted" state they never asked for.
const resolveGate = () => {
const video = videoRef.current
setNeedsGesture(false)
if (!video) return
if (mutedAttempt) unmute()
void video.play().catch(() => setNeedsGesture(true))
}
// Muted autoplay always succeeds, so "we muted it to get playing at all" is a
// silent state, and the stream sounds broken until someone notices the small
// speaker icon. This deliberately restores sound from ONE place — the video's
// own click handler below — rather than a window-level gesture listener: a
// window listener fires on pointerdown, which runs before React's click handler,
// so by then mutedAttempt was already false and the click toggled playback off
// instead of unmuting. One owner, one ordering.
useImperativeHandle(
ref,
() => ({
seek(seconds) {
// Live playlists only retain a short window, so seeking far back lands
// outside the buffer and stalls. Clamp to what is actually available.
const video = videoRef.current
if (!video) return
const target = Math.max(0, seconds)
if (live && video.buffered.length) {
const start = video.buffered.start(0)
if (target < start) {
video.currentTime = start
return
}
}
video.currentTime = target
},
async play() {
await videoRef.current?.play()
},
pause() {
videoRef.current?.pause()
},
getVideo() {
return videoRef.current
},
}),
[],
)
// 基础事件监听
useEffect(() => {
const video = videoRef.current
if (!video) return
@@ -95,81 +203,211 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
const onWaiting = () => setLoading(true)
const onPlaying = () => setLoading(false)
const onPlaying = () => {
setLoading(false)
setNeedsGesture(false)
setMuted(video.muted)
}
const onCanPlay = () => {
setLoading(false)
setReady(true)
// Data is in and the element still is not playing: autoplay either lost the
// race against the previous source or was refused, so prompt the viewer.
if (video.paused) setNeedsGesture(true)
}
const onVolumeChange = () => {
setVolume(video.volume)
setMuted(video.muted)
if (!video.muted) setMutedAttempt(false)
}
const onResize = () => measureSource()
const onErrorEvent = () => {
setLoading(false)
const error = video.error
let msg = '视频播放出错'
if (error) {
switch (error.code) {
case 1:
msg = '视频加载中止'
break
case 2:
msg = '网络错误'
break
case 3:
msg = '视频解码失败'
break
case 4:
msg = '视频格式不支持或未找到'
break
default:
msg = `未知错误 (${error.code})`
}
if (error.message) msg += `: ${error.message}`
}
onError?.(msg)
}
video.addEventListener('timeupdate', update)
video.addEventListener('durationchange', update)
video.addEventListener('progress', update)
video.addEventListener('play', onPlay)
video.addEventListener('pause', onPause)
video.addEventListener('volumechange', onVolumeChange)
video.addEventListener('waiting', onWaiting)
video.addEventListener('playing', onPlaying)
video.addEventListener('canplay', onCanPlay)
video.addEventListener('resize', onResize)
video.addEventListener('loadedmetadata', onResize)
video.addEventListener('error', onErrorEvent)
return () => {
video.removeEventListener('timeupdate', update)
video.removeEventListener('durationchange', update)
video.removeEventListener('progress', update)
video.removeEventListener('play', onPlay)
video.removeEventListener('pause', onPause)
video.removeEventListener('volumechange', onVolumeChange)
video.removeEventListener('waiting', onWaiting)
video.removeEventListener('playing', onPlaying)
video.removeEventListener('canplay', onCanPlay)
video.removeEventListener('resize', onResize)
video.removeEventListener('loadedmetadata', onResize)
video.removeEventListener('error', onErrorEvent)
}
}, [onError, measureSource])
// 全屏监听
useEffect(() => {
const listener = () => {
setFullscreen(document.fullscreenElement === rootRef.current)
setControlsVisible(true)
}
document.addEventListener('fullscreenchange', listener)
// Older WebKit (iOS Safari) reports video fullscreen through its own event.
const video = videoRef.current
const onWebkitBegin = () => setFullscreen(true)
const onWebkitEnd = () => setFullscreen(false)
video?.addEventListener('webkitbeginfullscreen', onWebkitBegin)
video?.addEventListener('webkitendfullscreen', onWebkitEnd)
return () => {
document.removeEventListener('fullscreenchange', listener)
video?.removeEventListener('webkitbeginfullscreen', onWebkitBegin)
video?.removeEventListener('webkitendfullscreen', onWebkitEnd)
}
}, [])
useEffect(() => {
const listener = () => setFullscreen(document.fullscreenElement === rootRef.current)
document.addEventListener('fullscreenchange', listener)
return () => document.removeEventListener('fullscreenchange', listener)
// Fade the controls out after a few idle seconds, but never while paused —
// a paused player with no controls looks broken.
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current)
hideTimerRef.current = window.setTimeout(() => {
if (!videoRef.current?.paused) setControlsVisible(false)
}, 3000)
}, [])
const revealControls = useCallback(() => {
setControlsVisible(true)
scheduleHide()
}, [scheduleHide])
useEffect(() => {
if (!playing) setControlsVisible(true)
return () => {
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current)
}
}, [playing])
// 切换源并自动播放
useEffect(() => {
const video = videoRef.current
if (!video) return
const controller = new AbortController()
let disposed = false
hlsRef.current?.destroy()
hlsRef.current = null
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = undefined
}
video.removeAttribute('src')
video.load()
setLoading(Boolean(live || replayUrl))
// A new source has to prove itself playable again before autoplay is judged,
// and the old source's measurements must not be attributed to it.
setReady(false)
setMutedAttempt(false)
setSourceMeta('')
sourceMetaRef.current = { isSource: false }
sourceBitrateRef.current = 0
onError?.('')
if (!live && replayUrl) {
void fetch(replayUrl, { signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.blob()
})
.then(blob => {
if (disposed) return
const objectUrl = URL.createObjectURL(blob)
objectUrlRef.current = objectUrl
video.src = objectUrl
video.load()
})
.catch(error => {
if (error instanceof DOMException && error.name === 'AbortError') return
setLoading(false)
onError?.('回放拉取失败,请检查远端地址及其 CORS 配置')
})
video.src = replayUrl
video.load()
void attemptAutoplay(video)
} else if (live) {
const line = lines.find(item => item.node_id === activeLine)
const line = lines.find((item) => item.node_id === activeLine)
// "自动" resolves to the rendition the owner's ladder picked. Each quality URL
// is a single-rendition media playlist, so this label is the only thing
// selecting a rendition — falling through to line.url would request a master
// playlist that the transcoder never writes (the line's own url is bare
// /hls/<key>/index.m3u8, which only MediaMTX itself could have served).
const label = activeQuality === 'auto' ? autoQuality : activeQuality
const quality = line?.qualities?.find(item => item.label === label)
const source = quality?.url || line?.url || line?.unicast_fallback
const quality = line?.qualities?.find((item) => item.label === label)
const source = quality?.url || line?.qualities?.[0]?.url || line?.url || line?.unicast_fallback
// The source rendition is the one advertised without dimensions, so its
// metadata is measured from the media rather than read off the API.
const sourceLabel = qualities.find((item) => !item.width || !item.height)?.label
// Every quality URL points at a single-rendition playlist, so no level list
// exists to name a level from the manifest; the selection decides it.
sourceMetaRef.current = { isSource: Boolean(sourceLabel) && label === sourceLabel }
const onFallbackLoaded = () => {
setLoading(false)
sourceBitrateRef.current = 0
measureSource()
}
if (!source) {
setLoading(false)
onError?.('当前没有可用的播放地址')
} else if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: true, lowLatencyMode: false })
// Buffering is the thing to avoid; chase latency only after that. hls.js
// measures latency to its own target rather than to the segment edge, so
// anything set here (liveSyncDurationCount, rate) has to be interpreted in
// its terms or the player and the UI disagree about what "behind" means.
//
// Deliberately NOT enabled, all for the same reason — each pulls the target
// latency down to a single 4s segment, so playback races the encoder and
// re-buffers at the end of every segment:
// lowLatencyMode: true target drops to the last *partial* segment
// liveSyncDurationCount: 1 target = exactly one segment
// maxLiveSyncPlaybackRate speeds playback up to 1.5x to hold that
// backBufferLength: 0 nothing behind the playhead to survive a stall
// The default three-segment (~12s) cushion is what keeps playback smooth.
const hls = new Hls({
enableWorker: true,
// Keeps recent segments available locally, so a brief stall is served
// from the buffer instead of going back to the network.
backBufferLength: 30,
})
hlsRef.current = hls
hls.loadSource(source)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => setLoading(false))
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setLoading(false)
// For live, anchors playback to the end of the sliding window so a new
// viewer starts at the live edge instead of the oldest retained segment.
hls.startLoad(live ? -1 : 0)
void attemptAutoplay(video)
})
// A single-rendition playlist has exactly one level and no name, so the
// selection made above is what decides whether this is the source tier.
// Only refresh the measured bitrate/size here; do not re-answer that.
hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
sourceBitrateRef.current = hls.levels?.[data.level]?.bitrate || 0
measureSource()
})
// The API is the authority on which rendition a label is; a manifest with no
// level names cannot override it. This only covers a fallback playlist whose
// rendition list we never received.
hls.on(Hls.Events.LEVEL_LOADED, (_event, data) => {
if (data.details.live && !qualities.length) onFallbackLoaded()
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setLoading(false)
@@ -179,6 +417,13 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.load()
void video
.play()
.then(onFallbackLoaded)
.catch(() => {
if (video.readyState >= 3) onFallbackLoaded()
else setNeedsGesture(true)
})
} else {
setLoading(false)
onError?.('当前浏览器不支持 HLS 播放')
@@ -186,22 +431,32 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
}
return () => {
disposed = true
controller.abort()
hlsRef.current?.destroy()
hlsRef.current = null
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = undefined
}
}
}, [activeLine, activeQuality, autoQuality, lines, live, onError, replayUrl])
}, [activeLine, activeQuality, autoQuality, lines, live, onError, replayUrl, attemptAutoplay, measureSource])
const togglePlayback = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play().catch(() => onError?.('浏览器阻止了自动播放,请再次点击播放'))
else video.pause()
if (video.paused) {
setNeedsGesture(false)
void video.play().catch(() => setNeedsGesture(true))
return
}
// Live cannot be paused: there is no seekable timeline behind the playhead, so
// pausing just parks the viewer further and further behind a stream that keeps
// running. Resuming is still offered (autoplay may have been blocked), but
// nothing here ever stops a live stream.
if (!live) video.pause()
}
const toggleMute = () => {
const video = videoRef.current
if (!video) return
video.muted = !video.muted
if (!video.muted && video.volume === 0) video.volume = 1
}
const seek = (value: number) => {
@@ -214,65 +469,231 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
if (!video) return
video.volume = value
video.muted = value === 0
if (value > 0) setMutedAttempt(false)
setVolume(value)
}
// 原画 是发布方原始流:分辨率与码率只有正在播放它时才测得到,所以先按"原始画质"占位。
const qualityOptions = qualities.map((quality) => {
const meta =
quality.width && quality.height
? `${quality.label} · ${quality.width}×${quality.height}`
: [quality.label, quality.label === activeQuality ? sourceMeta : ''].filter(Boolean).join(' · ')
return { value: quality.label, label: meta === quality.label ? `${quality.label} · 原始画质` : meta }
})
const autoLabel = qualityOptions.find((option) => option.value === autoQuality)?.label || autoQuality
const toggleFullscreen = async () => {
if (!rootRef.current) return
if (document.fullscreenElement) await document.exitFullscreen()
else await rootRef.current.requestFullscreen()
// Prefer the Fullscreen API on the player wrapper. It gives the true
// edge-to-edge experience and, unlike F11, also works on mobile browsers and
// when the page is embedded in an iframe (where F11's browser chrome is
// unavailable anyway). F11 remains available as the user's own shortcut.
const root = rootRef.current
if (!root) return
try {
if (document.fullscreenElement) {
await document.exitFullscreen()
} else if (typeof root.requestFullscreen === 'function') {
await root.requestFullscreen()
} else {
// iOS Safari has no element Fullscreen API; fall back to native video FS.
const video = videoRef.current as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null
video?.webkitEnterFullscreen?.()
}
} catch {
// Denied (e.g. permissions policy) — leave the current view as-is.
}
}
return (
<div ref={rootRef} className="custom-player">
<video ref={videoRef} playsInline preload="metadata" onClick={togglePlayback} />
{loading && <div className="custom-player-loading" aria-label="媒体加载中"><span /></div>}
<div className="custom-player-controls">
<div className="custom-player-timeline">
<div className="custom-player-buffer" style={{ width: `${duration ? Math.min(100, buffered / duration * 100) : 0}%` }} />
<input
aria-label="播放进度"
type="range"
min={0}
max={duration || 0}
step="0.1"
value={Math.min(currentTime, duration || 0)}
onChange={event => seek(Number(event.target.value))}
/>
{duration > 0 && chapters.map((chapter, index) => (
<button
className="custom-player-chapter-mark"
key={`${chapter.time}-${index}`}
style={{ left: `${Math.min(100, chapter.time / duration * 100)}%` }}
title={chapter.title}
type="button"
onClick={() => seek(chapter.time)}
/>
))}
<div
ref={rootRef}
onMouseMove={revealControls}
onMouseLeave={() => {
if (!videoRef.current?.paused) setControlsVisible(false)
}}
onTouchStart={revealControls}
className="group relative aspect-video w-full overflow-hidden rounded-lg bg-black [[:fullscreen]_&]:aspect-auto [[:fullscreen]_&]:h-screen [[:fullscreen]_&]:rounded-none [&:fullscreen]:aspect-auto [&:fullscreen]:h-screen [&:fullscreen]:w-screen [&:fullscreen]:rounded-none"
>
<video
ref={videoRef}
playsInline
preload="metadata"
// While the player is muted only because autoplay demanded it, a click on
// the picture means "give me the sound", not "pause". Handling it here
// rather than with a window-level listener keeps the two in a known order:
// the restore happens first, then this returns without toggling playback.
onClick={() => (mutedAttempt ? unmute() : togglePlayback())}
className="h-full w-full"
/>
{/* Persistent live badge — the stream state should be readable without
hovering, since "is this live?" is the first question a viewer asks. */}
{live && (
<div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2">
<span className="flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-xs font-semibold text-white backdrop-blur">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
LIVE
</span>
</div>
<div className="custom-player-row">
<button type="button" onClick={togglePlayback} aria-label={playing ? '暂停' : '播放'}>
{playing ? <PauseOutlined /> : <PlayCircleOutlined />}
)}
{loading && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</div>
)}
{/* Autoplay was refused: nothing plays until the viewer clicks, so this has
to be louder than the spinner. Sits under the control bar (z-10 vs z-20)
to keep the quality/line pickers reachable while it is up. */}
{needsGesture && ready && (
<button
type="button"
onClick={resolveGate}
className="absolute inset-0 z-10 flex cursor-pointer flex-col items-center justify-center gap-4 bg-black/70 text-white backdrop-blur-[2px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
>
<span className="flex h-20 w-20 animate-pulse items-center justify-center rounded-full border-2 border-white/60 bg-white/15">
<Play className="h-9 w-9 translate-x-0.5 fill-current" />
</span>
<span className="text-xl font-semibold">{playing ? '已静音播放' : '点击播放'}</span>
<span className="max-w-xs text-sm text-white/70">
{mutedAttempt ? '点击开启声音' : '浏览器阻止了自动播放,请点击开始'}
</span>
</button>
)}
{/* Playing but only because we muted it: say so loudly, since a silent
stream reads as broken. Any interaction restores the sound too. */}
{mutedAttempt && !needsGesture && (
<button
type="button"
onClick={unmute}
className="absolute left-3 bottom-16 z-10 flex cursor-pointer items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs font-medium text-white backdrop-blur transition-colors hover:bg-black/85"
>
<VolumeX className="h-4 w-4" />
已静音播放,点击开启声音
</button>
)}
{/* Controls */}
<div
className={
'absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 to-transparent p-3 transition-opacity duration-200 ' +
(controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0')
}
>
{/* VOD gets a scrubber; live gets a status strip — there is no seekable
timeline in a live stream, and rendering one implies you can rewind. */}
{!live && duration > 0 && (
<div className="relative mb-2 flex h-3 items-center">
<div className="absolute h-1 w-full rounded bg-white/20" />
<div
className="absolute h-1 rounded bg-white/40"
style={{ width: `${duration ? Math.min(100, (buffered / duration) * 100) : 0}%` }}
/>
<input
aria-label="播放进度"
type="range"
min={0}
max={duration || 0}
step="0.1"
value={Math.min(currentTime, duration || 0)}
onChange={(e) => seek(Number(e.target.value))}
className="absolute h-3 w-full cursor-pointer appearance-none bg-transparent"
/>
{chapters.map((chapter, index) => (
<button
key={`${chapter.time}-${index}`}
className="absolute h-2 w-2 -translate-x-1/2 rounded-full bg-primary"
style={{ left: `${Math.min(100, (chapter.time / duration) * 100)}%` }}
title={chapter.title}
onClick={() => seek(chapter.time)}
/>
))}
</div>
)}
<div className="flex items-center gap-3 text-white">
{/* Play/pause mirrors the real state, so the icon is never a lie. A live
stream has nothing to pause, so once it is running this control has
no action left and is not rendered at all — showing either icon would
misrepresent something: a pause icon promises a pause that does not
happen, and a play icon makes a playing stream look stopped. It
reappears if playback stops, which is the one case it can still act. */}
{(!live || !playing) && (
<button type="button" onClick={togglePlayback} aria-label={playing ? '暂停' : '播放'}>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : playing ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</button>
)}
{live ? (
<span className="flex items-center gap-1.5 text-xs font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
LIVE
</span>
) : (
<span className="text-xs tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
)}
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'}>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<span className="custom-player-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
<label className="custom-player-volume">
<SoundOutlined />
<input aria-label="音量" type="range" min={0} max={1} step="0.05" value={volume} onChange={event => changeVolume(Number(event.target.value))} />
<label className="flex items-center gap-2">
<input
aria-label="音量"
type="range"
min={0}
max={1}
step="0.05"
value={muted ? 0 : volume}
onChange={(e) => changeVolume(Number(e.target.value))}
className="h-1 w-16 cursor-pointer"
/>
</label>
<span className="custom-player-spacer" />
<span className="flex-1" />
{live && lines.length > 1 && (
<select aria-label="播放线路" value={activeLine} onChange={event => onLineChange(event.target.value)}>
{lines.map(line => <option key={line.node_id} value={line.node_id}>{line.node_name}</option>)}
<select
aria-label="播放线路"
value={activeLine}
onChange={(e) => onLineChange(e.target.value)}
className="rounded border border-white/30 bg-black/60 px-2 py-1 text-xs"
>
{lines.map((line) => (
<option key={line.node_id} value={line.node_id} className="bg-zinc-900">
{line.node_name}
</option>
))}
</select>
)}
{live && qualities.length > 0 && (
<select aria-label="清晰度" value={activeQuality} onChange={event => onQualityChange(event.target.value)}>
<option value="auto">自动{autoQuality ? ` (${autoQuality})` : ''}</option>
{qualities.map(quality => <option key={quality.label} value={quality.label}>{quality.label}</option>)}
{live && qualityOptions.length > 0 && (
<select
aria-label="清晰度"
value={activeQuality}
onChange={(e) => onQualityChange(e.target.value)}
className="rounded border border-white/30 bg-black/60 px-2 py-1 text-xs"
>
<option value="auto" className="bg-zinc-900">
自动{autoQuality ? ` (${autoLabel})` : ''}
</option>
{qualityOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-zinc-900">
{option.label}
</option>
))}
</select>
)}
<button type="button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? '退出全屏' : '全屏'}>
{fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
</button>
</div>
</div>
-169
View File
@@ -1,169 +0,0 @@
.custom-player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #020408;
color: #f4f7fb;
}
.custom-player video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
}
.custom-player-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
background: rgba(2, 4, 8, 0.24);
}
.custom-player-loading span {
width: 38px;
height: 38px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #5b8cff;
border-radius: 50%;
animation: custom-player-spin 800ms linear infinite;
}
@keyframes custom-player-spin {
to { transform: rotate(360deg); }
}
.custom-player-controls {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 36px 16px 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.86));
}
.custom-player-timeline {
position: relative;
height: 16px;
display: flex;
align-items: center;
}
.custom-player-timeline::before,
.custom-player-buffer {
position: absolute;
right: 0;
left: 0;
height: 4px;
border-radius: 999px;
content: '';
}
.custom-player-timeline::before {
background: rgba(255, 255, 255, 0.22);
}
.custom-player-buffer {
right: auto;
background: rgba(255, 255, 255, 0.46);
}
.custom-player-timeline input {
position: relative;
z-index: 2;
width: 100%;
margin: 0;
accent-color: #5b8cff;
cursor: pointer;
}
.custom-player-chapter-mark {
position: absolute;
z-index: 3;
width: 8px;
height: 8px;
padding: 0;
border: 2px solid #070a10;
border-radius: 50%;
background: #f6b94a;
transform: translateX(-50%);
cursor: pointer;
}
.custom-player-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.custom-player-row button {
display: grid;
width: 36px;
height: 36px;
padding: 0;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 19px;
cursor: pointer;
}
.custom-player-row button:hover,
.custom-player-row button:focus-visible {
background: rgba(255, 255, 255, 0.12);
outline: none;
}
.custom-player-time {
min-width: 100px;
color: #e4e9f2;
font: 500 12px/1.2 Inter, sans-serif;
white-space: nowrap;
}
.custom-player-volume {
display: flex;
align-items: center;
gap: 7px;
}
.custom-player-volume input {
width: 76px;
accent-color: #5b8cff;
}
.custom-player-spacer {
flex: 1;
}
.custom-player-row select {
max-width: 150px;
height: 32px;
padding: 0 28px 0 9px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(10, 14, 22, 0.88);
color: #f4f7fb;
font-size: 12px;
}
.custom-player:fullscreen {
width: 100vw;
height: 100vh;
aspect-ratio: auto;
}
@media (max-width: 640px) {
.custom-player-controls { padding: 28px 10px 8px; }
.custom-player-volume input { display: none; }
.custom-player-time { min-width: 82px; font-size: 11px; }
.custom-player-row { gap: 4px; }
.custom-player-row select { max-width: 104px; }
}
+121 -121
View File
@@ -1,27 +1,42 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Skeleton } from 'antd'
import { SearchOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
import { mdToText } from '../components/Markdown'
import './viewer.css'
import { Search, Video } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge'
import { fetchStreams, type StreamInfo } from '@/api/streams'
import { useBrand } from '@/brand/BrandContext'
import { mdToText } from '@/viewer/components/Markdown'
import { STATUS_ORDER } from '@/lib/status'
import { cn } from '@/lib/utils'
const FILTERS = [
['all', '全部'],
['live', '直播中'],
['pending', '待开播'],
['ended', '回放'],
] as const
export default function HomePage() {
const { brand } = useBrand()
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('all')
const [filter, setFilter] = useState<string>('all')
const navigate = useNavigate()
useEffect(() => {
let active = true
let pollTimer: ReturnType<typeof setInterval> | undefined
const load = () => fetchStreams()
.then(data => { if (active) setStreams(data) })
.catch(() => {})
.finally(() => { if (active) setLoading(false) })
const load = () =>
fetchStreams()
.then((data) => {
if (active) setStreams(data)
})
.catch(() => {})
.finally(() => {
if (active) setLoading(false)
})
const startPolling = () => {
if (!pollTimer) pollTimer = setInterval(load, 10000)
}
@@ -32,7 +47,7 @@ export default function HomePage() {
void load()
const events = new EventSource('/api/streams/catalog/events')
events.addEventListener('catalog', event => {
events.addEventListener('catalog', (event) => {
if (!active) return
try {
const data = JSON.parse((event as MessageEvent<string>).data)
@@ -55,145 +70,130 @@ export default function HomePage() {
}
}, [])
const sorted = [...streams].sort((a, b) => {
const o: Record<string, number> = { live: 0, pending: 1, ended: 2, cancelled: 3 }
return (o[a.status] ?? 9) - (o[b.status] ?? 9)
})
const visibleStreams = sorted.filter(stream => {
const sorted = [...streams].sort((a, b) => (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9))
const liveCount = sorted.filter((s) => s.status === 'live').length
const visibleStreams = sorted.filter((stream) => {
const matchesFilter = filter === 'all' || stream.status === filter
const haystack = `${stream.title} ${mdToText(stream.description)}`.toLowerCase()
return matchesFilter && haystack.includes(query.trim().toLowerCase())
})
return (
<div className="viewer-shell">
<header className="viewer-header">
<div className="viewer-header-brand">
<div>
<h1>{brand.brand_name}</h1>
{brand.brand_tagline && <p>{brand.brand_tagline}</p>}
<div className="min-h-screen bg-background">
<header className="border-b border-border/60">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<div className="flex items-center gap-2.5">
<span className="h-4 w-4 rounded-[5px] bg-gradient-to-br from-primary to-amber-600 shadow-sm" />
<span className="font-display text-lg font-semibold tracking-tight">{brand.brand_name}</span>
</div>
<div className="flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs text-muted-foreground">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
{liveCount} 个直播中
</div>
</div>
<div className="viewer-live-summary">
<span className="viewer-live-dot" />
{sorted.filter(stream => stream.status === 'live').length} 个直播中
</div>
</header>
<main className="viewer-main" id="programmes">
<div className="viewer-section-heading">
<h2>
直播频道
</h2>
<span>
{sorted.length} 个节目
</span>
</div>
<div className="viewer-directory-tools">
<label className="viewer-search">
<SearchOutlined aria-hidden="true" />
<input
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="搜索节目标题或简介"
aria-label="搜索节目"
/>
</label>
<div className="viewer-filters" aria-label="按节目状态筛选" role="group">
{[
['all', '全部'],
['live', '直播中'],
['pending', '待开播'],
['ended', '回放'],
].map(([value, label]) => (
<button
key={value}
type="button"
className={`viewer-button viewer-filter${filter === value ? ' is-active' : ''}`}
aria-pressed={filter === value}
onClick={() => setFilter(value)}
>
{label}
</button>
))}
<main className="mx-auto max-w-6xl px-6 pb-12 pt-6">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-baseline gap-2.5">
<h1 className="font-display text-2xl font-semibold tracking-tight">节目</h1>
<span className="text-sm tabular-nums text-muted-foreground">{sorted.length} 个</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative sm:w-64">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索节目标题或简介"
aria-label="搜索节目"
className="h-10 bg-secondary pl-9 shadow-none"
/>
</div>
<div className="flex gap-1.5">
{FILTERS.map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setFilter(value)}
className={cn(
'rounded-full px-3 py-1.5 text-sm font-medium transition-colors',
filter === value
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground',
)}
>
{label}
</button>
))}
</div>
</div>
</div>
{loading ? (
<div className="stream-grid" aria-label="正在加载直播节目">
{[1,2,3,4,5,6].map(i => (
<div className="stream-card stream-skeleton" key={i}>
<div className="stream-cover" />
<div className="stream-copy">
<Skeleton active paragraph={{ rows: 2 }} title={{ width: '60%' }} />
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="overflow-hidden rounded-xl border border-border/60 bg-card shadow-warm">
<Skeleton className="aspect-video rounded-none" />
<div className="space-y-2 p-4">
<Skeleton className="h-4 w-3/5" />
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
</div>
) : visibleStreams.length === 0 ? (
<div className="viewer-empty">
<span className="viewer-empty-icon"><VideoCameraOutlined /></span>
<h3>暂无直播</h3>
<p>当前没有正在播出或排期的直播节目</p>
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border py-20 text-center">
<Video className="h-10 w-10 text-muted-foreground" />
<h3 className="font-display text-lg font-semibold">暂无直播</h3>
<p className="text-sm text-muted-foreground">当前没有正在播出或排期的直播节目</p>
</div>
) : (
<div className="stream-grid">
{visibleStreams.map(s => {
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{visibleStreams.map((s) => {
const isLive = s.status === 'live'
const isPending = s.status === 'pending'
return (
<article key={s.id}>
<button
type="button"
className={`stream-card${isLive ? ' is-live' : ''}`}
onClick={() => navigate(`/watch/${s.id}`)}
<button
key={s.id}
type="button"
onClick={() => navigate(`/watch/${s.id}`)}
className="group overflow-hidden rounded-xl border border-border/60 bg-card text-left shadow-warm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-warm-lg"
>
<div
className="relative flex aspect-video items-center justify-center bg-secondary"
style={s.cover_url ? { backgroundImage: `url(${s.cover_url})`, backgroundSize: 'cover', backgroundPosition: 'center' } : undefined}
>
{/* Cover */}
<div className="stream-cover" style={s.cover_url ? { backgroundImage: `url(${s.cover_url})` } : undefined}>
{!s.cover_url && (
<VideoCameraOutlined style={{ color: 'rgba(249,247,244,0.08)', fontSize: 56 }} />
)}
{/* Status badge */}
{isLive && (
<div className="stream-status live">
<span className="live-dot" />直播中
</div>
)}
{isPending && (
<div className="stream-status pending">
待开播
</div>
)}
{!isLive && !isPending && (
<div>
<span className="stream-status ended">
{s.status === 'ended' ? '已结束' : '已取消'}
</span>
</div>
)}
</div>
{/* Info */}
<div className="stream-copy">
<h3>
{s.title}
</h3>
<p>
{mdToText(s.description) || '暂无简介'}
</p>
</div>
</button>
</article>
{!s.cover_url && <Video className="h-12 w-12 text-muted-foreground/30" />}
{isLive && (
<div className="absolute left-2 top-2">
<StatusBadge status="live" className="border-transparent bg-background/80 backdrop-blur" />
</div>
)}
{isPending && (
<div className="absolute left-2 top-2">
<StatusBadge status="pending" className="border-transparent bg-background/80 backdrop-blur" />
</div>
)}
{!isLive && !isPending && (
<div className="absolute left-2 top-2">
<StatusBadge status={s.status} className="border-transparent bg-background/80 backdrop-blur" />
</div>
)}
</div>
<div className="space-y-1.5 p-4">
<h3 className="font-medium leading-snug">{s.title}</h3>
<p className="line-clamp-2 text-sm text-muted-foreground">{mdToText(s.description) || '暂无简介'}</p>
</div>
</button>
)
})}
</div>
)}
</main>
<footer className="viewer-footer">
<span>
{brand.brand_name} &copy; {new Date().getFullYear()}
</span>
<footer className="border-t border-border/60 py-6 text-center text-sm text-muted-foreground">
{brand.brand_name} &copy; {new Date().getFullYear()}
</footer>
</div>
)
+181 -99
View File
@@ -1,9 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Button, Typography, Tag, Spin, Empty, Alert } from 'antd'
import { ArrowLeftOutlined, SignalFilled } from '@ant-design/icons'
import VideoPlayer, { type VideoPlayerHandle } from '../components/VideoPlayer'
import Markdown from '../components/Markdown'
import { ArrowLeft, Radio } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import VideoPlayer, { type VideoPlayerHandle } from '@/viewer/components/VideoPlayer'
import Markdown from '@/viewer/components/Markdown'
import {
getStreamStatusUrl,
getWatchUrls,
@@ -12,11 +15,8 @@ import {
sendViewerHeartbeat,
type WatchInfo,
type LineInfo,
} from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
import './viewer.css'
const { Text } = Typography
} from '@/api/streams'
import { useBrand } from '@/brand/BrandContext'
export default function WatchPage() {
const { brand } = useBrand()
@@ -38,15 +38,26 @@ export default function WatchPage() {
const [showPlaybackStats, setShowPlaybackStats] = useState(false)
const [playbackStats, setPlaybackStats] = useState({ buffered: 0, currentTime: 0, droppedFrames: 0, resolution: '未知' })
const viewerSessionRef = useRef<string>('')
const currentLine = info?.lines.find(line => line.node_id === activeLine)
const currentLine = info?.lines.find((line) => line.node_id === activeLine)
const generateSessionId = () =>
window.crypto?.randomUUID?.().replace(/-/g, '') ?? `${Date.now()}${Math.random().toString(16).slice(2)}`
// Keyed by stream: the stored id records which programme it belongs to, so
// switching streams starts a fresh session instead of colliding with the old one.
useEffect(() => {
const storageKey = 'viewer-session-id'
const storedSessionId = window.localStorage.getItem(storageKey)
const sessionId = storedSessionId || (window.crypto?.randomUUID?.().replace(/-/g, '') ?? `${Date.now()}${Math.random().toString(16).slice(2)}`)
if (!storedSessionId) window.localStorage.setItem(storageKey, sessionId)
const streamId = Number(id)
if (!Number.isInteger(streamId) || streamId <= 0) return
const storageKey = `viewer-session:${streamId}`
const stored = window.localStorage.getItem(storageKey)
const sessionId = stored || generateSessionId()
if (!stored) window.localStorage.setItem(storageKey, sessionId)
viewerSessionRef.current = sessionId.slice(0, 64)
}, [])
// Drop the pre-fix global key: it bound every programme to whichever the viewer
// watched first, which is what made heartbeats come back 409 and polling 404.
window.localStorage.removeItem('viewer-session-id')
}, [id])
useEffect(() => {
const streamId = Number(id)
@@ -54,9 +65,10 @@ export default function WatchPage() {
const heartbeat = async () => {
const video = getVideo()
const bufferedSeconds = video && video.buffered.length
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
: 0
const bufferedSeconds =
video && video.buffered.length
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
: 0
try {
const result = await sendViewerHeartbeat(streamId, {
session_id: viewerSessionRef.current,
@@ -68,8 +80,15 @@ export default function WatchPage() {
error: playbackError || null,
})
setRemoteAdjusted(result.remote_adjusted)
} catch {
// Telemetry must never interrupt playback.
} catch (error) {
// 409 means this session id belongs to a different programme — a leftover
// from before the per-stream storage key, or from another tab. Claim a new
// one so heartbeats and command polling recover instead of failing forever.
if ((error as { response?: { status?: number } })?.response?.status === 409) {
const fresh = generateSessionId()
viewerSessionRef.current = fresh.slice(0, 64)
window.localStorage.setItem(`viewer-session:${streamId}`, fresh)
}
}
}
@@ -105,7 +124,7 @@ export default function WatchPage() {
window.clearInterval(heartbeatTimer)
window.clearInterval(commandTimer)
}
}, [id, info, activeLine, activeQuality, playbackError, currentLine?.node_name])
}, [id, info, activeLine, activeQuality, playbackError, currentLine?.node_name, getVideo])
useEffect(() => {
const streamId = Number(id)
@@ -129,12 +148,12 @@ export default function WatchPage() {
}
setInfo(next)
setLoadError('')
setActiveLine(current => {
if (current && next.lines.some(line => line.node_id === current)) return current
setActiveLine((current) => {
if (current && next.lines.some((line) => line.node_id === current)) return current
return next.lines.length > 0 ? autoSelectLine(next.lines).node_id : ''
})
setActiveQuality(current => {
if (current && next.qualities.some(quality => quality.label === current)) return current
setActiveQuality((current) => {
if (current && next.qualities.some((quality) => quality.label === current)) return current
return next.qualities[0]?.label ?? ''
})
} catch {
@@ -173,33 +192,42 @@ export default function WatchPage() {
}, [id])
function autoSelectLine(lines: LineInfo[]): LineInfo {
const enabled = lines.filter(l => l.weight > 0)
const enabled = lines.filter((l) => l.weight > 0)
if (enabled.length === 0) return lines[0]
const total = enabled.reduce((s, l) => s + l.weight, 0)
let r = Math.random() * total
for (const line of enabled) { r -= line.weight; if (r <= 0) return line }
for (const line of enabled) {
r -= line.weight
if (r <= 0) return line
}
return enabled[enabled.length - 1]
}
useEffect(() => {
if (!info?.qualities.length) return
setAutoQuality(current => current || info.qualities[0].label)
setAutoQuality((current) => current || info.qualities[0].label)
}, [info])
useEffect(() => {
const video = getVideo()
if (!video || !info || activeQuality !== 'auto') return
if (!video || !info) return
const qualityLabels = info.qualities.map(quality => quality.label)
const qualityLabels = info.qualities.map((quality) => quality.label)
// The auto ladder only applies while the viewer is on "auto". Auto-selected
// renditions stay internal state so the UI can still report "自动 (720p)"
// instead of masquerading as a manual switch.
// `info.qualities` runs from the highest tier down, source ("原画") first, so
// stepping an index up walks toward lower quality and down toward higher.
// The source carries no dimensions, so this list must not be re-sorted.
const isAuto = activeQuality === 'auto'
const lowerQuality = () => {
setAutoQuality(current => {
setAutoQuality((current) => {
const index = Math.max(0, qualityLabels.indexOf(current))
return qualityLabels[Math.min(index + 1, qualityLabels.length - 1)] || current
})
}
const higherQuality = () => {
setAutoQuality(current => {
setAutoQuality((current) => {
const index = Math.max(0, qualityLabels.indexOf(current))
return qualityLabels[Math.max(index - 1, 0)] || current
})
@@ -210,9 +238,9 @@ export default function WatchPage() {
if (bufferStartedRef.current && now - playbackStartedRef.current > 10000) {
const duration = now - bufferStartedRef.current
bufferEventsRef.current.push({ at: now, duration })
bufferEventsRef.current = bufferEventsRef.current.filter(event => now - event.at <= 10000)
bufferEventsRef.current = bufferEventsRef.current.filter((event) => now - event.at <= 10000)
const accumulatedBuffering = bufferEventsRef.current.reduce((total, event) => total + event.duration, 0)
if (accumulatedBuffering >= 2000 || bufferEventsRef.current.length >= 3) {
if (isAuto && (accumulatedBuffering >= 2000 || bufferEventsRef.current.length >= 3)) {
lowerQuality()
bufferEventsRef.current = []
}
@@ -221,11 +249,24 @@ export default function WatchPage() {
}
const onWaiting = () => {
const now = Date.now()
if (!playbackStartedRef.current || now - playbackStartedRef.current <= 10000 || bufferStartedRef.current) return
// Don't count the initial tune-in as a stall: the player sits in "waiting"
// while the live edge is still being established, which is not a network
// problem worth degrading quality over.
if (
!isAuto ||
!playbackStartedRef.current ||
video.currentTime <= 0 ||
now - playbackStartedRef.current <= 20000 ||
bufferStartedRef.current
) {
return
}
bufferStartedRef.current = now
}
const recoveryTimer = window.setInterval(() => {
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
if (video.paused || video.seeking || video.readyState < 3) return
// Only step quality back up after a genuinely clean minute of playback.
if (!bufferEventsRef.current.length) higherQuality()
}, 60000)
video.addEventListener('waiting', onWaiting)
@@ -235,7 +276,7 @@ export default function WatchPage() {
video.removeEventListener('waiting', onWaiting)
video.removeEventListener('playing', onPlaying)
}
}, [activeQuality, info])
}, [activeQuality, info, getVideo])
useEffect(() => {
const video = getVideo()
@@ -255,61 +296,76 @@ export default function WatchPage() {
updateStats()
const timer = window.setInterval(updateStats, 1000)
return () => window.clearInterval(timer)
}, [activeLine, activeQuality, autoQuality])
}, [activeLine, activeQuality, autoQuality, getVideo])
function jumpToChapter(time: number) {
playerRef.current?.seek(time)
void playerRef.current?.play().catch(() => {})
}
useEffect(() => { document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name }, [info, brand])
useEffect(() => {
document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name
}, [info, brand])
if (loading) return (
<div style={{ minHeight: '100vh', background: '#1A1814', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)
if (!info) return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#F9F7F4' }}>
<Empty description={loadError || '未找到该直播'}>
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)
}
if (!info) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-8 text-center">
<h2 className="text-lg font-medium">{loadError || '未找到该直播'}</h2>
<Button onClick={() => window.location.reload()}>重新加载</Button>
</Empty>
</div>
)
</div>
)
}
const statusLabel = info.status === 'live' ? 'LIVE' : info.status === 'pending' ? '待开播' : info.status === 'ended' ? '已结束' : info.status
const statusLabel =
info.status === 'live' ? 'LIVE' : info.status === 'pending' ? '待开播' : info.status === 'ended' ? '已结束' : info.status
return (
<div className="watch-shell">
<header className="watch-header">
<div className="watch-heading">
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#949088' }}
onClick={() => navigate('/')}>{brand.brand_name}</Button>
<span className="watch-divider" />
<Text className="watch-title">{info.title}</Text>
<Tag color={info.status === 'live' ? 'green' : 'default'} style={{ fontWeight: 600, fontSize: 11 }}>
{statusLabel}
</Tag>
</div>
<div className="watch-options">
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
<Tag style={{ margin: 0 }}>清晰度:{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality || '不可用'}</Tag>
{remoteAdjusted && <Tag color="gold" style={{ margin: 0 }}>播放由管理员临时调整</Tag>}
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
{info.multicast_available ? '组播' : '单播'}
</Text>
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-card">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-3 px-6 py-3">
<Button variant="ghost" size="sm" onClick={() => navigate('/')}>
<ArrowLeft className="h-4 w-4" />
{brand.brand_name}
</Button>
<span className="text-muted-foreground">/</span>
<span className="font-medium">{info.title}</span>
<Badge variant={info.status === 'live' ? 'default' : 'secondary'}>{statusLabel}</Badge>
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline">节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Badge>
<Badge variant="outline">
清晰度:{activeQuality === 'auto' ? `自动${autoQuality ? `(${autoQuality})` : ''}` : activeQuality || '不可用'}
</Badge>
{remoteAdjusted && <Badge className="bg-amber-600">播放由管理员临时调整</Badge>}
<span className="flex items-center gap-1.5 text-muted-foreground">
<Radio className={`h-3.5 w-3.5 ${info.multicast_available ? 'text-emerald-500' : ''}`} />
{info.multicast_available ? '组播' : '单播'}
</span>
</div>
</div>
</header>
<main className="watch-stage">
<div className="watch-content">
<div className="watch-frame">
<div className="watch-player" onContextMenu={event => { event.preventDefault(); setShowPlaybackStats(value => !value) }}>
<main className="mx-auto max-w-6xl px-6 py-6">
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<div>
<div
className="overflow-hidden rounded-lg border border-border bg-black"
onContextMenu={(e) => {
e.preventDefault()
setShowPlaybackStats((v) => !v)
}}
>
<VideoPlayer
ref={playerRef}
live={info.status === 'live'}
replayUrl={info.status !== 'live' ? (info.replay_url || info.replay_local_url) : undefined}
replayUrl={info.status !== 'live' ? info.replay_url || info.replay_local_url : undefined}
lines={info.lines}
qualities={info.qualities}
activeLine={activeLine}
@@ -321,44 +377,70 @@ export default function WatchPage() {
onError={setPlaybackError}
/>
{showPlaybackStats && (
<section className="playback-stats" aria-label="播放统计信息">
<div><span>播放状态</span><strong>{getVideo()?.paused ? '暂停' : '播放中'}</strong></div>
<div><span>线路</span><strong>{currentLine?.node_name || '不可用'}</strong></div>
<div><span>清晰度</span><strong>{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality}</strong></div>
<div><span>分辨率</span><strong>{playbackStats.resolution}</strong></div>
<div><span>时间码</span><strong>{playbackStats.currentTime.toFixed(1)} 秒</strong></div>
<div><span>缓冲</span><strong>{playbackStats.buffered.toFixed(1)} 秒</strong></div>
<div><span>丢帧</span><strong>{playbackStats.droppedFrames}</strong></div>
<div className="playback-stats-url"><span>播放地址</span><strong>{getVideo()?.currentSrc || '加载中'}</strong></div>
<section className="grid grid-cols-2 gap-x-6 gap-y-1 border-t border-border p-4 text-sm sm:grid-cols-4">
{[
// Live is never paused (the player refuses to), so reporting a
// paused state there would only ever describe a stall.
['播放状态', getVideo()?.paused ? (info.status === 'live' ? '缓冲中' : '暂停') : '播放中'],
['线路', currentLine?.node_name || '不可用'],
['清晰度', activeQuality === 'auto' ? `自动${autoQuality ? `(${autoQuality})` : ''}` : activeQuality],
['分辨率', playbackStats.resolution],
['时间码', `${playbackStats.currentTime.toFixed(1)} 秒`],
['缓冲', `${playbackStats.buffered.toFixed(1)} 秒`],
['丢帧', String(playbackStats.droppedFrames)],
['播放地址', getVideo()?.currentSrc || '加载中'],
].map(([label, value]) => (
<div key={label}>
<span className="text-muted-foreground">{label}</span>
<div className="truncate font-medium">{value}</div>
</div>
))}
</section>
)}
</div>
{playbackError && (
<div className="watch-waiting">
<Alert type="error" showIcon message={playbackError} />
<div className="mt-3">
<Alert variant="destructive">
<AlertTitle>播放出错</AlertTitle>
<AlertDescription>{playbackError}</AlertDescription>
</Alert>
</div>
)}
{!playbackError && info.status !== 'live' && !info.replay_url && (
<div className="watch-waiting">
{info.status === 'pending' && <Spin size="large" />}
<Text>{info.status === 'ended' ? '节目已结束,暂无回放' : '等待主播开播...'}</Text>
{!playbackError && info.status !== 'live' && !info.replay_url && !info.replay_local_url && (
<div className="mt-3 rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
{info.status === 'ended' ? '节目已结束,暂无回放' : '等待主播开播...'}
</div>
)}
{!playbackError && info.status === 'live' && info.lines.length === 0 && (
<div className="watch-waiting"><Text>直播中,暂无可用播放线路</Text></div>
<div className="mt-3 rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
直播中,暂无可用播放线路
</div>
)}
</div>
{(info.description || info.chapters.length > 0) && (
<aside className="watch-details">
{info.description && <Markdown content={info.description} />}
<aside className="space-y-6">
{info.description && (
<div className="rounded-lg border border-border bg-card p-4">
<Markdown content={info.description} />
</div>
)}
{info.chapters.length > 0 && (
<section>
<h2>节目章节</h2>
<div className="chapter-list">
<section className="rounded-lg border border-border bg-card p-4">
<h2 className="mb-3 font-semibold">节目章节</h2>
<div className="space-y-2">
{info.chapters.map((chapter, index) => (
<button className="viewer-button chapter-button" key={`${chapter.time}-${index}`} type="button" onClick={() => jumpToChapter(chapter.time)}>
<button
key={`${chapter.time}-${index}`}
type="button"
onClick={() => jumpToChapter(chapter.time)}
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent"
>
<span>{chapter.title}</span>
<time>{Math.floor(chapter.time / 60)}:{String(chapter.time % 60).padStart(2, '0')}</time>
<time className="tabular-nums text-muted-foreground">
{Math.floor(chapter.time / 60)}:{String(Math.round(chapter.time % 60)).padStart(2, '0')}
</time>
</button>
))}
</div>
-648
View File
@@ -1,648 +0,0 @@
.viewer-shell {
color: #F4F7FB;
background: radial-gradient(circle at 80% 0%, rgba(91,140,255,.16), transparent 32rem), #070A10;
min-height: 100vh;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
background: #f4f0e8;
color: #211d18;
font-family: "Noto Serif SC", "Songti SC", SimSun, Georgia, serif;
}
.viewer-button {
appearance: none;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
font: 600 13px/1 Inter, "Noto Sans SC", sans-serif;
transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, color 160ms ease, transform 160ms ease;
}
.viewer-button:hover { transform: translateY(-1px); }
.viewer-button:active { transform: translateY(0); }
.viewer-button:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(118, 95, 69, 0.2); }
.viewer-button:disabled { cursor: not-allowed; opacity: 0.48; transform: none; }
.viewer-button--ghost { padding: 9px 14px; border-color: #d2cabf; background: rgba(255, 255, 255, 0.48); color: #403a33; }
.viewer-button--ghost:hover { border-color: #aa9f91; background: #fffdf9; color: #211d18; }
.viewer-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 72px;
color: #6f695f;
font-family: Inter, "Noto Sans SC", sans-serif;
font-size: 13px;
}
.viewer-nav span { color: #292720; font-weight: 650; }
.viewer-nav a { color: inherit; text-decoration: none; }
.viewer-nav a:hover { color: #292720; }
.viewer-directory-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 30px;
}
.viewer-search {
display: flex;
align-items: center;
width: min(420px, 100%);
gap: 10px;
padding: 11px 13px;
border: 1px solid #d8d2c8;
border-radius: 8px;
background: rgba(255, 255, 255, 0.58);
color: #767066;
box-shadow: 0 1px 2px rgba(33, 29, 24, 0.04), 0 8px 24px rgba(33, 29, 24, 0.04);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.viewer-search:focus-within { border-color: #aaa296; box-shadow: 0 0 0 3px rgba(117, 108, 94, 0.08); }
.viewer-search input { appearance: none; width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: #292720; font: 14px/1.4 Inter, "Noto Sans SC", sans-serif; }
.viewer-search input::placeholder { color: #9d978d; }
.viewer-filters { display: flex; flex-wrap: wrap; gap: 6px; }
.viewer-filter {
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: #777168;
cursor: pointer;
font: 13px/1 Inter, "Noto Sans SC", sans-serif;
}
.viewer-filter:hover { border-color: #d8d2c8; color: #292720; background: rgba(255, 255, 255, 0.72); }
.viewer-filter.is-active { border-color: #292720; background: #292720; color: #fffdf9; box-shadow: 0 5px 14px rgba(33, 29, 24, 0.16); }
.stream-card { border-color: #d9d3c9; box-shadow: none; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; }
.stream-card:hover { transform: translateY(-2px); border-color: #bcb4a8; }
.stream-copy h3 { font-size: clamp(17px, 1.6vw, 21px); }
.viewer-hero {
max-width: 1180px;
margin: 0 auto;
padding: 88px 40px 72px;
border-bottom: 1px solid #d7cec0;
}
.viewer-eyebrow {
margin-bottom: 18px;
color: #806d56;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.22em;
}
.viewer-logo {
display: block;
max-width: 180px;
height: 36px;
margin-bottom: 24px;
object-fit: contain;
object-position: left center;
}
.viewer-title {
max-width: 850px;
margin: 0;
color: #211d18;
font-size: clamp(42px, 7vw, 76px);
font-weight: 600;
line-height: 1.08;
letter-spacing: -0.035em;
}
.viewer-tagline {
max-width: 680px;
margin: 22px 0 0;
color: #685f54;
font-size: clamp(16px, 2vw, 20px);
line-height: 1.75;
}
.viewer-main {
max-width: 1180px;
margin: 0 auto;
padding: 52px 40px 96px;
}
.viewer-section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
margin-bottom: 32px;
}
.viewer-section-heading h2 {
margin: 0;
font-size: 25px;
font-weight: 600;
letter-spacing: -0.02em;
}
.viewer-section-heading span {
color: #84796c;
font-size: 13px;
}
.stream-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 32px 26px;
}
.stream-card {
appearance: none;
display: block;
width: 100%;
overflow: hidden;
border: 1px solid #dcd3c7;
border-radius: 14px;
background: #fffdf9;
color: inherit;
font: inherit;
text-align: left;
}
button.stream-card {
padding: 0;
cursor: pointer;
transition: border-color 140ms ease, transform 140ms ease;
}
button.stream-card:hover {
border-color: #a99882;
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(40, 34, 27, 0.09);
}
button.stream-card:active {
transform: translateY(0);
}
button.stream-card:focus-visible {
outline: 2px solid #765f45;
outline-offset: 3px;
}
.stream-cover {
position: relative;
display: flex;
aspect-ratio: 16 / 9;
align-items: center;
justify-content: center;
background-color: #29251f;
background-position: center;
background-size: cover;
}
.stream-status {
position: absolute;
top: 14px;
left: 14px;
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 4px 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
font-family: system-ui, sans-serif;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.06em;
}
.viewer-empty {
display: grid;
min-height: 300px;
place-items: center;
align-content: center;
padding: 56px 24px;
border: 1px dashed #cec5b8;
border-radius: 16px;
background: rgba(255, 253, 249, 0.72);
text-align: center;
}
.viewer-empty-icon { display: grid; width: 58px; height: 58px; margin-bottom: 18px; place-items: center; border-radius: 18px; background: #ebe4da; color: #766d62; font-size: 25px; }
.viewer-empty h3 { color: #3f3932; font-size: 19px; font-weight: 650; }
.viewer-empty p { margin-top: 6px; color: #84796c; font: 14px/1.6 Inter, "Noto Sans SC", sans-serif; }
.stream-status.live {
background: #8f302b;
color: #fff;
}
.stream-status.pending {
background: #27231e;
color: #f8f3ea;
}
.stream-status.ended {
top: auto;
bottom: 14px;
background: rgba(30, 27, 23, 0.82);
color: #e7dfd3;
}
.live-dot {
width: 6px;
height: 6px;
margin-right: 7px;
border-radius: 50%;
background: currentColor;
}
.stream-copy {
min-height: 112px;
padding: 20px 22px 22px;
}
.stream-copy h3 {
overflow: hidden;
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.stream-copy p {
overflow: hidden;
margin: 0;
color: #766d62;
font-size: 14px;
line-height: 1.6;
text-overflow: ellipsis;
white-space: nowrap;
}
.stream-skeleton {
pointer-events: none;
}
.viewer-footer {
padding: 26px 40px;
border-top: 1px solid #d7cec0;
color: #84796c;
font-size: 12px;
text-align: center;
}
.watch-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #171512;
color: #eee7dc;
font-family: "Noto Serif SC", "Songti SC", SimSun, Georgia, serif;
overflow-x: hidden;
}
.watch-header {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 10px 28px;
border-bottom: 1px solid #342f29;
background: #1d1a16;
}
.watch-heading,
.watch-options {
display: flex;
align-items: center;
gap: 16px;
}
.watch-divider {
width: 1px;
height: 18px;
background: rgba(235, 229, 219, 0.1);
}
.watch-title {
color: #ebe5db;
font-size: 14px;
font-weight: 500;
}
.watch-quality { width: 100px; }
.watch-line { width: 150px; }
.watch-stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 36px;
}
.watch-content {
width: 100%;
max-width: 1280px;
}
.watch-frame {
position: relative;
width: 100%;
max-width: 1280px;
max-height: calc(100vh - 136px);
overflow: hidden;
aspect-ratio: 16 / 9;
border: 1px solid #3d3730;
border-radius: 8px;
background: #080706;
}
.watch-player {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
}
.playback-stats {
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
width: min(360px, calc(100% - 32px));
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 10px;
background: rgba(18, 16, 13, 0.94);
color: #eee7dc;
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(10px);
}
.playback-stats div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
padding: 4px 0;
font-size: 12px;
}
.playback-stats span { color: #a99f92; }
.playback-stats strong { min-width: 0; overflow-wrap: anywhere; font-weight: 500; }
.playback-stats-url { border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 6px; padding-top: 10px !important; }
.watch-video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.watch-waiting {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: rgba(10, 10, 8, 0.92);
}
.watch-waiting .ant-typography { color: #949088; font-size: 17px; }
.watch-details {
margin-top: 24px;
padding: 24px;
border: 1px solid #3d3730;
border-radius: 8px;
background: #1d1a16;
}
.watch-details p { margin: 0 0 20px; color: #b7aea2; line-height: 1.7; }
.watch-details h2 { margin: 0 0 12px; font-size: 16px; }
.chapter-list { display: grid; gap: 8px; }
.chapter-list button {
display: flex;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: 1px solid #3d3730;
border-radius: 6px;
background: #24201b;
color: #eee7dc;
cursor: pointer;
text-align: left;
}
.chapter-button { width: 100%; border-radius: 8px; }
.chapter-list button:hover { border-color: #9d8263; background: #302920; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16); }
.chapter-list button:focus-visible { border-color: #b99a76; box-shadow: 0 0 0 3px rgba(185, 154, 118, 0.22); }
.chapter-list time { color: #949088; font-variant-numeric: tabular-nums; }
@media (max-width: 720px) {
.watch-header {
align-items: flex-start;
flex-direction: column;
padding: 14px 16px;
}
.watch-heading,
.watch-options {
width: 100%;
flex-wrap: wrap;
gap: 10px;
}
.watch-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.watch-options .ant-tag,
.watch-options .ant-typography { display: none; }
.watch-quality,
.watch-line { flex: 1; width: auto; min-width: 120px; }
.watch-stage {
align-items: flex-start;
padding: 20px 0;
}
.watch-frame {
border-right: 0;
border-left: 0;
border-radius: 0;
}
.watch-details {
margin: 16px;
padding: 18px;
}
}
@media (max-width: 900px) {
.stream-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.watch-shell,
.watch-stage,
.watch-content,
.watch-frame,
.watch-video {
width: 100%;
max-width: 100vw;
min-width: 0;
}
.watch-header {
box-sizing: border-box;
overflow: hidden;
}
.watch-options {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.watch-quality,
.watch-line {
width: 100% !important;
min-width: 0 !important;
}
.viewer-nav { margin-bottom: 48px; }
.viewer-directory-tools { align-items: stretch; flex-direction: column; }
.viewer-search { width: auto; }
.viewer-filters { max-width: 100%; overflow-x: auto; flex-wrap: nowrap; padding-bottom: 6px; scrollbar-width: none; }
.viewer-filters::-webkit-scrollbar { display: none; }
.viewer-filters button { flex: none; }
.viewer-hero {
padding: 58px 20px 48px;
}
.viewer-main {
padding: 38px 20px 64px;
}
.viewer-title {
font-size: clamp(38px, 13vw, 54px);
}
.stream-grid {
grid-template-columns: 1fr;
gap: 24px;
}
.viewer-section-heading {
margin-bottom: 24px;
}
.viewer-footer {
padding-inline: 20px;
}
}
@media (max-width: 360px) {
.viewer-hero, .viewer-main { padding-inline: 16px; }
.viewer-nav { gap: 12px; }
.viewer-nav span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.watch-header { padding-inline: 12px; }
.watch-quality, .watch-line { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.viewer-shell *,
.viewer-shell *::before,
.viewer-shell *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
/* Unified dark viewer system and compact homepage header */
.viewer-shell {
min-height: 100vh;
color: #f4f7fb;
background: #070a10;
}
.viewer-header {
max-width: 1440px;
margin: 0 auto;
min-height: 76px;
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
border-bottom: 1px solid #1d2636;
}
.viewer-header-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.viewer-header-logo { width: 42px; height: 42px; border-radius: 12px; object-fit: cover; }
.viewer-header h1 { margin: 0; color: #f4f7fb; font-size: 20px; line-height: 1.2; }
.viewer-header p { margin: 3px 0 0; color: #748096; font-size: 13px; }
.viewer-live-summary { display: flex; align-items: center; gap: 8px; color: #aab4c5; font-size: 13px; white-space: nowrap; }
.viewer-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #ff647c; box-shadow: 0 0 0 5px rgba(255,100,124,.12); }
.viewer-main { max-width: 1440px; margin: 0 auto; padding: 24px 28px 48px; }
.viewer-section-heading { margin-bottom: 18px; }
.viewer-section-heading h2 { color: #f4f7fb; }
.viewer-section-heading span { color: #748096; }
.viewer-directory-tools { margin-bottom: 22px; }
.viewer-search { background: #10151f; border-color: #273044; color: #748096; box-shadow: none; }
.viewer-search:focus-within { border-color: #5b8cff; box-shadow: 0 0 0 3px rgba(91,140,255,.14); }
.viewer-search input { color: #f4f7fb; }
.viewer-search input::placeholder { color: #748096; }
.viewer-filter { color: #aab4c5; }
.viewer-filter:hover { color: #f4f7fb; border-color: #273044; background: #171d29; }
.viewer-filter.is-active { color: #fff; border-color: #5b8cff; background: #5b8cff; }
.stream-card, .stream-card button { background: #10151f; color: #f4f7fb; border-color: #273044; }
.stream-card:hover { border-color: #3b4a66; box-shadow: 0 18px 44px rgba(0,0,0,.28); }
.stream-copy h3 { color: #f4f7fb; }
.stream-copy p { color: #748096; }
.viewer-footer { color: #748096; border-color: #1d2636; }
@media (max-width: 640px) {
.viewer-header { min-height: 68px; padding: 12px 16px; }
.viewer-header p { display: none; }
.viewer-main { padding: 18px 16px 36px; }
.viewer-directory-tools { align-items: stretch; }
}
/* Final dark-theme normalization for player surfaces */
.watch-shell, .watch-page { background: #070a10; color: #f4f7fb; }
.watch-header { background: #10151f; border-color: #273044; }
.watch-title, .watch-details h2 { color: #f4f7fb; }
.watch-main, .watch-stage { background: #070a10; }
.watch-player { background: #020408; border-color: #273044; box-shadow: 0 20px 60px rgba(0,0,0,.35); }
.playback-stats { background: rgba(10,14,22,.94); color: #f4f7fb; border-color: #273044; }
.playback-stats span, .watch-waiting .ant-typography, .chapter-list time { color: #748096; }
.watch-waiting { background: rgba(7,10,16,.94); }
.watch-details { background: #10151f; border-color: #273044; border-radius: 16px; }
.watch-details p { color: #aab4c5; }
.chapter-list button { background: #171d29; color: #f4f7fb; border-color: #273044; border-radius: 10px; }
.chapter-list button:hover { background: #1d2636; border-color: #5b8cff; box-shadow: 0 8px 24px rgba(0,0,0,.24); }
.chapter-list button:focus-visible { border-color: #5b8cff; box-shadow: 0 0 0 3px rgba(91,140,255,.18); }
+74
View File
@@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
}
+4 -1
View File
@@ -15,7 +15,10 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+32 -4
View File
@@ -1,17 +1,45 @@
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// Local dev proxy target. Defaults to the production backend port (8080).
// Override with BACKEND_TARGET when the loopback 8080 is occupied (e.g. by
// an unrelated process) — e.g. BACKEND_TARGET=http://127.0.0.1:8081.
const backendTarget = process.env.BACKEND_TARGET || 'http://127.0.0.1:8080'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
allowedHosts: ['nbd.rainplay.cn', '.rainplay.cn'],
allowedHosts: ['nbd.rainplay.cn', '.rainplay.cn', '.server2.y-dev.tech', 'live.server2.y-dev.tech', 'us.dl.y-dev.tech'],
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
target: backendTarget,
changeOrigin: true,
},
'/hls': {
target: 'http://127.0.0.1:8080',
target: backendTarget,
changeOrigin: true,
// Default "same-origin" strips the Referer. /hls/<key>/<label>/… carries the
// quality label, which is Chinese by default ("原画"), and a stripped Referer
// makes some proxies drop the rate-limited request. Not needed for the other
// routes, which only carry numeric ids.
referrerPolicy: 'origin-when-cross-origin',
},
'/docs': {
target: backendTarget,
changeOrigin: true,
},
'/openapi.json': {
target: backendTarget,
changeOrigin: true,
},
'/redoc': {
target: backendTarget,
changeOrigin: true,
},
},
+7 -67
View File
@@ -1,18 +1,13 @@
param(
[switch]$NoFrontend,
[switch]$NoMediaMTX,
[switch]$InstallDeps,
[string]$HostAddress = "0.0.0.0",
[int]$BackendPort = 8080,
[int]$FrontendPort = 5173
[int]$BackendPort = 8080
)
$ErrorActionPreference = "Stop"
$Base = Split-Path -Parent $MyInvocation.MyCommand.Path
$BackendDir = Join-Path $Base "backend"
$FrontendDir = Join-Path $Base "frontend"
$MediaDir = Join-Path $Base "mediamtx"
$RuntimeDir = Join-Path $Base "runtime"
$HlsDir = Join-Path $RuntimeDir "hls"
$ReplayDir = Join-Path $RuntimeDir "replay"
@@ -37,75 +32,20 @@ function Resolve-Python {
throw "Python not found. Install Python 3.11+ or create backend\.venv."
}
function Assert-Command($Name, $Hint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "$Name not found. $Hint"
}
}
$Python = Resolve-Python
Assert-Command "npm" "Install Node.js LTS and make npm available in PATH."
Assert-Command "ffmpeg" "Install FFmpeg and make ffmpeg.exe available in PATH."
if (-not $NoMediaMTX) {
Assert-Command "mediamtx" "Install MediaMTX and make mediamtx.exe available in PATH, or run with -NoMediaMTX."
}
if ($InstallDeps) {
Write-Host "[StreamCore] Installing backend dependencies..."
Push-Location $BackendDir
& $Python -m pip install -r requirements.txt
Pop-Location
if (-not $NoFrontend) {
Write-Host "[StreamCore] Installing frontend dependencies..."
Push-Location $FrontendDir
npm install
Pop-Location
}
}
$processes = @()
if (-not $NoMediaMTX) {
Write-Host "[StreamCore] Starting MediaMTX origin on RTMP :1935 and HLS :8888..."
$processes += Start-Process -FilePath "mediamtx" -ArgumentList "mediamtx.yml" -WorkingDirectory $MediaDir -PassThru -RedirectStandardOutput (Join-Path $LogDir "mediamtx.out.log") -RedirectStandardError (Join-Path $LogDir "mediamtx.err.log")
}
# MediaMTX, the transcoders and the HLS tree are managed by the backend itself:
# it starts the media origin at boot, supervises ffmpeg, and restarts either if it
# dies. This launcher only has to bring up the process that owns all of that.
Write-Host "[StreamCore] Starting backend on ${HostAddress}:${BackendPort} ..."
Write-Host "[StreamCore] MediaMTX, transcoders and HLS are supervised by the backend."
$backendArgs = @("-m", "uvicorn", "main:app", "--host", $HostAddress, "--port", "$BackendPort")
$processes += Start-Process -FilePath $Python -ArgumentList $backendArgs -WorkingDirectory $BackendDir -PassThru -RedirectStandardOutput (Join-Path $LogDir "backend.out.log") -RedirectStandardError (Join-Path $LogDir "backend.err.log")
if (-not $NoFrontend) {
Write-Host "[StreamCore] Starting frontend on ${HostAddress}:${FrontendPort} ..."
$frontendArgs = @("run", "dev", "--", "--host", $HostAddress, "--port", "$FrontendPort")
$processes += Start-Process -FilePath "npm" -ArgumentList $frontendArgs -WorkingDirectory $FrontendDir -PassThru -RedirectStandardOutput (Join-Path $LogDir "frontend.out.log") -RedirectStandardError (Join-Path $LogDir "frontend.err.log")
}
Start-Sleep -Seconds 3
Write-Host ""
Write-Host "[StreamCore] Windows deployment started."
Write-Host " Backend: http://127.0.0.1:$BackendPort/docs"
if (-not $NoFrontend) { Write-Host " Frontend: http://127.0.0.1:$FrontendPort/" }
if (-not $NoMediaMTX) {
Write-Host " RTMP: rtmp://127.0.0.1:1935/live/<stream_key>"
Write-Host " HLS: http://127.0.0.1:8888/<stream_key>/index.m3u8"
}
Write-Host " Runtime: $RuntimeDir"
Write-Host " HLS dir: $env:LS_HLS_RUNTIME_DIR"
Write-Host " Logs: $LogDir"
Write-Host ""
Write-Host "Press Ctrl+C to stop child processes."
try {
while ($true) {
foreach ($proc in $processes) {
if ($proc.HasExited) {
throw "Process $($proc.Id) exited with code $($proc.ExitCode). Check logs in $LogDir."
}
}
Start-Sleep -Seconds 2
}
} finally {
Write-Host "[StreamCore] Stopping child processes..."
foreach ($proc in $processes) {
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }
}
}
& $Python @backendArgs
Executable → Regular
+16 -35
View File
@@ -1,44 +1,25 @@
#!/bin/bash
# StreamCore - Start all services
# StreamCore - Start the backend.
#
# MediaMTX, the transcoders and the HLS tree are managed by the backend itself:
# it starts the media origin at boot, supervises ffmpeg, and restarts either if it
# dies. This script only has to bring up the process that owns all of that.
set -e
BASE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
echo "[StreamCore] Starting services..."
export LS_HLS_RUNTIME_DIR="${LS_HLS_RUNTIME_DIR:-$BASE/runtime/hls}"
export LS_REPLAY_MEDIA_DIR="${LS_REPLAY_MEDIA_DIR:-$BASE/runtime/replay}"
mkdir -p "$LS_HLS_RUNTIME_DIR" "$LS_REPLAY_MEDIA_DIR" "$BASE/runtime/logs"
# MediaMTX origin
cd "$BASE/mediamtx"
if pgrep -f "mediamtx mediamtx.yml" > /dev/null 2>/dev/null; then
echo "[StreamCore] MediaMTX already running."
else
nohup ./mediamtx mediamtx.yml > /tmp/mediamtx.log 2>&1 &
sleep 2
echo "[StreamCore] MediaMTX started (RTMP :1935, HLS :8888, API :9997)."
fi
HOST="${LS_HOST:-0.0.0.0}"
PORT="${LS_PORT:-8080}"
# Backend API
cd "$BASE/backend"
if curl -sf http://127.0.0.1:8080/api/health > /dev/null 2>&1; then
echo "[StreamCore] Backend already running."
else
(exec ./venv/bin/python run.py &) </dev/null &>/tmp/backend.log &
disown
sleep 3
echo "[StreamCore] Backend started on :8080."
fi
PY="$BASE/backend/venv/bin/python"
[ -x "$PY" ] || PY="$BASE/backend/.venv/bin/python"
[ -x "$PY" ] || PY="python"
# Frontend
cd "$BASE/frontend"
if curl -sf http://127.0.0.1:5173 > /dev/null 2>&1; then
echo "[StreamCore] Frontend already running."
else
(exec npm run dev &) </dev/null &>/tmp/frontend.log &
disown
sleep 3
echo "[StreamCore] Frontend started on :5173."
fi
echo "[StreamCore] All services up."
echo " Public: http://nbd.rainplay.cn:13001/"
echo " Admin: http://nbd.rainplay.cn:13001/admin/login"
echo " RTMP: rtmp://nbd.rainplay.cn:13001/live/{key}"
echo "[StreamCore] Starting backend on ${HOST}:${PORT}..."
echo "[StreamCore] MediaMTX, transcoders and HLS are supervised by the backend."
exec "$PY" -m uvicorn main:app --host "$HOST" --port "$PORT"