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

+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,
},
},