From 4ad266f6c45aa777394a702b79ef8a3dcd4d7896 Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 12 May 2026 23:19:37 +0800 Subject: [PATCH] =?UTF-8?q?-=20tauri=20=E8=AE=BE=E8=AE=A1=E5=AE=8C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 7 +- package.json | 9 +- src-tauri/Cargo.lock | 227 +++++ src-tauri/Cargo.toml | 20 +- src-tauri/python参考/db.json | 1 - src-tauri/python参考/main.py | 352 -------- src-tauri/src/events.rs | 29 + src-tauri/src/lib.rs | 18 +- src-tauri/src/models.rs | 161 ++++ src-tauri/src/server.rs | 415 +++++++++ src-tauri/src/storage.rs | 197 ++++ src-tauri/tauri.conf.json | 6 +- src/App.css | 116 --- src/App.jsx | 59 +- src/assets/react.svg | 1 - src/pages/Admin.jsx | 485 ++++++++++ src/pages/Screen.jsx | 402 ++++++++ src/styles/admin.css | 949 +++++++++++++++++++ src/styles/screen.css | 431 +++++++++ src/styles/tokens.css | 8 + src/templates/admin.html | 1659 ---------------------------------- src/templates/screen.html | 830 ----------------- src/utils/api.js | 109 +++ vite.config.js | 18 +- yarn.lock | 25 + 25 files changed, 3495 insertions(+), 3039 deletions(-) delete mode 100644 src-tauri/python参考/db.json delete mode 100644 src-tauri/python参考/main.py create mode 100644 src-tauri/src/events.rs create mode 100644 src-tauri/src/models.rs create mode 100644 src-tauri/src/server.rs create mode 100644 src-tauri/src/storage.rs delete mode 100644 src/App.css delete mode 100644 src/assets/react.svg create mode 100644 src/pages/Admin.jsx create mode 100644 src/pages/Screen.jsx create mode 100644 src/styles/admin.css create mode 100644 src/styles/screen.css create mode 100644 src/styles/tokens.css delete mode 100644 src/templates/admin.html delete mode 100644 src/templates/screen.html create mode 100644 src/utils/api.js diff --git a/index.html b/index.html index eb282ae..60a8fe3 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,13 @@ - + - Tauri + React + 大屏媒体轮播系统 + + + diff --git a/package.json b/package.json index 3f12d2f..341f6d2 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,15 @@ "tauri": "tauri" }, "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", "react-dom": "^19.1.0", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" + "react-router-dom": "^7.15.0" }, "devDependencies": { + "@tauri-apps/cli": "^2", "@vitejs/plugin-react": "^4.6.0", - "vite": "^7.0.4", - "@tauri-apps/cli": "^2" + "vite": "^7.0.4" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 380a0db..0747d68 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -213,6 +213,71 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "base64" version = "0.21.7" @@ -460,8 +525,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -797,11 +864,21 @@ dependencies = [ name = "dpm" version = "0.1.0" dependencies = [ + "axum", + "chrono", + "dirs", + "futures", + "once_cell", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", + "tokio", + "tokio-stream", + "tower", + "tower-http", + "uuid", ] [[package]] @@ -866,6 +943,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1036,6 +1122,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1043,6 +1144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1110,6 +1212,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1501,12 +1604,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -1520,6 +1635,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1976,6 +2092,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -1997,6 +2119,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2039,6 +2171,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2796,6 +2945,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2957,6 +3112,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2986,6 +3152,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.20.0" @@ -3158,6 +3336,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3695,11 +3879,37 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3846,6 +4056,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3856,10 +4067,19 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags 2.11.1", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -3884,6 +4104,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4001,6 +4222,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d03c52c..bff4b95 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,16 +1,11 @@ [package] name = "dpm" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] +description = "大屏媒体轮播系统 - Tauri App" +authors = ["Pine"] edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [lib] -# The `_lib` suffix may seem redundant but it is necessary -# to make the lib name unique and wouldn't conflict with the bin name. -# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 name = "dpm_lib" crate-type = ["staticlib", "cdylib", "rlib"] @@ -22,4 +17,13 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" - +tokio = { version = "1", features = ["full"] } +axum = { version = "0.8", features = ["macros", "multipart"] } +tower-http = { version = "0.6", features = ["cors", "fs"] } +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +tower = "0.5" +once_cell = "1" +dirs = "6" +futures = "0.3" +tokio-stream = { version = "0.1", features = ["sync"] } diff --git a/src-tauri/python参考/db.json b/src-tauri/python参考/db.json deleted file mode 100644 index 45a167e..0000000 --- a/src-tauri/python参考/db.json +++ /dev/null @@ -1 +0,0 @@ -{"settings": {"1": {"username": "admin", "password": "123456", "volume": 60, "auto_play": true, "play_mode": "sequential", "image_duration": 8, "control_action": "play"}}, "playlist": {"1": {"path": "\u540d\u7247.png"}, "2": {"path": "02.mp4"}}, "url_media": {"1": {"url": "https://video-qn.ibaotu.com/20/08/52/29f888piCpMR.mp4", "name": "29f888piCpMR.mp4", "type": "video", "added_at": "2026-05-12T20:36:28.512177"}}} \ No newline at end of file diff --git a/src-tauri/python参考/main.py b/src-tauri/python参考/main.py deleted file mode 100644 index a4d17a2..0000000 --- a/src-tauri/python参考/main.py +++ /dev/null @@ -1,352 +0,0 @@ -from pathlib import Path -import asyncio, json -from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request -from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse -from fastapi.middleware.cors import CORSMiddleware -from tinydb import TinyDB, Query -from datetime import datetime -from typing import Any, Dict, List - -app = FastAPI(title="大屏媒体轮播系统") - -# ===================== 模板加载 ===================== -TEMPLATE_DIR = Path(__file__).parent / "templates" - -def _load_template(name: str) -> str: - return (TEMPLATE_DIR / name).read_text(encoding="utf-8") - -SCREEN_HTML = _load_template("screen.html") -ADMIN_HTML = _load_template("admin.html") - -# ===================== SSE 事件管理器 ===================== -class EventManager: - def __init__(self): - self._queues: list[asyncio.Queue] = [] - - def subscribe(self, q: asyncio.Queue): - self._queues.append(q) - - def unsubscribe(self, q: asyncio.Queue): - try: - self._queues.remove(q) - except ValueError: - pass - - def broadcast(self, event: dict): - data = json.dumps(event) - dead = [] - for q in self._queues: - try: - q.put_nowait(data) - except asyncio.QueueFull: - dead.append(q) - for q in dead: - self.unsubscribe(q) - -events = EventManager() - -# ===================== 播放状态(内存) ===================== -playback_state: dict = { - "status": "idle", # playing | paused | idle - "index": 0, - "name": "", - "type": "", -} - -# CORS -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# ===================== 配置 ===================== -MEDIA_DIR = Path.home() / "Downloads" / "Media" -MEDIA_DIR.mkdir(exist_ok=True, parents=True) -DB = TinyDB("db.json") -FILES_TABLE = DB.table("files") -PLAYLIST_TABLE = DB.table("playlist") -SETTINGS_TABLE = DB.table("settings") -URL_MEDIA_TABLE = DB.table("url_media") -User = Query() -FileQuery = Query() -UrlMediaQuery = Query() - -ALLOWED_EXTENSIONS = {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"} - -# ===================== 初始化 ===================== -DEFAULT_SETTINGS = { - "username": "admin", - "password": "123456", - "volume": 80, - "auto_play": True, - "play_mode": "sequential", - "image_duration": 5, -} - -if not SETTINGS_TABLE.search(User.username == "admin"): - SETTINGS_TABLE.insert(DEFAULT_SETTINGS) -else: - # 补齐缺失的默认字段 - current = SETTINGS_TABLE.get(User.username == "admin") - updated = False - for key, val in DEFAULT_SETTINGS.items(): - if key not in current: - current[key] = val - updated = True - if updated: - SETTINGS_TABLE.update(current, User.username == "admin") - -# ===================== 前端页面 ===================== -@app.get("/", response_class=HTMLResponse) -async def screen_page(): - return SCREEN_HTML - -@app.get("/admin", response_class=HTMLResponse) -async def admin_page(): - return ADMIN_HTML - -# ===================== 文件访问 ===================== -from fastapi.responses import FileResponse -@app.get("/file/{path:path}") -async def get_file(path: str): - target = MEDIA_DIR / path - if not target.exists() or not target.is_file(): - raise HTTPException(404) - return FileResponse(target) - -# ===================== API ===================== -@app.post("/api/login") -async def login(username: str = Form(...), password: str = Form(...)): - admin = SETTINGS_TABLE.get(User.username == "admin") - if admin and admin["password"] == password: - return {"success": True} - return {"success": False} - -@app.get("/api/settings") -async def get_settings(): - s = SETTINGS_TABLE.get(User.username == "admin") - return {k: s[k] for k in DEFAULT_SETTINGS if k in s} - -@app.post("/api/settings") -async def update_settings(data: dict): - """统一设置更新,支持 volume/play_mode/image_duration""" - allowed = {"volume", "play_mode", "image_duration"} - update = {k: v for k, v in data.items() if k in allowed} - if update: - SETTINGS_TABLE.update(update, User.username == "admin") - events.broadcast({"action": "settings_changed", **update}) - return {"ok": True} - -@app.get("/media") -async def get_media_files(): - files = [] - # 本地文件 - for p in MEDIA_DIR.rglob("*"): - if not p.is_file(): continue - ext = p.suffix.lower() - if ext not in ALLOWED_EXTENSIONS: continue - rel = str(p.relative_to(MEDIA_DIR)) - t = "video" if ext in [".mp4",".mkv",".avi"] else "image" - files.append({ - "name": p.name, - "relative_path": rel, - "type": t, - "url": f"/file/{rel}", - "source": "local", - }) - # URL 媒体(持久化在 url_media 表中) - for item in URL_MEDIA_TABLE.all(): - files.append({ - "name": item["name"], - "relative_path": item["url"], - "type": item["type"], - "url": item["url"], - "source": "url", - }) - return {"files": files} - -@app.post("/upload") -async def upload(file: UploadFile = File(...)): - ext = Path(file.filename).suffix.lower() - if ext not in ALLOWED_EXTENSIONS: - raise HTTPException(400) - ts = datetime.now().strftime("%Y%m%d%H%M%S") - name = f"{Path(file.filename).stem}_{ts}{ext}" - path = MEDIA_DIR / name - with open(path, "wb") as f: - f.write(await file.read()) - return {"ok": True} - -@app.post("/api/delete") -async def delete_file(data: dict): - path = data["path"] - if path.startswith(("http://", "https://")): - # 删除 URL 媒体及其播放列表引用 - URL_MEDIA_TABLE.remove(UrlMediaQuery.url == path) - else: - p = MEDIA_DIR / path - if p.exists() and p.is_file(): - p.unlink() - PLAYLIST_TABLE.remove(FileQuery.path == path) - events.broadcast({"action": "playlist_changed"}) - return {"ok": True} - -@app.get("/api/playlist") -async def get_playlist(): - items = PLAYLIST_TABLE.all() - admin = SETTINGS_TABLE.get(User.username == "admin") - files = [] - for item in items: - if item.get("source") == "url": - ext = Path(item["path"]).suffix.lower() - files.append({ - "name": item.get("name", item["path"]), - "relative_path": item["path"], - "type": "video" if ext in [".mp4",".mkv",".avi"] else "image", - "source": "url", - }) - else: - p = MEDIA_DIR / item["path"] - if p.exists(): - ext = p.suffix.lower() - files.append({ - "name": p.name, - "relative_path": item["path"], - "type": "video" if ext in [".mp4",".mkv",".avi"] else "image", - "source": "local", - }) - return { - "files": files, - "volume": admin["volume"], - "play_mode": admin.get("play_mode", "sequential"), - "image_duration": admin.get("image_duration", 5), - } - -@app.post("/api/playlist/add") -async def add_playlist(data: dict): - path = data["path"] - if not PLAYLIST_TABLE.contains(FileQuery.path == path): - item: dict = {"path": path} - if path.startswith(("http://", "https://")): - item["source"] = "url" - # 从 URL 媒体库中获取名称 - um = URL_MEDIA_TABLE.get(UrlMediaQuery.url == path) - if um: - item["name"] = um["name"] - PLAYLIST_TABLE.insert(item) - events.broadcast({"action": "playlist_changed"}) - return {"ok": True} - -@app.post("/api/playlist/remove") -async def remove_playlist(data: dict): - PLAYLIST_TABLE.remove(FileQuery.path == data["path"]) - events.broadcast({"action": "playlist_changed"}) - return {"ok": True} - -@app.post("/api/media/add-url") -async def add_url_media(data: dict): - """添加 URL 到媒体库(持久化),不自动加入播放列表""" - url = data.get("url", "").strip() - if not url: - raise HTTPException(400, "url 不能为空") - if URL_MEDIA_TABLE.contains(UrlMediaQuery.url == url): - return {"ok": True, "duplicate": True} - ext = Path(url.split("?")[0]).suffix.lower() - name = data.get("name", "").strip() or url.rsplit("/", 1)[-1].split("?")[0] - URL_MEDIA_TABLE.insert({ - "url": url, - "name": name, - "type": "video" if ext in [".mp4",".mkv",".avi"] else "image", - "added_at": datetime.now().isoformat(), - }) - return {"ok": True} - - -@app.get("/api/files") -async def list_files_with_url(): - """输出媒体文件夹中所有文件的 URL""" - files = [] - for p in sorted(MEDIA_DIR.rglob("*")): - if not p.is_file(): continue - ext = p.suffix.lower() - if ext not in ALLOWED_EXTENSIONS: continue - rel = str(p.relative_to(MEDIA_DIR)) - t = "video" if ext in [".mp4",".mkv",".avi"] else "image" - files.append({ - "name": p.name, - "type": t, - "url": f"/file/{rel}", - }) - return {"files": files} - - -@app.get("/api/events") -async def sse_events(request: Request): - """SSE 端点,向后端推送实时播放控制指令""" - q: asyncio.Queue = asyncio.Queue(maxsize=32) - events.subscribe(q) - async def generate(): - try: - while True: - if await request.is_disconnected(): - break - try: - data = await asyncio.wait_for(q.get(), timeout=15) - yield f"data: {data}\n\n" - except asyncio.TimeoutError: - yield ": keepalive\n\n" - finally: - events.unsubscribe(q) - return StreamingResponse(generate(), media_type="text/event-stream") - - -@app.post("/api/control") -async def playback_control(data: dict): - """后端控制播放:{action: 'play'|'pause'|'next'|'prev'}""" - action = data.get("action", "") - if action not in ("play", "pause", "next", "prev"): - raise HTTPException(400, "action 必须是 play/pause/next/prev") - SETTINGS_TABLE.update({"control_action": action}, User.username == "admin") - events.broadcast({"action": action}) - # 更新本地状态(乐观) - if action == "play": - playback_state["status"] = "playing" - elif action == "pause": - playback_state["status"] = "paused" - events.broadcast({"action": "state_update", "state": playback_state}) - return {"ok": True, "action": action} - - -@app.get("/api/state") -async def get_state(): - """获取当前播放状态""" - return {"state": playback_state} - - -@app.post("/api/state") -async def update_state(data: dict): - """前端上报播放状态变更""" - state = data.get("state", {}) - if "status" in state: - playback_state["status"] = state["status"] - if "index" in state: - playback_state["index"] = state["index"] - if "name" in state: - playback_state["name"] = state["name"] - if "type" in state: - playback_state["type"] = state["type"] - events.broadcast({"action": "state_update", "state": playback_state}) - return {"ok": True} - - -if __name__ == "__main__": - import uvicorn - uvicorn.run( - "main:app", - host="0.0.0.0", - port=8000, - reload=True - ) \ No newline at end of file diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs new file mode 100644 index 0000000..15a95d3 --- /dev/null +++ b/src-tauri/src/events.rs @@ -0,0 +1,29 @@ +use crate::models::ServerEvent; +use once_cell::sync::Lazy; +use tokio::sync::broadcast; + +/// SSE 事件管理器 +pub struct EventManager { + tx: broadcast::Sender, +} + +impl EventManager { + pub fn new() -> Self { + let (tx, _) = broadcast::channel(64); + Self { tx } + } + + /// 广播事件给所有 SSE 客户端 + pub fn broadcast(&self, event: &ServerEvent) { + if let Ok(json) = serde_json::to_string(event) { + let _ = self.tx.send(json); + } + } + + /// 获取广播接收器 + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +pub static EVENTS: Lazy = Lazy::new(EventManager::new); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..70c007e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,20 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +mod events; +mod models; +mod server; +mod storage; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // 在后台线程启动 HTTP 服务器 + std::thread::spawn(|| { + let rt = tokio::runtime::Runtime::new().expect("无法创建 tokio runtime"); + rt.block_on(async { + server::start().await; + }); + }); + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..08d4757 --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; + +// ============ Settings ============ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub username: String, + pub password: String, + pub volume: u32, + pub auto_play: bool, + pub play_mode: String, + pub image_duration: u32, +} + +impl Default for Settings { + fn default() -> Self { + Self { + username: "admin".into(), + password: "123456".into(), + volume: 80, + auto_play: true, + play_mode: "sequential".into(), + image_duration: 5, + } + } +} + +// ============ Playlist Item ============ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaylistItem { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +// ============ URL Media Item ============ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UrlMediaItem { + pub url: String, + pub name: String, + #[serde(rename = "type")] + pub media_type: String, + pub added_at: String, +} + +// ============ Media File (response) ============ +#[derive(Debug, Clone, Serialize)] +pub struct MediaFile { + pub name: String, + pub relative_path: String, + #[serde(rename = "type")] + pub file_type: String, + pub url: String, + pub source: String, +} + +// ============ Playback State ============ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaybackState { + pub status: String, + pub index: usize, + pub name: String, + #[serde(rename = "type")] + pub media_type: String, +} + +impl Default for PlaybackState { + fn default() -> Self { + Self { + status: "idle".into(), + index: 0, + name: String::new(), + media_type: String::new(), + } + } +} + +// ============ Events ============ +#[derive(Debug, Clone, Serialize)] +pub struct ServerEvent { + pub action: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub play_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_duration: Option, +} + +// ============ Playlist Response ============ +#[derive(Debug, Clone, Serialize)] +pub struct PlaylistResponse { + pub files: Vec, + pub volume: u32, + pub play_mode: String, + pub image_duration: u32, +} + +// ============ API Request Bodies ============ +#[derive(Debug, Deserialize)] +pub struct LoginForm { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub success: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SettingsUpdate { + pub volume: Option, + pub play_mode: Option, + pub image_duration: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PathBody { + pub path: String, +} + +#[derive(Debug, Deserialize)] +pub struct UrlBody { + pub url: String, +} + +#[derive(Debug, Deserialize)] +pub struct ActionBody { + pub action: String, +} + +#[derive(Debug, Deserialize)] +pub struct StateBody { + pub state: PlaybackState, +} + +#[derive(Debug, Serialize)] +pub struct OkResponse { + pub ok: bool, +} + +#[derive(Debug, Serialize)] +pub struct OkDuplicateResponse { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub duplicate: Option, +} + +#[derive(Debug, Serialize)] +pub struct FilesResponse { + pub files: Vec, +} + +#[derive(Debug, Serialize)] +pub struct StateResponse { + pub state: PlaybackState, +} diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs new file mode 100644 index 0000000..d6500e0 --- /dev/null +++ b/src-tauri/src/server.rs @@ -0,0 +1,415 @@ +use crate::events::EVENTS; +use crate::models::*; +use crate::storage::{get_media_type, is_allowed_extension, STORAGE}; +use axum::{ + extract::{Form, Multipart, State}, + http::StatusCode, + response::sse::{Event, KeepAlive, Sse}, + response::Json, + routing::{get, post}, + Router, +}; +use chrono::Local; +use futures::stream::Stream; +use std::convert::Infallible; +use std::path::PathBuf; +use std::sync::Arc; +use std::fs; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::StreamExt; +use tower_http::cors::CorsLayer; +use tower_http::services::{ServeDir, ServeFile}; + +// ===================== 媒体目录 ===================== +fn media_dir() -> PathBuf { + let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push("Downloads"); + path.push("Media"); + fs::create_dir_all(&path).ok(); + path +} + +fn media_dir_str() -> String { + media_dir().to_string_lossy().to_string() +} + +#[derive(Clone)] +pub struct AppState { + pub playback: Arc>, +} + +impl AppState { + pub fn new() -> Self { + Self { + playback: Arc::new(tokio::sync::Mutex::new(PlaybackState::default())), + } + } +} + +// ===================== 路由构建 ===================== +pub fn create_router(state: AppState) -> Router { + Router::new() + .route("/api/login", post(login_handler)) + .route("/api/settings", get(get_settings).post(update_settings)) + .route("/media", get(list_media)) + .route("/upload", post(upload_handler)) + .route("/api/delete", post(delete_handler)) + .route("/api/media/add-url", post(add_url_handler)) + .route("/api/playlist", get(get_playlist)) + .route("/api/playlist/add", post(add_playlist)) + .route("/api/playlist/remove", post(remove_playlist)) + .route("/api/control", post(control_handler)) + .route("/api/state", get(get_state).post(update_state)) + .route("/api/events", get(sse_handler)) + .nest_service("/file", ServeDir::new(media_dir_str())) + .fallback_service( + ServeDir::new("../dist") + .append_index_html_on_directories(true) + .fallback(ServeFile::new("../dist/index.html")), + ) + .layer(CorsLayer::permissive()) + .with_state(state) +} + +pub async fn start() { + let state = AppState::new(); + let app = create_router(state); + + println!("🚀 大屏媒体轮播系统启动于 http://0.0.0.0:8000"); + println!(" - 管理后台: http://localhost:8000/admin"); + println!(" - 大屏展示: http://localhost:8000/screen"); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:8000") + .await + .expect("无法绑定 0.0.0.0:8000,请检查端口是否被占用"); + axum::serve(listener, app) + .await + .expect("服务器启动失败"); +} + +// ===================== Handler: 登录 ===================== +async fn login_handler( + Form(form): Form, +) -> Json { + let s = STORAGE.get_settings(); + Json(LoginResponse { + success: form.username == s.username && form.password == s.password, + }) +} + +// ===================== Handler: 设置 ===================== +async fn get_settings() -> Json { + let s = STORAGE.get_settings(); + Json(serde_json::json!({ + "volume": s.volume, + "play_mode": s.play_mode, + "image_duration": s.image_duration, + })) +} + +async fn update_settings( + Json(body): Json, +) -> Json { + STORAGE.update_settings(SettingsUpdate { + volume: body.volume, + play_mode: body.play_mode.clone(), + image_duration: body.image_duration, + }); + + EVENTS.broadcast(&ServerEvent { + action: "settings_changed".into(), + state: None, + volume: body.volume, + play_mode: body.play_mode, + image_duration: body.image_duration, + }); + + Json(serde_json::json!({"ok": true})) +} + +// ===================== Handler: 媒体列表 ===================== +async fn list_media() -> Json { + let mut files = Vec::new(); + let dir = media_dir(); + + // 本地文件 + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + if !is_allowed_extension(&name) { + continue; + } + let rel = name.clone(); + let t = get_media_type(&name).to_string(); + files.push(MediaFile { + name, + relative_path: rel.clone(), + file_type: t, + url: format!("/file/{}", rel), + source: "local".into(), + }); + } + } + + // URL 媒体 + for item in STORAGE.get_url_media() { + files.push(MediaFile { + name: item.name, + relative_path: item.url.clone(), + file_type: item.media_type, + url: item.url, + source: "url".into(), + }); + } + + Json(FilesResponse { files }) +} + +// ===================== Handler: 上传 ===================== +async fn upload_handler( + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + while let Ok(Some(field)) = multipart.next_field().await { + let file_name = field.file_name().unwrap_or("file").to_string(); + let ext = std::path::Path::new(&file_name) + .extension() + .and_then(|e| e.to_str()) + .map(|e| format!(".{}", e.to_lowercase())) + .unwrap_or_default(); + + if !is_allowed_extension(&ext) { + return Err((StatusCode::BAD_REQUEST, "不支持的文件类型".into())); + } + + let ts = Local::now().format("%Y%m%d%H%M%S"); + let stem = std::path::Path::new(&file_name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + let new_name = format!("{}_{}{}", stem, ts, ext); + let save_path = media_dir().join(&new_name); + + let data = field.bytes().await.map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + fs::write(&save_path, &data).map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + } + Ok(Json(serde_json::json!({"ok": true}))) +} + +// ===================== Handler: 删除 ===================== +async fn delete_handler( + Json(body): Json, +) -> Json { + let path = body.path; + if !path.starts_with("http://") && !path.starts_with("https://") { + let file_path = media_dir().join(&path); + if file_path.exists() && file_path.is_file() { + fs::remove_file(&file_path).ok(); + } + } + STORAGE.delete_media(&path); + + EVENTS.broadcast(&ServerEvent { + action: "playlist_changed".into(), + state: None, + volume: None, + play_mode: None, + image_duration: None, + }); + + Json(OkResponse { ok: true }) +} + +// ===================== Handler: 添加 URL ===================== +async fn add_url_handler( + Json(body): Json, +) -> Json { + let url = body.url.trim().to_string(); + if url.is_empty() { + return Json(OkDuplicateResponse { ok: false, duplicate: None }); + } + + let name = url.rsplit('/').next().unwrap_or(&url) + .split('?').next().unwrap_or(&url) + .to_string(); + let t = get_media_type(&url).to_string(); + let is_new = STORAGE.add_url_media(&url, name, t); + + Json(OkDuplicateResponse { ok: true, duplicate: Some(!is_new) }) +} + +// ===================== Handler: 播放列表 ===================== +async fn get_playlist() -> Json { + let items = STORAGE.get_playlist(); + let settings = STORAGE.get_settings(); + let dir = media_dir(); + let mut files = Vec::new(); + + for item in &items { + if item.source.as_deref() == Some("url") { + let ext = std::path::Path::new(&item.path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let t = if ["mp4", "mkv", "avi"].contains(&ext) { "video" } else { "image" }; + files.push(MediaFile { + name: item.name.clone().unwrap_or_else(|| item.path.clone()), + relative_path: item.path.clone(), + file_type: t.to_string(), + url: item.path.clone(), + source: "url".into(), + }); + } else { + let file_path = dir.join(&item.path); + if file_path.exists() { + let ext = file_path.extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let t = if ["mp4", "mkv", "avi"].contains(&ext) { "video" } else { "image" }; + files.push(MediaFile { + name: file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&item.path) + .to_string(), + relative_path: item.path.clone(), + file_type: t.to_string(), + url: format!("/file/{}", item.path), + source: "local".into(), + }); + } + } + } + + Json(PlaylistResponse { + files, + volume: settings.volume, + play_mode: settings.play_mode, + image_duration: settings.image_duration, + }) +} + +async fn add_playlist( + Json(body): Json, +) -> Json { + STORAGE.add_to_playlist(&body.path); + EVENTS.broadcast(&ServerEvent { + action: "playlist_changed".into(), + state: None, + volume: None, + play_mode: None, + image_duration: None, + }); + Json(OkResponse { ok: true }) +} + +async fn remove_playlist( + Json(body): Json, +) -> Json { + STORAGE.remove_from_playlist(&body.path); + EVENTS.broadcast(&ServerEvent { + action: "playlist_changed".into(), + state: None, + volume: None, + play_mode: None, + image_duration: None, + }); + Json(OkResponse { ok: true }) +} + +// ===================== Handler: 播放控制 ===================== +async fn control_handler( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, String)> { + if !["play", "pause", "next", "prev"].contains(&body.action.as_str()) { + return Err((StatusCode::BAD_REQUEST, "action 必须是 play/pause/next/prev".into())); + } + + EVENTS.broadcast(&ServerEvent { + action: body.action.clone(), + state: None, + volume: None, + play_mode: None, + image_duration: None, + }); + + let mut playback = state.playback.lock().await; + playback.status = match body.action.as_str() { + "play" => "playing".into(), + "pause" => "paused".into(), + _ => playback.status.clone(), + }; + let state_snapshot = playback.clone(); + drop(playback); + + EVENTS.broadcast(&ServerEvent { + action: "state_update".into(), + state: Some(state_snapshot), + volume: None, + play_mode: None, + image_duration: None, + }); + + Ok(Json(serde_json::json!({"ok": true, "action": body.action}))) +} + +// ===================== Handler: 状态 ===================== +async fn get_state( + State(state): State, +) -> Json { + let s = state.playback.lock().await; + Json(StateResponse { state: s.clone() }) +} + +async fn update_state( + State(state): State, + Json(body): Json, +) -> Json { + { + let mut s = state.playback.lock().await; + if !body.state.status.is_empty() { + s.status = body.state.status.clone(); + } + s.index = body.state.index; + if !body.state.name.is_empty() { + s.name = body.state.name.clone(); + } + if !body.state.media_type.is_empty() { + s.media_type = body.state.media_type.clone(); + } + } + + let s2 = state.playback.lock().await; + EVENTS.broadcast(&ServerEvent { + action: "state_update".into(), + state: Some(s2.clone()), + volume: None, + play_mode: None, + image_duration: None, + }); + + Json(OkResponse { ok: true }) +} + +// ===================== Handler: SSE ===================== +async fn sse_handler() -> Sse>> { + let rx = EVENTS.subscribe(); + let stream = BroadcastStream::new(rx).map(|msg| match msg { + Ok(data) => Ok(Event::default().data(data)), + Err(_) => Ok(Event::default().data("")), + }); + Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(std::time::Duration::from_secs(15)), + ) +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..25a5f5b --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,197 @@ +use crate::models::*; +use once_cell::sync::Lazy; +use std::path::PathBuf; +use std::sync::Mutex; + +/// 数据文件路径:系统数据目录 /dpm/data.json +fn db_path() -> PathBuf { + let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push("dpm"); + std::fs::create_dir_all(&path).ok(); + path.push("data.json"); + path +} + +/// 持久化数据结构 +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct PersistentData { + settings: std::collections::HashMap, + playlist: Vec, + url_media: Vec, +} + +impl Default for PersistentData { + fn default() -> Self { + let mut s = std::collections::HashMap::new(); + s.insert("admin".into(), Settings::default()); + Self { + settings: s, + playlist: Vec::new(), + url_media: Vec::new(), + } + } +} + +/// 数据存储 +pub struct Storage { + inner: Mutex, + path: PathBuf, +} + +impl Storage { + pub fn new() -> Self { + let path = db_path(); + let data = if path.exists() { + std::fs::read_to_string(&path) + .ok() + .and_then(|c| serde_json::from_str(&c).ok()) + .unwrap_or_default() + } else { + PersistentData::default() + }; + let storage = Self { + inner: Mutex::new(data), + path, + }; + storage.save(); + storage + } + + fn save(&self) { + if let Ok(data) = self.inner.lock() { + if let Ok(json) = serde_json::to_string_pretty(&*data) { + std::fs::write(&self.path, json).ok(); + } + } + } + + // === Settings === + pub fn get_settings(&self) -> Settings { + self.inner + .lock() + .ok() + .and_then(|d| d.settings.get("admin").cloned()) + .unwrap_or_default() + } + + pub fn update_settings(&self, update: SettingsUpdate) { + if let Ok(mut data) = self.inner.lock() { + if let Some(s) = data.settings.get_mut("admin") { + if let Some(v) = update.volume { + s.volume = v; + } + if let Some(m) = update.play_mode { + s.play_mode = m; + } + if let Some(d) = update.image_duration { + s.image_duration = d; + } + } + } + self.save(); + } + + // === Playlist === + pub fn get_playlist(&self) -> Vec { + self.inner + .lock() + .ok() + .map(|d| d.playlist.clone()) + .unwrap_or_default() + } + + pub fn add_to_playlist(&self, path: &str) { + if let Ok(mut data) = self.inner.lock() { + if !data.playlist.iter().any(|p| p.path == path) { + let mut item = PlaylistItem { + path: path.into(), + source: None, + name: None, + }; + if path.starts_with("http://") || path.starts_with("https://") { + item.source = Some("url".into()); + if let Some(um) = data.url_media.iter().find(|u| u.url == path) { + item.name = Some(um.name.clone()); + } + } + data.playlist.push(item); + } + } + self.save(); + } + + pub fn remove_from_playlist(&self, path: &str) { + if let Ok(mut data) = self.inner.lock() { + data.playlist.retain(|p| p.path != path); + } + self.save(); + } + + // === URL Media === + pub fn add_url_media(&self, url: &str, name: String, media_type: String) -> bool { + let is_new = if let Ok(mut data) = self.inner.lock() { + if !data.url_media.iter().any(|u| u.url == url) { + data.url_media.push(UrlMediaItem { + url: url.into(), + name, + media_type, + added_at: chrono::Local::now() + .format("%Y-%m-%dT%H:%M:%S%.3f") + .to_string(), + }); + true + } else { + false + } + } else { + false + }; + if is_new { + self.save(); + } + is_new + } + + pub fn get_url_media(&self) -> Vec { + self.inner + .lock() + .ok() + .map(|d| d.url_media.clone()) + .unwrap_or_default() + } + + pub fn remove_url_media(&self, url: &str) { + if let Ok(mut data) = self.inner.lock() { + data.url_media.retain(|u| u.url != url); + } + self.save(); + } + + // === Delete === + pub fn delete_media(&self, path: &str) { + if path.starts_with("http://") || path.starts_with("https://") { + self.remove_url_media(path); + } + self.remove_from_playlist(path); + } +} + +// 全局共享实例 +pub static STORAGE: Lazy = Lazy::new(Storage::new); + +/// 判断文件扩展名是否允许 +pub fn is_allowed_extension(path: &str) -> bool { + let allowed = [".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"]; + let lower = path.to_lowercase(); + allowed.iter().any(|ext| lower.ends_with(ext)) +} + +/// 获取媒体类型 +pub fn get_media_type(path: &str) -> &'static str { + let lower = path.to_lowercase(); + if lower.ends_with(".mp4") || lower.ends_with(".mkv") || lower.ends_with(".avi") { + "video" + } else { + "image" + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3df9f46..f5e05d8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,9 +12,9 @@ "app": { "windows": [ { - "title": "dpm", - "width": 800, - "height": 600 + "title": "大屏媒体轮播系统", + "width": 1200, + "height": 800 } ], "security": { diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 85f7a4a..0000000 --- a/src/App.css +++ /dev/null @@ -1,116 +0,0 @@ -.logo.vite:hover { - filter: drop-shadow(0 0 2em #747bff); -} - -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafb); -} -:root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 400; - - color: #0f0f0f; - background-color: #f6f6f6; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; -} - -.container { - margin: 0; - padding-top: 10vh; - display: flex; - flex-direction: column; - justify-content: center; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: 0.75s; -} - -.logo.tauri:hover { - filter: drop-shadow(0 0 2em #24c8db); -} - -.row { - display: flex; - justify-content: center; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} - -a:hover { - color: #535bf2; -} - -h1 { - text-align: center; -} - -input, -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; - transition: border-color 0.25s; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -} - -button { - cursor: pointer; -} - -button:hover { - border-color: #396cd8; -} -button:active { - border-color: #396cd8; - background-color: #e8e8e8; -} - -input, -button { - outline: none; -} - -#greet-input { - margin-right: 5px; -} - -@media (prefers-color-scheme: dark) { - :root { - color: #f6f6f6; - background-color: #2f2f2f; - } - - a:hover { - color: #24c8db; - } - - input, - button { - color: #ffffff; - background-color: #0f0f0f98; - } - button:active { - background-color: #0f0f0f69; - } -} diff --git a/src/App.jsx b/src/App.jsx index 8286a76..86a1dcb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,51 +1,16 @@ -import { useState } from "react"; -import reactLogo from "./assets/react.svg"; -import { invoke } from "@tauri-apps/api/core"; -import "./App.css"; - -function App() { - const [greetMsg, setGreetMsg] = useState(""); - const [name, setName] = useState(""); - - async function greet() { - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ - setGreetMsg(await invoke("greet", { name })); - } +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import Admin from './pages/Admin'; +import Screen from './pages/Screen'; +import './styles/tokens.css'; +export default function App() { return ( -
-

Welcome to Tauri + React

- - -

Click on the Tauri, Vite, and React logos to learn more.

- -
{ - e.preventDefault(); - greet(); - }} - > - setName(e.currentTarget.value)} - placeholder="Enter a name..." - /> - -
-

{greetMsg}

-
+ + + } /> + } /> + } /> + + ); } - -export default App; diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx new file mode 100644 index 0000000..2201e6c --- /dev/null +++ b/src/pages/Admin.jsx @@ -0,0 +1,485 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import * as api from '../utils/api'; +import '../styles/admin.css'; + +/* ============ SVG Icons ============ */ +const IconPrev = () => ( + + + +); +const IconPlay = () => ( + + + +); +const IconPause = () => ( + + + + +); +const IconNext = () => ( + + + +); + +/* ============ Badge ============ */ +function Badge({ type, label }) { + const map = { video: 'badge-video', image: 'badge-image', url: 'badge-url', local: 'badge-local' }; + return {label || type}; +} + +/* ============ Toast Container ============ */ +function ToastContainer({ toasts }) { + return ( +
+ {toasts.map(t => ( +
{t.msg}
+ ))} +
+ ); +} + +/* ============ Preview Modal ============ */ +function PreviewModal({ item, onClose, onAddToPlaylist, onDelete }) { + const [closing, setClosing] = useState(false); + const videoRef = useRef(null); + + const handleClose = useCallback(() => { + setClosing(true); + if (videoRef.current) { + videoRef.current.pause(); + videoRef.current.removeAttribute('src'); + } + setTimeout(() => { + onClose(); + setClosing(false); + }, 200); + }, [onClose]); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') handleClose(); }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [handleClose]); + + if (!item) return null; + + const previewUrl = item.source === 'url' ? item.relative_path : `/file/${item.relative_path}`; + const overlayClass = `modal-overlay${closing ? ' closing' : ''}`; + + return ( +
{ if (e.target.className.includes('modal-overlay')) handleClose(); }}> +
e.stopPropagation()}> +
+ {item.name} + +
+
+ {item.type === 'image' ? ( + {item.name} + ) : ( +
+
+ + + + +
+ + +
+
+
+
+ ); +} + +/* ============ Media Card ============ */ +function MediaCard({ item, onPreview, onAddToPlaylist, onDelete, showRemove, onRemove }) { + const thumbUrl = item.source === 'url' ? item.relative_path : `/file/${item.relative_path}`; + + return ( +
+ {item.type === 'image' ? ( + {item.name} onPreview(item)} /> + ) : ( +
+ ); +} + +/* ========================================================= + Admin Page + ========================================================= */ +export default function Admin() { + // Auth state + const [isLoggedIn, setIsLoggedIn] = useState(() => localStorage.getItem('token') === 'admin_logged'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + // Data state + const [files, setFiles] = useState([]); + const [playlistItems, setPlaylistItems] = useState([]); + const [playMode, setPlayMode] = useState('sequential'); + const [imageDuration, setImageDuration] = useState(5); + const [volume, setVolume] = useState(80); + + // UI state + const [statusText, setStatusText] = useState('等待大屏连接...'); + const [playState, setPlayState] = useState({ status: 'idle' }); + const [previewItem, setPreviewItem] = useState(null); + const [toasts, setToasts] = useState([]); + const toastIdRef = useRef(0); + + // ============ Toast ============ + const addToast = useCallback((msg, type = 'success') => { + const id = ++toastIdRef.current; + setToasts(prev => [...prev, { id, msg, type }]); + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, 2800); + }, []); + + // ============ Auth ============ + const handleLogin = async () => { + const data = await api.login(username, password); + if (data.success) { + localStorage.setItem('token', 'admin_logged'); + setIsLoggedIn(true); + } else { + addToast('登录失败,请检查账号密码', 'error'); + } + }; + + const handleKeyDown = (e) => { if (e.key === 'Enter') handleLogin(); }; + + const handleLogout = () => { + localStorage.removeItem('token'); + setIsLoggedIn(false); + }; + + // ============ Data Loading ============ + const loadSettings = useCallback(async () => { + try { + const d = await api.getSettings(); + setVolume(d.volume ?? 80); + setPlayMode(d.play_mode || 'sequential'); + setImageDuration(d.image_duration || 5); + } catch { /* ignore */ } + }, []); + + const loadFiles = useCallback(async () => { + try { + const data = await api.getMediaFiles(); + setFiles(data.files || []); + } catch { /* ignore */ } + }, []); + + const loadPlaylist = useCallback(async () => { + try { + const data = await api.getPlaylist(); + setPlaylistItems(data.files || []); + setPlayMode(data.play_mode || 'sequential'); + setImageDuration(data.image_duration ?? 5); + setVolume(data.volume ?? 80); + } catch { /* ignore */ } + }, []); + + const loadAll = useCallback(() => { + loadSettings(); + loadFiles(); + loadPlaylist(); + }, [loadSettings, loadFiles, loadPlaylist]); + + useEffect(() => { + if (isLoggedIn) loadAll(); + }, [isLoggedIn, loadAll]); + + // ============ SSE ============ + useEffect(() => { + if (!isLoggedIn) return; + const es = new EventSource('/api/events'); + es.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.action === 'state_update' && msg.state) { + setPlayState(msg.state); + const s = msg.state; + if (s.status === 'idle' || !s.name) { + setStatusText('等待大屏连接...'); + } else if (s.status === 'playing') { + setStatusText(`正在播放 — ${s.name}`); + } else if (s.status === 'paused') { + setStatusText(`已暂停 — ${s.name}`); + } + } + if (msg.action === 'playlist_changed') { + loadPlaylist(); + } + if (msg.action === 'settings_changed') { + if (msg.volume !== undefined) setVolume(msg.volume); + if (msg.play_mode !== undefined) setPlayMode(msg.play_mode); + if (msg.image_duration !== undefined) setImageDuration(msg.image_duration); + } + } catch { /* ignore */ } + }; + return () => es.close(); + }, [isLoggedIn, loadPlaylist]); + + // ============ Handlers ============ + const handleUpload = async () => { + const inp = document.getElementById('fileInput'); + const fileList = Array.from(inp.files); + if (fileList.length === 0) { addToast('请选择文件', 'error'); return; } + await api.uploadFiles(fileList); + inp.value = ''; + addToast('上传完成'); + loadAll(); + }; + + const handleAddUrl = async () => { + const inp = document.getElementById('urlInput'); + const url = inp.value.trim(); + if (!url) { addToast('请输入 URL', 'error'); return; } + const data = await api.addUrlMedia(url); + inp.value = ''; + addToast(data.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库'); + loadAll(); + }; + + const handleSaveSettings = async () => { + await api.updateSettings({ play_mode: playMode, image_duration: imageDuration, volume }); + }; + + const handleAddToPlaylist = async (path) => { + await api.addToPlaylist(path); + addToast('已加入播放列表'); + loadAll(); + }; + + const handleRemoveFromPlaylist = async (path) => { + await api.removeFromPlaylist(path); + addToast('已移出播放列表'); + loadAll(); + }; + + const handleDeleteFile = async (path) => { + if (!window.confirm('确定删除该文件?')) return; + await api.deleteMedia(path); + addToast('已删除'); + loadAll(); + }; + + const handleSendControl = async (action) => { + await api.sendControl(action); + }; + + // ============ Render ============ + if (!isLoggedIn) { + return ( +
+
+
+
+
+
+
+
+
+
+

昆明市大学生创业园

+

大屏幕轮播控制系统

+
+
+ + setUsername(e.target.value)} placeholder="请输入账号" autoComplete="username" onKeyDown={handleKeyDown} /> +
+
+ + setPassword(e.target.value)} placeholder="请输入密码" autoComplete="current-password" onKeyDown={handleKeyDown} /> +
+ +
+
+ +
+ ); + } + + const isPlaying = playState.status === 'playing'; + const isPaused = playState.status === 'paused'; + const hasFiles = files.length > 0; + const hasPlaylist = playlistItems.length > 0; + + return ( +
+
+ {/* Topbar */} +
+
+
+

昆明市大学生创业园

+
+
+ {statusText} +
+
+ + + + + + +
+
+ + {/* Main Content */} +
+ {/* Settings Card */} +
+
+
{'\u2699'}
+

播放设置

+
+
+
+ + +
+
+ + { setImageDuration(Number(e.target.value)); setTimeout(handleSaveSettings, 0); }} /> +
+
+ + { setVolume(Number(e.target.value)); setTimeout(handleSaveSettings, 0); }} /> +
{volume}%
+
+
+
+ + {/* Upload Card */} +
+
+
{'\uD83D\uDCE4'}
+

添加媒体

+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + {/* Media Library */} +
+
+
{'\uD83D\uDCC1'}
+

媒体库

+
+
+ {!hasFiles ? ( +
+
{'\uD83D\uDCF7'}
+ 暂无媒体文件
请上传或通过 URL 添加 +
+ ) : ( + files.map((item, i) => ( + + )) + )} +
+
+ + {/* Playlist */} +
+
+
{'\u25B6'}
+

播放列表

+
+
+ {!hasPlaylist ? ( +
+
{'\u25B6'}
+ 播放列表为空
从上方媒体库添加内容 +
+ ) : ( + playlistItems.map((item, i) => ( + + )) + )} +
+
+
+
+ + {/* Preview Modal */} + setPreviewItem(null)} + onAddToPlaylist={handleAddToPlaylist} + onDelete={handleDeleteFile} + /> + + {/* Toast */} + +
+ ); +} diff --git a/src/pages/Screen.jsx b/src/pages/Screen.jsx new file mode 100644 index 0000000..01c82ba --- /dev/null +++ b/src/pages/Screen.jsx @@ -0,0 +1,402 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import * as api from '../utils/api'; +import '../styles/screen.css'; + +/* ============ Loader ============ */ +function Loader({ hidden, text }) { + return ( +
+
+
+
+
+
+
PineSound
+
+
+
+
+
+
+
+
+ ); +} + +/* ========================================================= + Screen Page + ========================================================= */ +export default function Screen() { + const [list, setList] = useState([]); + const [index, setIndex] = useState(0); + const [volume, setVolume] = useState(0.8); + const [playMode, setPlayMode] = useState('sequential'); + const [imageDuration, setImageDuration] = useState(5); + const [paused, setPaused] = useState(false); + const [soundBlocked, setSoundBlocked] = useState(false); + const [loaded, setLoaded] = useState(false); + const [loaderText, setLoaderText] = useState('加载播放列表中...'); + const [showVideo, setShowVideo] = useState(true); + const [currentSrc, setCurrentSrc] = useState(''); + + const videoRef = useRef(null); + const imgRef = useRef(null); + const timerRef = useRef(null); + + const getUrl = useCallback((item) => { + return item.source === 'url' ? item.relative_path : `/file/${item.relative_path}`; + }, []); + + const reportState = useCallback((status) => { + const item = list[index] || null; + api.updateState({ + status, + index, + name: item ? item.name : '', + type: item ? item.type : '', + }).catch(() => {}); + }, [list, index]); + + const updateStatusUI = useCallback(() => { }, []); // CSS handles visual feedback + + const play = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + if (paused) return; + const item = list[index]; + if (!item) return; + const url = getUrl(item); + + if (item.type === 'video') { + setShowVideo(true); + setCurrentSrc(url); + const video = videoRef.current; + if (video) { + video.style.display = 'block'; + video.src = url; + video.load(); + video.muted = soundBlocked; + const p = video.play(); + if (p !== undefined) { + p.then(() => { + reportState('playing'); + }).catch(() => { + setSoundBlocked(true); + video.muted = true; + video.play().catch(() => {}); + reportState('playing'); + }); + } + } + } else { + // Image + setShowVideo(false); + setCurrentSrc(url); + reportState('playing'); + timerRef.current = setTimeout(next, imageDuration * 1000); + } + }, [list, index, paused, getUrl, soundBlocked, imageDuration, reportState]); + + const skip = useCallback((delta) => { + if (timerRef.current) clearTimeout(timerRef.current); + setIndex(prev => { + if (playMode === 'random') { + return Math.floor(Math.random() * list.length); + } + return (prev + delta + list.length) % list.length; + }); + }, [playMode, list.length]); + + const next = useCallback(() => { + if (paused) return; + if (timerRef.current) clearTimeout(timerRef.current); + setIndex(prev => { + if (playMode === 'random') { + return Math.floor(Math.random() * list.length); + } + return (prev + 1) % list.length; + }); + }, [paused, playMode, list.length]); + + // Play when index changes + useEffect(() => { + if (loaded && list.length > 0) { + const item = list[index]; + if (item) { + if (item.type === 'video') { + setShowVideo(true); + setCurrentSrc(getUrl(item)); + } else { + const url = getUrl(item); + setShowVideo(false); + setCurrentSrc(url); + } + } + } + }, [index, loaded]); + + // Separate effect for playback triggered by src changes + useEffect(() => { + if (!loaded || list.length === 0 || !currentSrc) return; + const item = list[index]; + if (!item) return; + + if (item.type === 'video') { + const video = videoRef.current; + if (video) { + video.style.display = 'block'; + video.src = currentSrc; + video.load(); + video.muted = soundBlocked; + const p = video.play(); + if (p !== undefined) { + p.then(() => reportState('playing')) + .catch(() => { + setSoundBlocked(true); + video.muted = true; + video.play().catch(() => {}); + reportState('playing'); + }); + } + } + } else { + if (timerRef.current) clearTimeout(timerRef.current); + reportState('playing'); + timerRef.current = setTimeout(next, imageDuration * 1000); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [index]); + + // Load playlist on mount + useEffect(() => { + const load = async () => { + try { + const data = await api.getPlaylist(); + const files = data.files || []; + const vol = (data.volume || 80) / 100; + setList(files); + setVolume(vol); + setPlayMode(data.play_mode || 'sequential'); + setImageDuration(data.image_duration || 5); + if (videoRef.current) videoRef.current.volume = vol; + + if (files.length === 0) { + setLoaderText('播放列表为空,前往管理后台添加'); + return; + } + setLoaded(true); + + // Try play with sound + const video = videoRef.current; + if (video) { + video.muted = false; + const testPlay = video.play(); + if (testPlay !== undefined) { + testPlay.then(() => { + setSoundBlocked(false); + video.pause(); + reportState('idle'); + }).catch(() => { + setSoundBlocked(true); + video.muted = true; + reportState('idle'); + }); + } + } + } catch {/* ignore */} + }; + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Connect SSE + useEffect(() => { + const es = new EventSource('/api/events'); + es.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + switch (msg.action) { + case 'pause': + setPaused(true); + if (timerRef.current) clearTimeout(timerRef.current); + if (videoRef.current) videoRef.current.pause(); + reportState('paused'); + break; + case 'play': + setPaused(false); + play(); + reportState('playing'); + break; + case 'next': + setPaused(false); + skip(1); + break; + case 'prev': + setPaused(false); + skip(-1); + break; + case 'playlist_changed': + reloadPlaylist(); + break; + case 'settings_changed': + applySettings(msg); + break; + } + } catch {/* ignore */} + }; + return () => es.close(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [play, skip, reportState]); + + const reloadPlaylist = useCallback(async () => { + try { + const data = await api.getPlaylist(); + const newList = data.files || []; + const currentItem = list[index] || null; + + if (newList.length === 0) { + setList([]); + setLoaded(false); + setLoaderText('播放列表已清空,前往管理后台添加'); + const video = videoRef.current; + if (video) video.pause(); + return; + } + + setLoaded(true); + + let newIndex = -1; + if (currentItem) { + newIndex = newList.findIndex(item => item.relative_path === currentItem.relative_path); + } + if (newIndex >= 0) { + setList(newList); + setIndex(newIndex); + } else { + setList(newList); + setIndex(prev => Math.min(prev, newList.length - 1)); + } + } catch {/* ignore */} + }, [list, index]); + + const applySettings = useCallback((msg) => { + if (msg.volume !== undefined) { + const vol = msg.volume / 100; + setVolume(vol); + const video = videoRef.current; + if (video) { + video.volume = vol; + if (soundBlocked) { + video.muted = false; + video.play().then(() => { + setSoundBlocked(false); + const hint = document.getElementById('soundHint'); + if (hint) hint.style.display = 'none'; + }).catch(() => { + video.muted = true; + video.play().catch(() => {}); + }); + } + } + } + if (msg.play_mode !== undefined) setPlayMode(msg.play_mode); + if (msg.image_duration !== undefined) setImageDuration(msg.image_duration); + }, [soundBlocked]); + + const enableSound = useCallback(() => { + const video = videoRef.current; + if (!video) return; + video.muted = false; + video.play().then(() => { + setSoundBlocked(false); + const hint = document.getElementById('soundHint'); + if (hint) hint.style.display = 'none'; + }).catch(() => { + video.muted = true; + video.play().catch(() => {}); + }); + }, []); + + // Video ended handler + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const handler = () => next(); + video.addEventListener('ended', handler); + return () => video.removeEventListener('ended', handler); + }, [next]); + + // Global click for sound unblock + useEffect(() => { + const handler = () => { + if (soundBlocked) enableSound(); + }; + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, [soundBlocked, enableSound]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const currentItem = list[index] || null; + + return ( +
+ {/* Perspective Grid */} +
+ {/* Ambient Orbs */} +
+
+
+ + {/* Loader */} +
+ ); +} diff --git a/src/styles/admin.css b/src/styles/admin.css new file mode 100644 index 0000000..d84afbd --- /dev/null +++ b/src/styles/admin.css @@ -0,0 +1,949 @@ +/* ========================================================= + Admin Page Theme — 昆明市大学生创业园大屏媒体轮播 + 基于 admin.html CSS 移植 + ========================================================= */ +.admin-page { + --primary: #00BD7D; + --primary-hover: #00a36b; + --primary-glow: rgba(0, 189, 125, 0.25); + --primary-subtle: rgba(0, 189, 125, 0.07); + --primary-surface: rgba(0, 189, 125, 0.04); + --success: #16A34A; + --success-subtle: rgba(22, 163, 74, 0.08); + --warning: #D97706; + --warning-subtle: rgba(217, 119, 6, 0.08); + --danger: #DC2626; + --danger-hover: #ef4444; + --danger-subtle: rgba(220, 38, 38, 0.08); + --surface: #FFFFFF; + --text: #111827; + --text-secondary: #4b5563; + --text-dim: #9ca3af; + --text-inverse: #FFFFFF; + --border-subtle: rgba(0,0,0,0.06); + --border-default: rgba(0,0,0,0.1); + --border-strong: rgba(0,0,0,0.16); + + --space-4: 4px; + --space-8: 8px; + --space-12: 12px; + --space-16: 16px; + --space-24: 24px; + --space-32: 32px; + + --radius-sm: 4px; + --radius-md: 8px; + + --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.35s cubic-bezier(0.4, 0, 0.2, 1); + + --shadow-xs: 0 1px 2px rgba(0,0,0,0.04); + --shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); + --shadow-card: 0 1px 3px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04); + --shadow-card-hover: 0 4px 16px rgba(0,0,0,0.08), 0 8px 32px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.03); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.1), 0 16px 48px rgba(0,0,0,0.08); + --shadow-xl: 0 12px 32px rgba(0,0,0,0.12), 0 24px 64px rgba(0,0,0,0.1); + + font-family: "Poppins", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif; + background: #f3f4f6; + color: var(--text); + min-height: 100vh; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + font-weight: 400; + position: relative; +} + +/* ============ Spatial Background ============ */ +.admin-page::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse 55% 40% at 80% 10%, rgba(0, 189, 125, 0.05) 0%, transparent 55%), + radial-gradient(ellipse 50% 38% at 18% 6%, rgba(217, 119, 6, 0.04) 0%, transparent 55%), + radial-gradient(ellipse 45% 28% at 50% 96%, rgba(0,0,0,0.02) 0%, transparent 50%); +} + +.admin-page::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.02; + background-image: + linear-gradient(30deg, rgba(0,0,0,0.15) 1px, transparent 1px), + linear-gradient(-30deg, rgba(0,0,0,0.15) 1px, transparent 1px); + background-size: 32px 32px; +} + +/* ============ Login ============ */ +.login-wrapper { + position: relative; + z-index: 1; + display: grid; + place-items: center; + min-height: 100vh; + padding: var(--space-24); +} + +.login-wrapper::before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 700px; + height: 700px; + background: + radial-gradient(circle at 50% 50%, rgba(0, 189, 125, 0.07) 0%, transparent 35%), + radial-gradient(circle at 50% 50%, rgba(217, 119, 6, 0.03) 0%, transparent 55%); + pointer-events: none; + animation: loginAmbient 8s ease-in-out infinite; +} +@keyframes loginAmbient { + 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.7; } + 50% { transform: translate(-50%, -50%) scale(1.25); opacity: 1; } +} + +.login-wrapper::after { + content: ''; + position: fixed; + top: 10%; + right: 6%; + width: 240px; + height: 240px; + pointer-events: none; + opacity: 0.05; + background: + linear-gradient(60deg, transparent 49%, rgba(0,189,125,0.6) 49.5%, rgba(0,189,125,0.6) 50.5%, transparent 51%), + linear-gradient(-60deg, transparent 49%, rgba(0,189,125,0.6) 49.5%, rgba(0,189,125,0.6) 50.5%, transparent 51%), + linear-gradient(180deg, transparent 49%, rgba(0,189,125,0.6) 49.5%, rgba(0,189,125,0.6) 50.5%, transparent 51%); + background-size: 48px 48px; + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); +} + +.login-card { + position: relative; + background: var(--surface); + border-radius: var(--radius-md); + padding: var(--space-32); + width: 100%; + max-width: 420px; + text-align: center; + box-shadow: + 0 20px 60px rgba(0,0,0,0.08), + 0 0 0 1px rgba(0,0,0,0.04), + 0 1px 0 rgba(255,255,255,0.6) inset; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + transform: perspective(800px) rotateX(1deg); + transition: transform var(--transition-slow); +} +.login-card:hover { transform: perspective(800px) rotateX(0deg) translateY(-2px); } + +.login-card::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: var(--radius-md); + padding: 1px; + background: linear-gradient(135deg, + rgba(0,0,0,0.04) 0%, + rgba(0, 189, 125, 0.2) 30%, + transparent 55%, + rgba(217, 119, 6, 0.12) 80%, + rgba(0,0,0,0.04) 100%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +.login-brand { margin-bottom: var(--space-32); } +.login-brand .icon { + width: 56px; + height: 56px; + margin: 0 auto var(--space-16); + position: relative; +} +.login-brand .icon .iso-top { + position: absolute; + top: 2px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 30px; + height: 30px; + background: rgba(0, 189, 125, 0.12); + border: 1px solid rgba(0, 189, 125, 0.25); + border-radius: 4px; +} +.login-brand .icon .iso-left { + position: absolute; + bottom: 2px; + left: 2px; + width: 26px; + height: 24px; + background: rgba(0,0,0,0.03); + border: 1px solid rgba(0,0,0,0.08); + border-radius: 4px; + transform: skewY(25deg); +} +.login-brand .icon .iso-right { + position: absolute; + bottom: 2px; + right: 2px; + width: 26px; + height: 24px; + background: rgba(0, 189, 125, 0.15); + border: 1px solid rgba(0, 189, 125, 0.22); + border-radius: 4px; + transform: skewY(-25deg); +} +.login-brand .icon .iso-dot { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 7px; + height: 7px; + background: var(--primary); + border-radius: 50%; + box-shadow: 0 0 12px rgba(0, 189, 125, 0.35); + z-index: 2; +} +.login-brand h2 { + font-family: "Oswald", "PingFang SC", "Hiragino Sans GB", sans-serif; + font-size: 24px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.02em; + text-transform: uppercase; +} +.login-brand p { + font-family: "Poppins", "PingFang SC", sans-serif; + font-size: 13px; + font-weight: 400; + color: var(--text-dim); + margin-top: var(--space-4); +} + +.login-card .field { margin-bottom: var(--space-16); text-align: left; } +.login-card .field label { + display: block; + font-family: "JetBrains Mono", "SF Mono", "Consolas", monospace; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: var(--space-8); + letter-spacing: 0.04em; + text-transform: uppercase; +} +.login-card .field input { + width: 100%; + padding: var(--space-12) var(--space-16); + background: #f9fafb; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + font-size: 15px; + font-family: "Poppins", "PingFang SC", sans-serif; + font-weight: 400; + color: var(--text); + transition: all var(--transition); + outline: none; +} +.login-card .field input:focus { + border-color: rgba(0, 189, 125, 0.5); + box-shadow: 0 0 0 3px rgba(0, 189, 125, 0.08); +} +.login-card .field input::placeholder { color: var(--text-dim); } +.login-card .btn-login { + width: 100%; + margin-top: var(--space-8); + padding: 13px; + background: var(--primary); + color: var(--text-inverse); + border: none; + border-radius: var(--radius-sm); + font-family: "Oswald", "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + transition: all var(--transition); + box-shadow: 0 4px 16px rgba(0, 189, 125, 0.15); +} +.login-card .btn-login:hover { + background: var(--primary-hover); + box-shadow: 0 6px 24px rgba(0, 189, 125, 0.25); + transform: translateY(-2px); +} +.login-card .btn-login:active { transform: translateY(0); } + +/* ============ Dashboard Layout ============ */ +.dashboard { display: none; position: relative; z-index: 1; } +.dashboard.active { display: block; } + +/* ============ Topbar ============ */ +.topbar { + position: sticky; + top: 0; + z-index: 20; + background: rgba(255,255,255,0.84); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border-bottom: 1px solid var(--border-subtle); + padding: 0 var(--space-32); + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-16); + box-shadow: + 0 1px 0 rgba(255,255,255,0.5) inset, + 0 2px 16px rgba(0,0,0,0.03); +} +.topbar-left { + display: flex; + align-items: center; + gap: var(--space-12); + min-width: 0; +} +.topbar-left .brand-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--primary); + box-shadow: 0 0 10px rgba(0, 189, 125, 0.35); + flex-shrink: 0; + animation: brandDotPulse 3s ease-in-out infinite; +} +@keyframes brandDotPulse { + 0%, 100% { box-shadow: 0 0 8px rgba(0, 189, 125, 0.3); } + 50% { box-shadow: 0 0 18px rgba(0, 189, 125, 0.5); } +} +.topbar-left h1 { + font-family: "Oswald", "PingFang SC", sans-serif; + font-size: 18px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.topbar-center { flex: 1; text-align: center; min-width: 0; } +.status-bar-text { + font-family: "JetBrains Mono", "SF Mono", "Consolas", monospace; + font-size: 12px; + font-weight: 500; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + max-width: 400px; + margin: 0 auto; +} +.topbar-right { + display: flex; + align-items: center; + gap: var(--space-4); + flex-shrink: 0; +} + +/* Control buttons */ +.btn-control { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: #f9fafb; + color: var(--text-secondary); + cursor: pointer; + font-size: 15px; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + flex-shrink: 0; + box-shadow: var(--shadow-xs); +} +.btn-control:hover { + background: var(--surface); + border-color: var(--border-strong); + color: var(--text); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} +.btn-control:active { transform: translateY(1px); box-shadow: none; } +.btn-control.active { + background: var(--primary); + border-color: var(--primary); + color: var(--text-inverse); + box-shadow: 0 0 16px rgba(0, 189, 125, 0.3); +} +.btn-control.play-btn { + width: 48px; + height: 48px; + font-size: 18px; + background: var(--surface); + border-color: var(--border-default); + box-shadow: var(--shadow-card); +} +.btn-control.play-btn:hover { + background: var(--surface); + border-color: var(--border-strong); + box-shadow: var(--shadow-card-hover); +} +.btn-control.play-btn.active { + background: var(--primary); + border-color: var(--primary); + color: var(--text-inverse); + box-shadow: 0 0 20px rgba(0, 189, 125, 0.35); +} +.topbar-divider { + width: 1px; + height: 24px; + background: var(--border-subtle); + margin: 0 var(--space-4); +} +.btn-logout { + padding: var(--space-8) var(--space-16); + font-family: "Poppins", "PingFang SC", sans-serif; + font-size: 13px; + font-weight: 500; + background: transparent; + border: 1px solid var(--border-default); + color: var(--text-secondary); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} +.btn-logout:hover { + background: var(--danger-subtle); + border-color: rgba(220, 38, 38, 0.3); + color: var(--danger); + transform: translateY(-1px); +} + +/* ============ Main Grid ============ */ +.main-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-24); + padding: var(--space-32); + max-width: 1400px; + margin: 0 auto; +} + +/* ============ Cards ============ */ +.card { + background: var(--surface); + border-radius: var(--radius-md); + padding: var(--space-24); + border: 1px solid var(--border-subtle); + box-shadow: var(--shadow-card); + transition: all var(--transition-slow); + transform: perspective(1000px) translateZ(0); + position: relative; + overflow: hidden; +} +.card::after { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--radius-md); + background: linear-gradient(135deg, rgba(255,255,255,0.5) 0%, transparent 50%, rgba(0,0,0,0.01) 100%); + pointer-events: none; +} +.card:hover { + border-color: var(--border-default); + box-shadow: var(--shadow-card-hover); + transform: perspective(1000px) translateZ(4px); +} +.card.full { grid-column: 1 / -1; } +.card-header { + display: flex; + align-items: center; + gap: var(--space-12); + margin-bottom: var(--space-24); + position: relative; + z-index: 1; +} +.card-header .card-icon { + font-size: 18px; + width: 36px; + height: 36px; + border-radius: var(--radius-md); + background: var(--primary-subtle); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 1px solid rgba(0, 189, 125, 0.12); +} +.card-header h3 { + font-family: "Oswald", "PingFang SC", sans-serif; + font-size: 16px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.02em; + text-transform: uppercase; +} + +/* ============ Form Elements ============ */ +input[type="text"], +input[type="number"], +input[type="password"], +select { + width: 100%; + padding: var(--space-12) var(--space-16); + background: #f9fafb; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + font-family: "Poppins", "PingFang SC", sans-serif; + font-size: 14px; + font-weight: 400; + color: var(--text); + transition: all var(--transition); + outline: none; +} +input:focus, select:focus { + border-color: rgba(0, 189, 125, 0.5); + box-shadow: 0 0 0 3px rgba(0, 189, 125, 0.07); +} +input::placeholder { color: var(--text-dim); } +select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--space-12) center; + padding-right: 36px; +} +input[type="file"] { + font-size: 14px; + font-family: "Poppins", "PingFang SC", sans-serif; + font-weight: 400; + color: var(--text-secondary); +} +input[type="file"]::file-selector-button { + background: #f3f4f6; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + padding: var(--space-8) var(--space-16); + margin-right: var(--space-12); + cursor: pointer; + font-family: "Poppins", "PingFang SC", sans-serif; + font-size: 13px; + font-weight: 500; + color: var(--text); + transition: all var(--transition); +} +input[type="file"]::file-selector-button:hover { + background: var(--surface); + border-color: var(--border-strong); +} +input[type="range"] { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: rgba(0,0,0,0.1); + border-radius: 2px; + padding: 0; + border: none; +} +input[type="range"]:focus { box-shadow: none; } +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + background: var(--primary); + border-radius: 50%; + cursor: pointer; + border: 2px solid #fff; + box-shadow: 0 1px 4px rgba(0,0,0,0.15), 0 0 8px rgba(0, 189, 125, 0.25); + transition: all var(--transition); +} +input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 2px 8px rgba(0,0,0,0.2), 0 0 18px rgba(0, 189, 125, 0.35); +} + +.form-group { margin-bottom: var(--space-16); position: relative; z-index: 1; } +.form-group:last-child { margin-bottom: 0; } +.form-group label { + display: block; + font-family: "JetBrains Mono", "SF Mono", "Consolas", monospace; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: var(--space-8); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.form-row { + display: flex; + gap: var(--space-8); + align-items: end; +} +.form-row > :first-child { flex: 1; } +.form-row > button { flex: 0 0 auto; } +.form-inline { + display: flex; + gap: var(--space-24); + align-items: end; + position: relative; + z-index: 1; +} +.form-inline .form-group { flex: 1; margin-bottom: 0; } + +/* ============ Buttons ============ */ +button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; transition: all var(--transition); } +.btn-primary { + padding: var(--space-12) var(--space-24); + border-radius: var(--radius-sm); + border: none; + font-size: 13px; + font-weight: 600; + background: var(--text); + color: var(--text-inverse); + white-space: nowrap; + letter-spacing: 0.01em; + box-shadow: var(--shadow-sm); +} +.btn-primary:hover { + background: #1f2937; + box-shadow: var(--shadow-card); + transform: translateY(-1px); +} +.btn-primary:active { transform: translateY(1px); } +.btn-accent { + padding: var(--space-12) var(--space-24); + border-radius: var(--radius-sm); + border: none; + font-size: 13px; + font-weight: 600; + background: var(--primary); + color: var(--text-inverse); + white-space: nowrap; + letter-spacing: 0.01em; + box-shadow: 0 2px 8px rgba(0, 189, 125, 0.2); +} +.btn-accent:hover { + background: var(--primary-hover); + box-shadow: 0 4px 16px rgba(0, 189, 125, 0.3); + transform: translateY(-1px); +} +.btn-accent:active { transform: translateY(1px); } +.btn-danger { + padding: var(--space-12) var(--space-24); + border-radius: var(--radius-sm); + border: none; + font-size: 13px; + font-weight: 600; + background: var(--danger); + color: var(--text-inverse); + white-space: nowrap; + letter-spacing: 0.01em; + box-shadow: 0 2px 8px rgba(220, 38, 38, 0.15); +} +.btn-danger:hover { + background: var(--danger-hover); + box-shadow: 0 4px 16px rgba(220, 38, 38, 0.25); + transform: translateY(-1px); +} +.btn-sm { + padding: var(--space-8) var(--space-12); + font-size: 12px; + font-weight: 500; + border-radius: var(--radius-sm); +} + +/* ============ Media Grid ============ */ +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--space-16); +} +.media-card { + background: #f9fafb; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + transition: all var(--transition-slow); + position: relative; + box-shadow: var(--shadow-xs); + transform: perspective(800px) translateZ(0); +} +.media-card:hover { + background: var(--surface); + border-color: var(--border-strong); + box-shadow: var(--shadow-card-hover); + transform: perspective(800px) translateZ(8px) translateY(-3px); +} +.media-card .thumb { + width: 100%; + height: 108px; + object-fit: cover; + background: #e5e7eb; + display: block; + cursor: pointer; +} +.media-card .thumb-placeholder { + width: 100%; + height: 108px; + background: #f3f4f6; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + font-family: "JetBrains Mono", "SF Mono", monospace; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.media-card .body { padding: var(--space-12); } +.media-card .body .name { + font-family: "Poppins", "PingFang SC", sans-serif; + font-size: 13px; + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} +.media-card .body .meta-row { + display: flex; + gap: var(--space-4); + margin-top: var(--space-8); + flex-wrap: wrap; +} +.media-card .body .actions { + display: flex; + gap: var(--space-4); + margin-top: var(--space-12); +} +.media-card .body .actions button { flex: 1; } + +.badge { + display: inline-flex; + align-items: center; + font-family: "JetBrains Mono", "SF Mono", "Consolas", monospace; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: var(--radius-sm); + letter-spacing: 0.04em; + text-transform: uppercase; +} +.badge-video { background: rgba(0, 189, 125, 0.1); color: #00BD7D; } +.badge-image { background: rgba(217, 119, 6, 0.1); color: #b45309; } +.badge-url { background: rgba(139, 92, 246, 0.08); color: #7c3aed; } +.badge-local { background: rgba(22, 163, 74, 0.1); color: #16A34A; } + +.empty-state { + text-align: center; + padding: var(--space-32) var(--space-24); + color: var(--text-dim); + font-family: "Poppins", "PingFang SC", sans-serif; + font-size: 14px; + font-weight: 400; + grid-column: 1 / -1; +} +.empty-state .empty-icon { font-size: 40px; margin-bottom: var(--space-12); opacity: 0.35; } + +/* ============ Toast ============ */ +.toast-container { + position: fixed; + top: var(--space-24); + right: var(--space-24); + z-index: 9999; + display: flex; + flex-direction: column; + gap: var(--space-8); + pointer-events: none; +} +.toast { + padding: var(--space-12) var(--space-24); + border-radius: var(--radius-md); + font-family: "Poppins", "PingFang SC", sans-serif; + font-size: 13px; + font-weight: 500; + pointer-events: auto; + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + animation: toastIn 0.35s cubic-bezier(0.16, 1, 0.3, 1); + max-width: 360px; + border: 1px solid; + box-shadow: var(--shadow-lg); +} +.toast.success { background: rgba(22, 163, 74, 0.1); color: #166534; border-color: rgba(22, 163, 74, 0.2); } +.toast.error { background: rgba(220, 38, 38, 0.08); color: #991b1b; border-color: rgba(220, 38, 38, 0.18); } +@keyframes toastIn { + from { opacity: 0; transform: translateX(40px) scale(0.92); } + to { opacity: 1; transform: translateX(0) scale(1); } +} + +/* ============ Preview Modal ============ */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0,0,0,0.45); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-32); + animation: overlayIn 0.25s ease; +} +@keyframes overlayIn { from { opacity: 0; } to { opacity: 1; } } +.modal-overlay.closing { animation: overlayOut 0.2s ease forwards; } +@keyframes overlayOut { from { opacity: 1; } to { opacity: 0; } } +.modal-panel { + position: relative; + max-width: 90vw; + max-height: 85vh; + width: auto; + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-xl); + animation: panelIn 0.35s cubic-bezier(0.16, 1, 0.3, 1); + transform: perspective(1000px) rotateX(0.5deg); +} +@keyframes panelIn { from { opacity: 0; transform: perspective(1000px) rotateX(2deg) translateY(20px) scale(0.94); } to { opacity: 1; transform: perspective(1000px) rotateX(0.5deg) translateY(0) scale(1); } } +.modal-overlay.closing .modal-panel { animation: panelOut 0.2s ease forwards; } +@keyframes panelOut { from { opacity: 1; transform: perspective(1000px) rotateX(0.5deg) translateY(0) scale(1); } to { opacity: 0; transform: perspective(1000px) rotateX(2deg) translateY(20px) scale(0.94); } } +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-16) var(--space-24); + border-bottom: 1px solid var(--border-subtle); + background: #f9fafb; +} +.modal-header .modal-title { + font-family: "Oswald", "PingFang SC", sans-serif; + font-size: 14px; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: var(--space-12); + letter-spacing: 0.02em; + text-transform: uppercase; +} +.modal-close { + width: 32px; + height: 32px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 16px; + transition: all var(--transition); +} +.modal-close:hover { + background: var(--danger-subtle); + border-color: rgba(220, 38, 38, 0.3); + color: var(--danger); +} +.modal-body { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-24); + background: #f3f4f6; + min-height: 200px; + max-height: 65vh; + overflow: auto; +} +.modal-body img { max-width: 100%; max-height: 60vh; object-fit: contain; border-radius: var(--radius-sm); } +.modal-body video { max-width: 100%; max-height: 60vh; border-radius: var(--radius-sm); outline: none; } +.modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-12) var(--space-24); + border-top: 1px solid var(--border-subtle); + background: #f9fafb; +} +.modal-info { + font-family: "JetBrains Mono", "SF Mono", "Consolas", monospace; + font-size: 11px; + font-weight: 500; + color: var(--text-dim); + display: flex; + gap: var(--space-4); + align-items: center; +} +.modal-actions { display: flex; gap: var(--space-8); } + +/* ============ Utilities ============ */ +.hidden { display: none !important; } +.volume-label { + font-family: "JetBrains Mono", "SF Mono", "Consolas", monospace; + font-size: 12px; + font-weight: 500; + color: var(--text-dim); + text-align: right; + margin-top: var(--space-4); + font-variant-numeric: tabular-nums; +} + +/* ============ Responsive ============ */ +@media (max-width: 768px) { + .login-card { padding: var(--space-24) var(--space-16) var(--space-32); margin: var(--space-12); max-width: none; } + .login-brand h2 { font-size: 20px; } + .topbar { padding: 0 var(--space-16); height: 56px; gap: var(--space-8); } + .topbar-left h1 { font-size: 15px; } + .topbar-center { display: none; } + .btn-control { width: 36px; height: 36px; font-size: 14px; } + .btn-control.play-btn { width: 42px; height: 42px; font-size: 16px; } + .btn-logout { padding: var(--space-4) var(--space-12); font-size: 12px; } + .topbar-divider { display: none; } + .main-grid { grid-template-columns: 1fr; gap: var(--space-16); padding: var(--space-16); } + .card { padding: var(--space-16); } + .card.full { grid-column: 1; } + .form-inline { flex-direction: column; gap: var(--space-16); } + .media-grid { grid-template-columns: repeat(2, 1fr); gap: var(--space-8); } + .media-card .thumb { height: 88px; } + .media-card .body { padding: var(--space-8); } + .media-card .body .name { font-size: 12px; } + .media-card .body .actions button { font-size: 11px; padding: var(--space-4) var(--space-8); } + .toast-container { top: var(--space-12); right: var(--space-12); left: var(--space-12); } + .toast { max-width: none; font-size: 12px; } + .btn-primary, .btn-accent, .btn-danger { padding: var(--space-12) var(--space-16); font-size: 12px; } + .modal-overlay { padding: var(--space-16); } + .modal-panel { max-width: 100%; max-height: 90vh; } + .modal-body { padding: var(--space-12); max-height: 55vh; } + .modal-body img, .modal-body video { max-height: 50vh; } + .modal-header { padding: var(--space-12) var(--space-16); } + .modal-footer { padding: var(--space-8) var(--space-16); } +} + +@media (max-width: 400px) { + .topbar-left h1 { font-size: 13px; } + .topbar-right { gap: 2px; } + .btn-control { width: 32px; height: 32px; font-size: 12px; } + .btn-control.play-btn { width: 38px; height: 38px; font-size: 14px; } + .media-grid { grid-template-columns: repeat(2, 1fr); gap: var(--space-4); } +} diff --git a/src/styles/screen.css b/src/styles/screen.css new file mode 100644 index 0000000..a484898 --- /dev/null +++ b/src/styles/screen.css @@ -0,0 +1,431 @@ +/* ========================================================= + Screen Page Theme — 大屏媒体轮播 + 基于 screen.html CSS 移植 + ========================================================= */ +.screen-page { + --space-deep: #060d1a; + --space-mid: #0b1530; + --surface-glass: rgba(255,255,255,0.04); + --surface-glass-warm: rgba(255,248,240,0.05); + --text-dim: rgba(255,255,255,0.38); + --text-soft: rgba(255,255,255,0.55); + --text-bright: rgba(255,255,255,0.88); + --accent-cyan: #06b6d4; + --accent-warm: #f59e0b; + --accent-rose: #f472b6; + --glow-cyan: rgba(6, 182, 212, 0.25); + --glow-warm: rgba(245, 158, 11, 0.2); + --glow-rose: rgba(244, 114, 182, 0.15); + + background: var(--space-deep); + color: #fff; + font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif; + overflow: hidden; + -webkit-font-smoothing: antialiased; + user-select: none; + -webkit-user-select: none; + min-height: 100vh; + position: relative; +} + +/* ============ Spatial Depth Layers ============ */ + +/* Layer 0 — Deep space background */ +.screen-page::before { + content: ''; + position: fixed; + inset: 0; + z-index: 0; + background: + radial-gradient(ellipse 60% 50% at 75% 25%, rgba(245, 158, 11, 0.07) 0%, transparent 60%), + radial-gradient(ellipse 50% 45% at 20% 20%, rgba(6, 182, 212, 0.05) 0%, transparent 55%), + radial-gradient(ellipse 40% 35% at 15% 85%, rgba(244, 114, 182, 0.04) 0%, transparent 50%), + radial-gradient(ellipse 80% 80% at 50% 50%, rgba(6, 13, 26, 0) 0%, var(--space-deep) 100%); +} + +/* Layer 1 — Perspective grid */ +.perspective-grid { + position: fixed; + inset: 0; + z-index: 1; + pointer-events: none; + opacity: 0.06; + background-image: + repeating-linear-gradient( + to bottom, + transparent, + transparent calc(2px + 0.5vh), + rgba(255,255,255,0.3) calc(2px + 0.5vh), + rgba(255,255,255,0.3) calc(2.5px + 0.5vh) + ), + repeating-linear-gradient( + to right, + transparent, + transparent calc(2px + 0.5vw), + rgba(255,255,255,0.3) calc(2px + 0.5vw), + rgba(255,255,255,0.3) calc(2.5px + 0.5vw) + ); + mask-image: radial-gradient(ellipse 60% 80% at 50% 50%, transparent 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.15) 70%, transparent 100%); + -webkit-mask-image: radial-gradient(ellipse 60% 80% at 50% 50%, transparent 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.15) 70%, transparent 100%); +} + +/* Layer 2 — Ambient orbs */ +.ambient-orb { + position: fixed; + border-radius: 50%; + pointer-events: none; + z-index: 2; + filter: blur(80px); + animation: orbDrift 12s ease-in-out infinite; +} +.ambient-orb.orb-1 { + width: 400px; height: 400px; + top: -100px; right: -80px; + background: rgba(6, 182, 212, 0.06); + animation-delay: 0s; +} +.ambient-orb.orb-2 { + width: 350px; height: 350px; + bottom: -120px; left: -60px; + background: rgba(245, 158, 11, 0.05); + animation-delay: -4s; +} +.ambient-orb.orb-3 { + width: 250px; height: 250px; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + background: rgba(244, 114, 182, 0.03); + animation-delay: -8s; +} +@keyframes orbDrift { + 0%, 100% { transform: translate(0, 0) scale(1); } + 25% { transform: translate(30px, -20px) scale(1.08); } + 50% { transform: translate(-15px, 25px) scale(0.94); } + 75% { transform: translate(-25px, -10px) scale(1.04); } +} + +/* Layer 3 — Vignette */ +.screen-page::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 50; + background: + radial-gradient(ellipse at 50% 50%, transparent 55%, rgba(6, 13, 26, 0.55) 90%, rgba(4, 8, 16, 0.8) 100%); +} + +/* ============ Media Players ============ */ +#player { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + object-fit: contain; + background: transparent; + z-index: 3; +} +#imgPlayer { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + object-fit: contain; + background: transparent; + z-index: 3; + display: none; +} + +/* ============ Loader ============ */ +.loader { + position: fixed; + inset: 0; + z-index: 100; + background: var(--space-deep); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 48px; + transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} +.loader.hidden { opacity: 0; visibility: hidden; pointer-events: none; } + +.loader-brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} +.loader-brand .mark { + width: 56px; + height: 56px; + position: relative; +} +.loader-brand .mark::before { + content: ''; + position: absolute; + top: 6px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 28px; + height: 28px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 4px; +} +.loader-brand .mark::after { + content: ''; + position: absolute; + top: 18px; + left: 4px; + width: 24px; + height: 28px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 3px; + transform: skewY(25deg); +} +.loader-brand .mark .iso-right { + position: absolute; + top: 18px; + right: 4px; + width: 24px; + height: 28px; + background: rgba(6, 182, 212, 0.08); + border: 1px solid rgba(6, 182, 212, 0.15); + border-radius: 3px; + transform: skewY(-25deg); +} +.loader-brand .mark .iso-dot { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 8px; + height: 8px; + background: var(--accent-cyan); + border-radius: 50%; + box-shadow: 0 0 16px var(--glow-cyan), 0 0 32px rgba(6, 182, 212, 0.15); + animation: isoPulse 2.4s ease-in-out infinite; + z-index: 2; +} +@keyframes isoPulse { + 0%, 100% { opacity: 0.5; box-shadow: 0 0 12px var(--glow-cyan); } + 50% { opacity: 1; box-shadow: 0 0 24px var(--glow-cyan), 0 0 48px rgba(6, 182, 212, 0.2); } +} +.loader-brand .label { + font-size: 13px; + font-weight: 500; + letter-spacing: 0.4em; + color: rgba(255,255,255,0.35); + text-transform: uppercase; +} + +.loader-ring { + position: relative; + width: 72px; + height: 72px; + perspective: 200px; +} +.loader-ring .ring { + position: absolute; + border-radius: 50%; + border: 1.5px solid transparent; +} +.loader-ring .ring:nth-child(1) { + inset: 0; + border-top-color: rgba(6, 182, 212, 0.5); + border-right-color: rgba(6, 182, 212, 0.2); + animation: spin3d 1.4s linear infinite; + box-shadow: 0 0 20px rgba(6, 182, 212, 0.1); +} +.loader-ring .ring:nth-child(2) { + inset: 8px; + border-bottom-color: rgba(245, 158, 11, 0.35); + border-left-color: rgba(245, 158, 11, 0.12); + animation: spin3d 2s linear infinite reverse; + transform: rotateX(30deg); +} +.loader-ring .ring:nth-child(3) { + inset: 16px; + border-top-color: rgba(244, 114, 182, 0.25); + border-right-color: rgba(244, 114, 182, 0.08); + animation: spin3d 2.6s linear infinite; + transform: rotateX(-25deg); +} +@keyframes spin3d { to { transform: rotate(360deg); } } + +.loader-hint { + font-size: 13px; + color: var(--text-dim); + letter-spacing: 0.15em; +} +.loader-hint a { + color: rgba(255,255,255,0.6); + text-decoration: none; + border-bottom: 1px solid rgba(255,255,255,0.15); + padding-bottom: 2px; + transition: color 0.3s, border-color 0.3s; +} +.loader-hint a:hover { + color: rgba(255,255,255,0.9); + border-color: rgba(255,255,255,0.4); +} + +/* ============ Sound Hint ============ */ +#soundHint { + position: fixed; + bottom: 48px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + background: rgba(255,255,255,0.05); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border: 1px solid rgba(255,255,255,0.1); + border-bottom: 1px solid rgba(255,255,255,0.04); + color: rgba(255,255,255,0.8); + padding: 14px 32px; + border-radius: 40px; + font-size: 14px; + font-weight: 450; + letter-spacing: 0.05em; + cursor: pointer; + display: none; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 24px rgba(0,0,0,0.3), + 0 0 0 1px rgba(255,255,255,0.03) inset, + 0 1px 0 rgba(255,255,255,0.04) inset; + animation: hintFloat 4s ease-in-out infinite; +} +#soundHint:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.18); + box-shadow: + 0 8px 32px rgba(0,0,0,0.4), + 0 0 0 1px rgba(255,255,255,0.05) inset, + 0 1px 0 rgba(255,255,255,0.06) inset, + 0 0 40px rgba(6, 182, 212, 0.08); + transform: translateX(-50%) translateY(-2px); +} +@keyframes hintFloat { + 0%, 100% { box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 0 0 rgba(6, 182, 212, 0); } + 50% { box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 32px 2px rgba(6, 182, 212, 0.06); } +} + +/* ============ Status Elements ============ */ +.corner-info { + position: fixed; + top: 28px; + left: 28px; + z-index: 100; + display: flex; + align-items: center; + gap: 10px; + background: rgba(255,255,255,0.03); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 12px; + padding: 8px 16px; + opacity: 0; + transition: opacity 0.6s ease; + box-shadow: + 0 2px 12px rgba(0,0,0,0.2), + 0 0 0 1px rgba(255,255,255,0.02) inset; + pointer-events: none; +} +.corner-info.visible { opacity: 1; } +.corner-info .ci-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent-cyan); + box-shadow: 0 0 10px var(--glow-cyan); + flex-shrink: 0; +} +.corner-info .ci-text { + font-size: 11px; + font-weight: 500; + color: var(--text-soft); + letter-spacing: 0.04em; + white-space: nowrap; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; +} + +.conn-indicator { + position: fixed; + top: 28px; + right: 28px; + z-index: 100; + display: flex; + align-items: center; + gap: 8px; + opacity: 0; + transition: opacity 0.6s ease; + pointer-events: none; +} +.conn-indicator.visible { opacity: 1; } +.conn-rings { position: relative; width: 8px; height: 8px; } +.conn-rings .cr-inner { + position: absolute; + inset: 1px; + border-radius: 50%; + background: rgba(52, 211, 153, 0.6); + box-shadow: 0 0 6px rgba(52, 211, 153, 0.3); + transition: all 0.6s ease; +} +.conn-rings .cr-outer { + position: absolute; + inset: -3px; + border-radius: 50%; + border: 1px solid rgba(52, 211, 153, 0.2); + animation: connPulse 2.5s ease-in-out infinite; +} +@keyframes connPulse { + 0%, 100% { transform: scale(1); opacity: 0.3; } + 50% { transform: scale(1.6); opacity: 0; } +} +.conn-label { font-size: 10px; font-weight: 500; color: var(--text-dim); letter-spacing: 0.06em; text-transform: uppercase; } + +.status-bar { + position: fixed; + bottom: 28px; + right: 28px; + z-index: 100; + display: flex; + align-items: center; + gap: 8px; + background: rgba(255,255,255,0.03); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255,255,255,0.05); + border-radius: 10px; + padding: 6px 14px; + opacity: 0; + transition: opacity 0.6s ease; + box-shadow: 0 2px 10px rgba(0,0,0,0.15); + pointer-events: none; +} +.status-bar.visible { opacity: 1; } +.status-bar .sb-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent-warm); + box-shadow: 0 0 8px var(--glow-warm); + flex-shrink: 0; +} +.status-bar .sb-dot.idle { background: rgba(255,255,255,0.25); box-shadow: none; } +.status-bar .sb-text { + font-size: 10px; + font-weight: 500; + color: var(--text-dim); + letter-spacing: 0.04em; + font-variant-numeric: tabular-nums; + font-family: "SF Mono", "JetBrains Mono", "Consolas", monospace; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..d62a8e0 --- /dev/null +++ b/src/styles/tokens.css @@ -0,0 +1,8 @@ +/* ============ Base Reset ============ */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Admin and Screen pages define their own :root variables */ diff --git a/src/templates/admin.html b/src/templates/admin.html deleted file mode 100644 index 7856974..0000000 --- a/src/templates/admin.html +++ /dev/null @@ -1,1659 +0,0 @@ - - - - - 管理后台 — 昆明市大学生创业园大屏媒体轮播 - - - - - - - - - - - - -
-
-
-
-

昆明市大学生创业园

-
-
- 等待大屏连接... -
-
- - - - - - -
-
- -
- -
-
-
-

播放设置

-
-
-
- - -
-
- - -
-
- - -
80%
-
-
-
- - -
-
-
📤
-

添加媒体

-
-
- -
- - -
-
-
- -
- - -
-
-
- - -
-
-
📁
-

媒体库

-
-
-
- - -
-
-
-

播放列表

-
-
-
-
-
- - - - - -
- - - - diff --git a/src/templates/screen.html b/src/templates/screen.html deleted file mode 100644 index 360199f..0000000 --- a/src/templates/screen.html +++ /dev/null @@ -1,830 +0,0 @@ - - - - - - 昆明市大学生创业园-大屏媒体轮播 - - - - -
- - -
-
-
- - -
-
-
-
-
-
-
PineSound
-
-
-
-
-
-
-
加载播放列表中...
-
- - - - - - -
-
- 就绪 -
- - -
-
-
-
-
- Live -
- - -
-
- 0 / 0 -
- - -
点击启用声音
- - - - diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..b714a6a --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,109 @@ +// API 基地址:Tauri 生产环境使用绝对路径,浏览器环境使用相对路径 +const BASE = window.location.protocol === 'http:' || window.location.protocol === 'https:' + ? '' // 浏览器环境 — 同源请求 + : 'http://localhost:8000'; // Tauri 生产环境 (tauri://) + +async function request(url, options = {}) { + const res = await fetch(`${BASE}${url}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + return res.json(); +} + +function formEncode(data) { + return Object.entries(data) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); +} + +// ============ Auth ============ +export async function login(username, password) { + const res = await fetch(`${BASE}/api/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formEncode({ username, password }), + }); + return res.json(); +} + +// ============ Settings ============ +export async function getSettings() { + const res = await fetch(`${BASE}/api/settings`); + return res.json(); +} + +export async function updateSettings(data) { + return request('/api/settings', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +// ============ Media Files ============ +export async function getMediaFiles() { + const res = await fetch(`${BASE}/media`); + return res.json(); +} + +export async function uploadFiles(files) { + for (const file of files) { + const fd = new FormData(); + fd.append('file', file); + await fetch(`${BASE}/upload`, { method: 'POST', body: fd }); + } +} + +export async function addUrlMedia(url) { + return request('/api/media/add-url', { + method: 'POST', + body: JSON.stringify({ url }), + }); +} + +export async function deleteMedia(path) { + return request('/api/delete', { + method: 'POST', + body: JSON.stringify({ path }), + }); +} + +// ============ Playlist ============ +export async function getPlaylist() { + const res = await fetch(`${BASE}/api/playlist`); + return res.json(); +} + +export async function addToPlaylist(path) { + return request('/api/playlist/add', { + method: 'POST', + body: JSON.stringify({ path }), + }); +} + +export async function removeFromPlaylist(path) { + return request('/api/playlist/remove', { + method: 'POST', + body: JSON.stringify({ path }), + }); +} + +// ============ Playback Control ============ +export async function sendControl(action) { + return request('/api/control', { + method: 'POST', + body: JSON.stringify({ action }), + }); +} + +export async function getState() { + const res = await fetch(`${BASE}/api/state`); + return res.json(); +} + +export async function updateState(state) { + return request('/api/state', { + method: 'POST', + body: JSON.stringify({ state }), + }); +} diff --git a/vite.config.js b/vite.config.js index 1cb124a..088cf6e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,25 +7,23 @@ const host = process.env.TAURI_DEV_HOST; export default defineConfig(async () => ({ plugins: [react()], - // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` - // - // 1. prevent Vite from obscuring rust errors clearScreen: false, - // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, strictPort: true, host: host || "127.0.0.1", hmr: host - ? { - protocol: "ws", - host, - port: 1421, - } + ? { protocol: "ws", host, port: 1421 } : undefined, watch: { - // 3. tell Vite to ignore watching `src-tauri` ignored: ["**/src-tauri/**"], }, + // 开发时代理 API 请求到 Python/Rust 后端 + proxy: { + '/api': 'http://localhost:8000', + '/file': 'http://localhost:8000', + '/media': 'http://localhost:8000', + '/upload': 'http://localhost:8000', + }, }, })); diff --git a/yarn.lock b/yarn.lock index c034b94..1426dc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -614,6 +614,11 @@ convert-source-map@^2.0.0: resolved "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +cookie@^1.0.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" + integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== + debug@^4.1.0, debug@^4.3.1: version "4.4.3" resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" @@ -746,6 +751,21 @@ react-refresh@^0.17.0: resolved "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== +react-router-dom@^7.15.0: + version "7.15.0" + resolved "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.15.0.tgz#a4b95c4402d896c2ad437014aff9076b94673063" + integrity sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ== + dependencies: + react-router "7.15.0" + +react-router@7.15.0: + version "7.15.0" + resolved "https://registry.npmmirror.com/react-router/-/react-router-7.15.0.tgz#cb438ff254ab5a1e356ef5a23d7821d8f6fbe652" + integrity sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ== + dependencies: + cookie "^1.0.1" + set-cookie-parser "^2.6.0" + react@^19.1.0: version "19.2.6" resolved "https://registry.npmmirror.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d" @@ -795,6 +815,11 @@ semver@^6.3.1: resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +set-cookie-parser@^2.6.0: + version "2.7.2" + resolved "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68" + integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"