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:
1 parent
186e32caf1
commit
996e3fea39
70 files changed
+4844
-3070
No files matched your search
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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); }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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} © {new Date().getFullYear()}
|
||||
</span>
|
||||
<footer className="border-t border-border/60 py-6 text-center text-sm text-muted-foreground">
|
||||
{brand.brand_name} © {new Date().getFullYear()}
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
@@ -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')],
|
||||
}
|
||||
@@ -15,7 +15,10 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+32
-4
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user