commit 4e2775c8c37da93501639600398ce0c9ae639b91 Author: Tatta Date: Wed Aug 12 20:57:55 2026 +0800 init: EasyScreen 电子菜单系统 - server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管) - android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报) - web: React + Vite + antd 管理台(屏幕/素材/节目管理) - 屏幕设备 ID 关联机制、gunicorn 生产部署脚本 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5860fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ + +# 数据库(本地开发 SQLite;生产用 MySQL) +*.db +*.sqlite3 + +# 本地临时目录(构建工具缓存等) +tmp/ + +# 上传的素材(媒体文件不入库,正式部署用共享目录/对象存储) +server/uploads/* +!server/uploads/.gitkeep +server/logs/ + +# Node +node_modules/ +web/dist/ +web/.vite/ + +# Android / Gradle +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +android/.idea/ +*.iml +.DS_Store + +# IDE / OS +.idea/ +.vscode/ +*.swp +Thumbs.db + +# 本地工具数据(playwright 快照 / Reasonix 会话日志) +.playwright-mcp/ +.reasonix/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ca1ed0 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# EasyScreen 电子菜单系统 + +基于 **Android 6.0 电子显示屏** 的电子菜单(广告屏)解决方案。支持多块屏幕独立配置、远程下发图片/视频节目、开机自启全屏播放。 + +系统分三端: + +| 端 | 技术栈 | 说明 | +|---|---|---| +| `android/` | 原生 Java(minSdk 23,兼容 Android 6.0+) | 运行在电子显示屏上,全屏轮播图片/视频 | +| `server/` | Python FastAPI + SQLAlchemy | 配置管理、素材存储、客户端接口、管理台托管 | +| `web/` | React + Vite + antd | 管理台:多屏管理、素材上传、节目编排 | + +``` +┌─────────────┐ 开机/重开自动拉取配置 ┌──────────────┐ +│ Android 显示屏 │ ─── /api/client/config ───▶│ │ +│ (ExoPlayer │ ─── /media/xxx 拉素材 ─────▶│ FastAPI │ +│ + Glide) │ ◀── /api/client/register ─── │ 后端服务 │ +└─────────────┘ 心跳 /heartbeat │ + MySQL/SQLite + 崩溃上报 /crash │ │ +┌─────────────┐ 管理台(浏览器) │ │ +│ Web 管理台 │ ─── /api/... ──────────────▶│ │ +│ (React) │ └──────────────┘ +└─────────────┘ +``` + +--- + +## 核心概念:屏幕 ID 关联 + +**每块显示屏由唯一「设备 ID」标识**(如 `SF-01`),这是后台屏幕与 App 关联的钥匙: + +1. 后台「屏幕管理」新增屏幕时**推荐填写设备 ID**(如 `SF-01`),屏幕列表会显示所有屏幕的设备 ID(可一键复制) +2. App 首次启动进入配置界面,填写**服务器地址 + 屏幕 ID**(与后台完全一致,含大小写) +3. App 保存时自动校验:屏幕 ID 不存在则提示错误,不会进入播放 +4. App 用该 ID 注册并拉取这块屏幕绑定的节目 → **多块屏各配各的 ID,各播各的节目** + +> 屏幕 ID 留空时,App 用设备硬件 ID(ANDROID_ID)自动注册新屏幕,后台会自动出现该设备,改名后绑定节目即可。 + +--- + +## 目录结构 + +``` +EasyScreen/ +├── server/ # FastAPI 后端 +│ ├── app/ +│ │ ├── main.py # 入口(API + /media + 管理台托管) +│ │ ├── config.py # settings.json 读取(多数据源切换) +│ │ ├── database.py # SQLAlchemy 引擎/会话 +│ │ ├── models.py # 数据表:screens/assets/playlists/items/bindings +│ │ ├── auth.py # JWT 登录鉴权 +│ │ └── routers/ # admin_auth/screens/assets/playlists/client +│ ├── settings.json # 数据库与服务器配置 +│ ├── uploads/ # 上传的素材文件(/media 访问) +│ ├── logs/crashes/ # App 崩溃上报日志(自动创建) +│ ├── scripts/ # start_prod.sh/.bat、stop_prod.sh +│ ├── requirements.txt +│ └── run_local.py # 本地开发启动(uvicorn --reload) +├── android/ # Android 客户端工程(Android Studio 打开) +│ └── app/src/main/java/com/easyscreen/player/ +│ ├── App.java # 全局崩溃捕获(本地记录 + 上报后端) +│ ├── MainActivity.java # 全屏播放调度(图片/视频轮播、心跳、定期重载) +│ ├── SetupActivity.java # 配置界面(服务器地址 + 屏幕 ID + 记住配置 + 校验) +│ ├── receiver/BootReceiver.java # 开机自启 +│ ├── api/ # OkHttp + Gson 网络层(回调自动切主线程) +│ └── util/ # Prefs / DeviceId / CrashHandler +└── web/ # React 管理台 + └── src/ + ├── pages/ # Login/Screens/Assets/Playlists + ├── components/Layout.jsx + └── api/client.js # axios 封装(token 持久化) +``` + +--- + +## 快速开始 + +### 1. 后端 + +```bash +cd server +python -m venv .venv +.venv\Scripts\pip install -r requirements.txt # Windows +.venv\Scripts\python run_local.py # 启动,端口 5889 +``` + +首次启动自动建表。默认使用 SQLite(`easyscreen.db`)零配置运行;切换 MySQL 见下方配置说明。 + +### 2. 管理台 + +生产模式(后端已托管管理台,构建一次即可): + +```bash +cd web +npm install +npm run build # 生成 web/dist,FastAPI 启动时自动托管 +``` + +开发模式(热更新): + +```bash +npm run dev # http://localhost:5173 (已代理 /api 和 /media 到 5889) +``` + +默认账号 `admin / admin123`(在 `server/settings.json` 的 `admin` 段修改)。 + +### 3. Android 客户端 + +用 Android Studio 打开 `android/` 目录,等 Gradle 同步完成后构建 APK 安装到显示屏设备(或直接使用 `app/build/outputs/apk/debug/app-debug.apk`)。 + +**首次配置**:打开 App 进入配置界面—— + +- **服务器地址**:如 `http://192.168.1.100:5889`(保存时自动测试连通性) +- **屏幕 ID(可选)**:后台「屏幕管理」中的设备 ID;保存时自动校验是否存在 +- **记住本次配置**(默认勾选):下次打开免输入,直接进入播放 + +**USB 调试小技巧**:手机 USB 连电脑时执行 `adb reverse tcp:5889 tcp:5889`,App 地址填 `http://127.0.0.1:5889` 即可直连电脑后端,无需局域网 IP。 + +--- + +## 使用流程 + +1. **登录管理台** → 上传素材(图片/视频)到「素材管理」 +2. 「节目编排」创建节目:左侧素材库**分页网格**可视化点选素材(图片缩略图/视频图标),右侧设置每项展示时长(0=自动:图片默认 10 秒,视频按自身时长)与播放顺序 +3. 「屏幕管理」将节目**绑定**到目标屏幕(每块屏可绑定不同节目) +4. 屏幕设备:开机自动注册 → 拉配置 → 循环播放;每 30 秒心跳上报,后台实时显示在线状态;后台修改节目后 App 10 分钟内自动生效(重启 App 立即生效) + +--- + +## 配置说明(server/settings.json) + +| 字段 | 说明 | +|---|---| +| `active_db` | 当前生效数据源:`dev_sqlite`(默认,无需 MySQL)/ `dev` / `production` | +| `databases.*` | 各数据源连接信息;MySQL 填 host/port/user/password/database_name,切换时改 `active_db` 即可,代码零改动 | +| `server.base_url` | **必须修改**:部署机器的局域网 IP + 端口(Android 设备通过它访问素材) | +| `server.max_upload_mb` | 单文件上传大小上限(默认 500MB) | +| `server.allowed_types` | 允许上传的 MIME 类型 | +| `admin.username/password` | 管理台登录账号密码(**上线前务必修改**) | +| `admin.token_secret` | JWT 签名密钥(**上线前务必改为随机字符串**) | + +--- + +## API 一览 + +在线文档:启动后访问 `http://localhost:5889/docs`(Swagger UI)。 + +**管理端**(需 `Authorization: Bearer `) + +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/api/auth/login` | 登录,返回 token | +| GET/POST | `/api/screens` | 屏幕列表 / 新增(推荐填写 device_id 用于 App 关联) | +| PUT/DELETE | `/api/screens/{id}` | 编辑 / 删除屏幕 | +| POST | `/api/screens/{id}/bind` | 绑定节目(替换当前生效节目) | +| DELETE | `/api/screens/{id}/bind` | 解除绑定 | +| GET/POST | `/api/assets` | 素材列表 / 上传(multipart) | +| DELETE | `/api/assets/{id}` | 删除素材(被节目引用时返回 409) | +| GET/POST | `/api/playlists` | 节目列表 / 新建 | +| PUT/DELETE | `/api/playlists/{id}` | 编辑(全量替换节目项)/ 删除 | + +**客户端**(无需鉴权,以 `device_id` 标识) + +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/api/client/register` | 设备注册(幂等,按 device_id 复用或创建屏幕) | +| GET | `/api/client/check?device_id=xxx` | 校验屏幕是否已存在(App 配置时提示用) | +| GET | `/api/client/config?device_id=xxx` | 拉取当前生效播放配置(含 version 版本号) | +| POST | `/api/client/heartbeat` | 心跳上报(30s 间隔,超 90s 视为离线) | +| POST | `/api/client/crash` | 崩溃上报(写入 server/logs/crashes/) | +| GET | `/media/{file}` | 素材文件访问 | + +--- + +## 生产部署 + +### 构建管理台并单端口托管 + +```bash +cd web && npm run build # 生成 web/dist +``` + +后端启动时若检测到 `web/dist` 存在,会自动托管管理台(SPA 路由回退到 index.html),生产环境只需暴露一个端口。 + +### 启动后端 + +```bash +cd server +scripts/start_prod.sh # Linux:gunicorn + 2 workers + 日志(logs/) +scripts/start_prod.bat # Windows:gunicorn 前台运行 +``` + +### MySQL 切换 + +在 `settings.json` 中创建数据库并填入连接信息,将 `active_db` 改为 `dev` 或 `production`: + +```sql +CREATE DATABASE easyscreen DEFAULT CHARACTER SET utf8mb4; +CREATE USER 'easyscreen'@'%' IDENTIFIED BY '你的密码'; +GRANT ALL PRIVILEGES ON easyscreen.* TO 'easyscreen'@'%'; +``` + +--- + +## 常见问题 + +- **App 打开闪退**:先看后端 `server/logs/crashes/` 是否有崩溃日志(App 会自动上报),或 `adb logcat` 抓 `AndroidRuntime` 段;常见为网络回调线程问题与 Glide/ExoPlayer 生命周期问题,最新版本已修复。 +- **App 提示"屏幕 ID 不存在"**:确认后台「屏幕管理」已创建该屏幕,且设备 ID 与 App 填写内容**完全一致**(含大小写)。 +- **管理台打不开**:确认 `web/` 下已执行 `npm run build`(或使用 `npm run dev` 开发模式)。 +- **客户端提示"连接服务器失败"**:检查 `settings.json` 的 `base_url` 是否为显示屏可达的局域网地址、防火墙是否放行端口。 +- **上传被拒**:检查文件类型是否在 `allowed_types` 内(视频建议 MP4/H.264 编码,老设备解码能力有限)。 +- **Android 6.0 播放卡顿**:优先使用 H.264 编码、分辨率不超过屏幕尺寸的 MP4;图片避免超大 PNG。 +- **删除素材失败 409**:素材正被节目引用,先在「节目编排」中移除。 diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..9617f48 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.easyscreen.player' + compileSdk 36 + + defaultConfig { + applicationId "com.easyscreen.player" + minSdk 23 // Android 6.0 + targetSdk 36 + versionCode 1 + versionName "1.0.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +// 统一 Kotlin 标准库版本,解决 okhttp(4.x)/glide 传递依赖导致的重复类冲突 +configurations.all { + resolutionStrategy { + force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22' + } +} + +dependencies { + // androidx 注解(@Nullable 等) + implementation 'androidx.annotation:annotation:1.6.0' + // 网络 + implementation 'com.squareup.okhttp3:okhttp:4.11.0' + // JSON + implementation 'com.google.code.gson:gson:2.10.1' + // 图片加载(本地缓存,断网仍可显示历史图片) + implementation 'com.github.bumptech.glide:glide:4.15.1' + // 视频播放(ExoPlayer 2.x,兼容 API 16+,老设备友好) + implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' + implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..1cd014f --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# 保留 Gson 反射所需类 +-keep class com.easyscreen.player.api.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f3376d8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/easyscreen/player/App.java b/android/app/src/main/java/com/easyscreen/player/App.java new file mode 100644 index 0000000..056e301 --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/App.java @@ -0,0 +1,15 @@ +package com.easyscreen.player; + +import android.app.Application; + +import com.easyscreen.player.util.CrashHandler; + +/** 应用入口:注册全局崩溃捕获(堆栈写入本地文件并上报后端)。 */ +public class App extends Application { + + @Override + public void onCreate() { + super.onCreate(); + Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(this)); + } +} diff --git a/android/app/src/main/java/com/easyscreen/player/MainActivity.java b/android/app/src/main/java/com/easyscreen/player/MainActivity.java new file mode 100644 index 0000000..84c2e59 --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/MainActivity.java @@ -0,0 +1,332 @@ +package com.easyscreen.player; + +import android.app.Activity; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import com.bumptech.glide.Glide; +import com.easyscreen.player.api.ApiClient; +import com.easyscreen.player.api.ClientModels; +import com.easyscreen.player.util.DeviceId; +import com.easyscreen.player.util.Prefs; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ui.PlayerView; + +import java.util.ArrayList; +import java.util.List; + +/** + * 电子菜单主播放界面: + * 1. 每次打开自动从远程加载配置(注册设备 -> 拉取配置) + * 2. 全屏轮播图片/视频 + * 3. 心跳上报 + 定期重载配置(后台改动后自动生效) + */ +public class MainActivity extends Activity { + + private static final long HEARTBEAT_INTERVAL_MS = 30_000L; + private static final long RELOAD_CONFIG_INTERVAL_MS = 10 * 60_000L; + private static final long RETRY_INTERVAL_MS = 30_000L; + + private final Handler handler = new Handler(Looper.getMainLooper()); + + private ImageView imageView; + private PlayerView playerView; + private View statusOverlay; + private TextView statusText; + private ProgressBar progressBar; + + private Prefs prefs; + private ApiClient api; + private String deviceId; + + private ExoPlayer exoPlayer; + private List items = new ArrayList<>(); + private int currentIndex = -1; + private long currentVersion = -1; + private boolean playing = false; + + private final Runnable nextRunnable = this::next; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + prefs = new Prefs(this); + // 设备标识:优先使用配置的「屏幕 ID」(与后台屏幕关联),未配置则用设备硬件 ID 自动注册 + String configuredScreenId = prefs.getScreenId(); + deviceId = (configuredScreenId != null && !configuredScreenId.isEmpty()) + ? configuredScreenId + : DeviceId.get(this); + + setupFullscreen(); + buildUi(); + + String serverUrl = prefs.getServerUrl(); + if (serverUrl.isEmpty()) { + // 首次启动:先去配置服务器地址 + startActivity(new android.content.Intent(this, SetupActivity.class)); + finish(); + return; + } + + api = new ApiClient(serverUrl); + startPlayback(); + } + + // ---------------- UI ---------------- + + private void buildUi() { + FrameLayout root = new FrameLayout(this); + + imageView = new ImageView(this); + imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + root.addView(imageView, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + + playerView = new PlayerView(this); + playerView.setResizeMode(com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM); + playerView.setVisibility(View.GONE); + root.addView(playerView, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + + // 状态覆盖层(加载中 / 等待配置 / 错误提示) + FrameLayout overlay = new FrameLayout(this); + overlay.setBackgroundColor(0x99000000); + overlay.setVisibility(View.GONE); + + progressBar = new ProgressBar(this); + FrameLayout.LayoutParams pbLp = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT); + pbLp.gravity = android.view.Gravity.CENTER; + overlay.addView(progressBar, pbLp); + + statusText = new TextView(this); + statusText.setTextColor(0xFFFFFFFF); + statusText.setTextSize(22); + statusText.setGravity(android.view.Gravity.CENTER); + FrameLayout.LayoutParams stLp = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); + stLp.gravity = android.view.Gravity.CENTER; + stLp.topMargin = 120; + overlay.addView(statusText, stLp); + + statusOverlay = overlay; + root.addView(overlay, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + + setContentView(root); + } + + private void setupFullscreen() { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + hideSystemUi(); + } + + private void hideSystemUi() { + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) hideSystemUi(); + } + + // ---------------- 播放调度 ---------------- + + private void startPlayback() { + // 首次拉取配置(注册 + 拉配置) + fetchConfig(); + // 心跳循环 + handler.postDelayed(new Runnable() { + @Override + public void run() { + api.heartbeat(deviceId, new ApiClient.ApiCallback() { + @Override + public void onSuccess(ClientModels.OkResponse result) { } + + @Override + public void onError(String message) { } + }); + handler.postDelayed(this, HEARTBEAT_INTERVAL_MS); + } + }, HEARTBEAT_INTERVAL_MS); + // 定期重载配置,后台变更自动生效 + handler.postDelayed(new Runnable() { + @Override + public void run() { + fetchConfig(); + handler.postDelayed(this, RELOAD_CONFIG_INTERVAL_MS); + } + }, RELOAD_CONFIG_INTERVAL_MS); + } + + private void fetchConfig() { + api.register(deviceId, DeviceId.displayName(this), new ApiClient.ApiCallback() { + @Override + public void onSuccess(ClientModels.OkResponse result) { + api.fetchConfig(deviceId, new ApiClient.ApiCallback() { + @Override + public void onSuccess(ClientModels.ClientConfig config) { + handleConfig(config); + } + + @Override + public void onError(String message) { + showError("拉取配置失败: " + message); + scheduleRetry(); + } + }); + } + + @Override + public void onError(String message) { + showError("连接服务器失败: " + message); + scheduleRetry(); + } + }); + } + + private void handleConfig(ClientModels.ClientConfig config) { + if (!config.configured || config.items == null || config.items.isEmpty()) { + // 尚未绑定节目:显示等待提示,由定期重载自动生效 + stopPlayback(); + items.clear(); + currentVersion = -1; + showWaiting("屏幕尚未配置节目,请到后台绑定"); + return; + } + if (config.version == currentVersion && playing) { + return; // 配置未变化 + } + items = config.items; + currentVersion = config.version; + currentIndex = -1; + hideOverlay(); + playing = true; + next(); + } + + private void next() { + if (!playing || items.isEmpty()) return; + currentIndex = (currentIndex + 1) % items.size(); + ClientModels.ConfigItem item = items.get(currentIndex); + if ("video".equals(item.type)) { + playVideo(item); + } else { + playImage(item); + } + } + + private void playImage(ClientModels.ConfigItem item) { + releasePlayer(); + playerView.setVisibility(View.GONE); + imageView.setVisibility(View.VISIBLE); + Glide.with(this) + .load(api.mediaUrl(item.url)) + .into(imageView); + int durationMs = Math.max(item.duration, 3) * 1000; // 至少 3 秒 + handler.removeCallbacks(nextRunnable); + handler.postDelayed(nextRunnable, durationMs); + } + + private void playVideo(ClientModels.ConfigItem item) { + imageView.setVisibility(View.GONE); + releasePlayer(); + + exoPlayer = new ExoPlayer.Builder(this).build(); + exoPlayer.setMediaItem(MediaItem.fromUri(api.mediaUrl(item.url))); + exoPlayer.addListener(new Player.Listener() { + @Override + public void onPlaybackStateChanged(int playbackState) { + if (playbackState == Player.STATE_ENDED) { + next(); + } + } + }); + playerView.setPlayer(exoPlayer); + playerView.setVisibility(View.VISIBLE); + exoPlayer.prepare(); + exoPlayer.setPlayWhenReady(true); + + // 若配置了展示时长(>0),视频也可定时强制切换 + if (item.duration > 0) { + handler.removeCallbacks(nextRunnable); + handler.postDelayed(nextRunnable, item.duration * 1000L); + } + } + + private void releasePlayer() { + handler.removeCallbacks(nextRunnable); + if (exoPlayer != null) { + exoPlayer.release(); + exoPlayer = null; + playerView.setPlayer(null); + } + } + + private void stopPlayback() { + playing = false; + releasePlayer(); + imageView.setVisibility(View.GONE); + playerView.setVisibility(View.GONE); + } + + // ---------------- 状态提示 ---------------- + + private void showWaiting(String message) { + progressBar.setVisibility(View.GONE); + statusText.setText(message); + statusOverlay.setVisibility(View.VISIBLE); + } + + private void showError(String message) { + playing = false; + stopPlayback(); + progressBar.setVisibility(View.GONE); + statusText.setText(message + "\n将自动重试..."); + statusOverlay.setVisibility(View.VISIBLE); + } + + private void hideOverlay() { + statusOverlay.setVisibility(View.GONE); + } + + private void scheduleRetry() { + handler.removeCallbacks(retryRunnable); + handler.postDelayed(retryRunnable, RETRY_INTERVAL_MS); + } + + private final Runnable retryRunnable = new Runnable() { + @Override + public void run() { + fetchConfig(); + } + }; + + @Override + protected void onDestroy() { + super.onDestroy(); + handler.removeCallbacksAndMessages(null); + releasePlayer(); + // 注意:不要在 onDestroy 里调用 Glide.with(this)——Activity 已销毁时 + // Glide.with(Activity) 会抛 IllegalArgumentException(Glide 会自动清理其资源) + } +} diff --git a/android/app/src/main/java/com/easyscreen/player/SetupActivity.java b/android/app/src/main/java/com/easyscreen/player/SetupActivity.java new file mode 100644 index 0000000..6f63dfd --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/SetupActivity.java @@ -0,0 +1,212 @@ +package com.easyscreen.player; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.view.Gravity; +import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +import com.easyscreen.player.util.Prefs; + +import org.json.JSONObject; + +import java.io.IOException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +/** + * 首次启动配置界面:输入后台服务器地址(如 http://192.168.1.100:5889)。 + * 保存前会测试 /api/health 连通性,通过后才保存并进入播放界面。 + */ +public class SetupActivity extends Activity { + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler handler = new Handler(Looper.getMainLooper()); + + private EditText urlInput; + private EditText screenIdInput; + private CheckBox rememberCheck; + private Button saveButton; + private TextView hintText; + private boolean saving = false; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setGravity(Gravity.CENTER); + root.setPadding(80, 80, 80, 80); + root.setBackgroundColor(Color.BLACK); + + TextView title = new TextView(this); + title.setText("EasyScreen 服务器配置"); + title.setTextColor(Color.WHITE); + title.setTextSize(26); + title.setGravity(Gravity.CENTER); + root.addView(title, lp(true)); + + urlInput = new EditText(this); + urlInput.setHint("http://192.168.1.100:5889"); + urlInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + urlInput.setTextColor(Color.WHITE); + urlInput.setHintTextColor(Color.GRAY); + urlInput.setSingleLine(true); + urlInput.setPadding(20, 20, 20, 20); + root.addView(urlInput, lp(true)); + + screenIdInput = new EditText(this); + screenIdInput.setHint("屏幕 ID(可选,如 screen-001)"); + screenIdInput.setTextColor(Color.WHITE); + screenIdInput.setHintTextColor(Color.GRAY); + screenIdInput.setSingleLine(true); + screenIdInput.setPadding(20, 20, 20, 20); + root.addView(screenIdInput, lp(true)); + + hintText = new TextView(this); + hintText.setText("请输入后端服务地址;若后台已建屏,可填写后台「屏幕管理」中的设备 ID 以关联该屏(留空则自动注册新屏)"); + hintText.setTextColor(Color.LTGRAY); + hintText.setTextSize(14); + hintText.setGravity(Gravity.CENTER); + root.addView(hintText, lp(true)); + + rememberCheck = new CheckBox(this); + rememberCheck.setText("记住本次配置(下次打开免输入,直接进入播放)"); + rememberCheck.setTextColor(Color.WHITE); + rememberCheck.setChecked(true); + root.addView(rememberCheck, lp(true)); + + saveButton = new Button(this); + saveButton.setText("保存并开始播放"); + saveButton.setAllCaps(false); + saveButton.setOnClickListener(v -> onSaveClicked()); + root.addView(saveButton, lp(true)); + + // 预填已保存的地址(重新进入时) + Prefs prefs = new Prefs(this); + String saved = prefs.getServerUrl(); + if (!saved.isEmpty()) urlInput.setText(saved); + String savedId = prefs.getScreenId(); + if (!savedId.isEmpty()) screenIdInput.setText(savedId); + + setContentView(root); + } + + private LinearLayout.LayoutParams lp(boolean horizontalMatch) { + return new LinearLayout.LayoutParams( + horizontalMatch ? LinearLayout.LayoutParams.MATCH_PARENT : LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + } + + private void onSaveClicked() { + if (saving) return; + String url = urlInput.getText().toString().trim(); + if (url.isEmpty()) { + Toast.makeText(this, "请输入服务器地址", Toast.LENGTH_SHORT).show(); + return; + } + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "http://" + url; + } + url = url.replaceAll("/+$", ""); + + final String finalUrl = url; + final String finalScreenId = screenIdInput.getText().toString().trim(); + final boolean remember = rememberCheck.isChecked(); + + // 提交型操作:立即 loading(禁用 + 提示),接口返回后复位 + saving = true; + saveButton.setEnabled(false); + saveButton.setText("正在校验配置..."); + hintText.setText(""); + + executor.execute(() -> { + boolean urlOk = testConnection(finalUrl); + boolean idOk = true; + if (urlOk && !finalScreenId.isEmpty()) { + idOk = checkScreenExists(finalUrl, finalScreenId); + } + final boolean fUrlOk = urlOk; + final boolean fIdOk = idOk; + handler.post(() -> { + saving = false; + saveButton.setEnabled(true); + saveButton.setText("保存并开始播放"); + if (!fUrlOk) { + hintText.setText("无法连接该地址,请检查服务器是否已启动、地址是否正确(含端口)"); + return; + } + if (!fIdOk) { + hintText.setText("屏幕 ID 不存在!请确认后台「屏幕管理」已创建该屏幕,且设备 ID 完全一致(含大小写)"); + return; + } + if (remember) { + new Prefs(this).setServerUrl(finalUrl); + new Prefs(this).setScreenId(finalScreenId); + Toast.makeText(this, "配置已保存,下次打开免输入", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "校验通过(本次有效,未保存)", Toast.LENGTH_SHORT).show(); + } + startActivity(new Intent(this, MainActivity.class)); + finish(); + }); + }); + } + + /** 校验屏幕 ID 是否在后台存在(同步)。接口异常时放行,避免误伤。 */ + private boolean checkScreenExists(String baseUrl, String deviceId) { + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(5, java.util.concurrent.TimeUnit.SECONDS) + .build(); + Request request = new Request.Builder() + .url(baseUrl + "/api/client/check?device_id=" + deviceId) + .get() + .build(); + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) return true; + String text = response.body() != null ? response.body().string() : "{}"; + JSONObject json = new JSONObject(text); + return json.optBoolean("exists", true); + } catch (Exception e) { + return true; // 网络异常放行(URL 连通性已单独校验) + } + } + + private boolean testConnection(String baseUrl) { + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(5, java.util.concurrent.TimeUnit.SECONDS) + .build(); + Request request = new Request.Builder().url(baseUrl + "/api/health").get().build(); + try (Response response = client.newCall(request).execute()) { + return response.isSuccessful(); + } catch (IOException e) { + return false; + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + executor.shutdownNow(); + handler.removeCallbacksAndMessages(null); + } +} diff --git a/android/app/src/main/java/com/easyscreen/player/api/ApiClient.java b/android/app/src/main/java/com/easyscreen/player/api/ApiClient.java new file mode 100644 index 0000000..133deb6 --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/api/ApiClient.java @@ -0,0 +1,127 @@ +package com.easyscreen.player.api; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.google.gson.Gson; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** 后端 HTTP 客户端封装(OkHttp 异步回调)。 */ +public class ApiClient { + + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final Gson GSON = new Gson(); + private static final int TIMEOUT_SECONDS = 15; + + private final OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build(); + + private final String baseUrl; // 如 http://192.168.1.100:5889(无尾斜杠) + + public ApiClient(String baseUrl) { + this.baseUrl = baseUrl.endsWith("/") + ? baseUrl.substring(0, baseUrl.length() - 1) + : baseUrl; + } + + public interface ApiCallback { + void onSuccess(T result); + + void onError(String message); + } + + private void get(String path, Class clazz, ApiCallback cb) { + Request request = new Request.Builder().url(baseUrl + path).get().build(); + enqueue(request, clazz, cb); + } + + private void postJson(String path, Object body, Class clazz, ApiCallback cb) { + Request request = new Request.Builder() + .url(baseUrl + path) + .post(RequestBody.create(GSON.toJson(body), JSON)) + .build(); + enqueue(request, clazz, cb); + } + + private void enqueue(Request request, Class clazz, ApiCallback cb) { + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + postToMain(() -> cb.onError("网络错误: " + e.getMessage())); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + try { + if (!response.isSuccessful()) { + postToMain(() -> cb.onError("服务器返回 " + response.code())); + return; + } + String text = response.body() != null ? response.body().string() : "{}"; + final T result = clazz == String.class + ? (T) text + : GSON.fromJson(text, clazz); + postToMain(() -> cb.onSuccess(result)); + } catch (Exception e) { + postToMain(() -> cb.onError("解析失败: " + e.getMessage())); + } finally { + response.close(); + } + } + }); + } + + /** 将回调切换到主线程执行(OkHttp 回调默认在 IO 线程,不能直接操作 UI)。 */ + private void postToMain(Runnable runnable) { + new Handler(Looper.getMainLooper()).post(runnable); + } + + /** 设备注册(幂等)。 */ + public void register(String deviceId, String deviceName, ApiCallback cb) { + // 后端 register 返回 {screen_id, registered},此处不关心具体字段,仅确认成功 + postJson("/api/client/register", new ClientModels.RegisterRequest(deviceId, deviceName), ClientModels.OkResponse.class, cb); + } + + /** 拉取播放配置。 */ + public void fetchConfig(String deviceId, ApiCallback cb) { + get("/api/client/config?device_id=" + deviceId, ClientModels.ClientConfig.class, cb); + } + + /** 心跳上报。 */ + public void heartbeat(String deviceId, ApiCallback cb) { + postJson("/api/client/heartbeat", new ClientModels.HeartbeatRequest(deviceId), ClientModels.OkResponse.class, cb); + } + + /** 崩溃上报(同步,短超时,确保 App 退出前请求发出)。 */ + public void reportCrash(ClientModels.CrashReportRequest body) throws IOException { + Request request = new Request.Builder() + .url(baseUrl + "/api/client/crash") + .post(RequestBody.create(GSON.toJson(body), JSON)) + .build(); + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + Log.d("EasyScreen", "crash report failed: " + response.code()); + } + } + } + + /** 拼接素材完整地址。 */ + public String mediaUrl(String relativeUrl) { + return baseUrl + relativeUrl; + } +} diff --git a/android/app/src/main/java/com/easyscreen/player/api/ClientModels.java b/android/app/src/main/java/com/easyscreen/player/api/ClientModels.java new file mode 100644 index 0000000..3884562 --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/api/ClientModels.java @@ -0,0 +1,73 @@ +package com.easyscreen.player.api; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +/** 与后端 /api/client 接口对应的数据模型(字段名与后端 JSON 一致)。 */ +public class ClientModels { + + /** 配置项:单个图片或视频。 */ + public static class ConfigItem { + @SerializedName("asset_id") + public long assetId; + @SerializedName("type") + public String type; // image / video + @SerializedName("url") + public String url; // 相对路径,如 /media/xxx.png + @SerializedName("name") + public String name; + @SerializedName("duration") + public int duration; // 秒;视频为 0 表示按视频自身时长 + } + + /** GET /api/client/config 响应。 */ + public static class ClientConfig { + @SerializedName("configured") + public boolean configured; + @SerializedName("playlist_id") + public Long playlistId; + @SerializedName("playlist_name") + public String playlistName; + @SerializedName("version") + public long version; + @SerializedName("items") + public List items; + } + + /** POST /api/client/register 请求。 */ + public static class RegisterRequest { + @SerializedName("device_id") + public String deviceId; + @SerializedName("name") + public String name; + + public RegisterRequest(String deviceId, String name) { + this.deviceId = deviceId; + this.name = name; + } + } + + /** POST /api/client/heartbeat 请求。 */ + public static class HeartbeatRequest { + @SerializedName("device_id") + public String deviceId; + + public HeartbeatRequest(String deviceId) { + this.deviceId = deviceId; + } + } + + public static class OkResponse { + @SerializedName("ok") + public boolean ok; + } + + /** POST /api/client/crash 请求。 */ + public static class CrashReportRequest { + @SerializedName("device_id") + public String deviceId; + @SerializedName("stacktrace") + public String stacktrace; + } +} diff --git a/android/app/src/main/java/com/easyscreen/player/receiver/BootReceiver.java b/android/app/src/main/java/com/easyscreen/player/receiver/BootReceiver.java new file mode 100644 index 0000000..214c17d --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/receiver/BootReceiver.java @@ -0,0 +1,20 @@ +package com.easyscreen.player.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.easyscreen.player.MainActivity; + +/** 开机自启:电子屏设备上电后自动进入播放界面。 */ +public class BootReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + Intent launch = new Intent(context, MainActivity.class); + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(launch); + } + } +} diff --git a/android/app/src/main/java/com/easyscreen/player/util/CrashHandler.java b/android/app/src/main/java/com/easyscreen/player/util/CrashHandler.java new file mode 100644 index 0000000..7b4fbdc --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/util/CrashHandler.java @@ -0,0 +1,71 @@ +package com.easyscreen.player.util; + +import android.content.Context; +import android.util.Log; + +import com.easyscreen.player.api.ApiClient; +import com.easyscreen.player.api.ClientModels; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * 全局崩溃捕获: + * 1. 堆栈写入应用私有目录 crash.log(无需权限) + * 2. 若已配置服务器地址,同步上报到后端 /api/client/crash(远程排查显示屏异常) + */ +public class CrashHandler implements Thread.UncaughtExceptionHandler { + + private final Context appContext; + private final Thread.UncaughtExceptionHandler defaultHandler; + + public CrashHandler(Context context) { + this.appContext = context.getApplicationContext(); + this.defaultHandler = Thread.getDefaultUncaughtExceptionHandler(); + } + + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + try { + String stack = Log.getStackTraceString(throwable); + saveToFile(stack); + reportToServer(stack); + } catch (Throwable ignored) { + // 上报过程自身异常不影响后续 + } + if (defaultHandler != null) { + defaultHandler.uncaughtException(thread, throwable); + } else { + android.os.Process.killProcess(android.os.Process.myPid()); + } + } + + private void saveToFile(String stack) { + try { + File file = new File(appContext.getFilesDir(), "crash.log"); + FileWriter writer = new FileWriter(file, true); + String ts = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(new Date()); + writer.write("===== " + ts + " =====\n" + stack + "\n"); + writer.close(); + } catch (IOException ignored) { + } + } + + private void reportToServer(String stack) { + Prefs prefs = new Prefs(appContext); + String serverUrl = prefs.getServerUrl(); + if (serverUrl.isEmpty()) return; + try { + ApiClient api = new ApiClient(serverUrl); + ClientModels.CrashReportRequest req = new ClientModels.CrashReportRequest(); + req.deviceId = DeviceId.get(appContext); + req.stacktrace = stack; + api.reportCrash(req); + } catch (Throwable ignored) { + } + } +} diff --git a/android/app/src/main/java/com/easyscreen/player/util/DeviceId.java b/android/app/src/main/java/com/easyscreen/player/util/DeviceId.java new file mode 100644 index 0000000..5a3b25d --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/util/DeviceId.java @@ -0,0 +1,25 @@ +package com.easyscreen.player.util; + +import android.content.Context; +import android.os.Build; +import android.provider.Settings; + +/** 设备唯一标识:优先 ANDROID_ID(无需权限,持久稳定)。 */ +public class DeviceId { + + public static String get(Context context) { + String androidId = Settings.Secure.getString( + context.getContentResolver(), Settings.Secure.ANDROID_ID); + if (androidId != null && !androidId.isEmpty()) { + return androidId; + } + // 极少数设备 ANDROID_ID 为空时的兜底 + String fallback = Build.BOARD + "-" + Build.BRAND + "-" + Build.DEVICE; + return String.valueOf(fallback.hashCode() & 0x7fffffff); + } + + /** 设备显示名(注册到后台,便于管理台识别)。 */ + public static String displayName(Context context) { + return Build.MODEL; + } +} diff --git a/android/app/src/main/java/com/easyscreen/player/util/Prefs.java b/android/app/src/main/java/com/easyscreen/player/util/Prefs.java new file mode 100644 index 0000000..3c22a2a --- /dev/null +++ b/android/app/src/main/java/com/easyscreen/player/util/Prefs.java @@ -0,0 +1,43 @@ +package com.easyscreen.player.util; + +import android.content.Context; +import android.content.SharedPreferences; + +/** 本地偏好存储:服务器地址等。 */ +public class Prefs { + + private static final String NAME = "easyscreen_prefs"; + private static final String KEY_SERVER_URL = "server_url"; + private static final String KEY_SCREEN_ID = "screen_id"; + private static final String KEY_DEVICE_NAME = "device_name"; + + private final SharedPreferences sp; + + public Prefs(Context context) { + sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); + } + + public String getServerUrl() { + return sp.getString(KEY_SERVER_URL, ""); + } + + public void setServerUrl(String url) { + sp.edit().putString(KEY_SERVER_URL, url.trim()).apply(); + } + + public String getDeviceName() { + return sp.getString(KEY_DEVICE_NAME, ""); + } + + public void setDeviceName(String name) { + sp.edit().putString(KEY_DEVICE_NAME, name).apply(); + } + + public String getScreenId() { + return sp.getString(KEY_SCREEN_ID, ""); + } + + public void setScreenId(String screenId) { + sp.edit().putString(KEY_SCREEN_ID, screenId == null ? "" : screenId.trim()).apply(); + } +} diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..e4c43b5 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..59d0abd Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..efc5232 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..02d5c26 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ef9ef9c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..167f158 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + EasyScreen + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..acb03ad --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..5b7fabb --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,4 @@ +// Top-level build file +plugins { + id 'com.android.application' version '8.9.2' apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..3c5e113 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..81aa1c0 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..38bffe2 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + // 国内镜像优先(阿里云),加速依赖下载 + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } + maven { url 'https://maven.aliyun.com/repository/public' } + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/public' } + google() + mavenCentral() + } +} +rootProject.name = "EasyScreen" +include ':app' diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/auth.py b/server/app/auth.py new file mode 100644 index 0000000..2e54d03 --- /dev/null +++ b/server/app/auth.py @@ -0,0 +1,44 @@ +"""管理台认证:JWT 签发与校验(内网管理场景的轻量方案)。""" +from datetime import datetime, timedelta, timezone + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from .config import get_admin_config + +ALGORITHM = "HS256" +TOKEN_TTL_HOURS = 24 + +_bearer = HTTPBearer(auto_error=False) + + +def create_token(username: str) -> str: + cfg = get_admin_config() + payload = { + "sub": username, + "exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS), + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, cfg["token_secret"], algorithm=ALGORITHM) + + +def verify_credentials(username: str, password: str) -> bool: + cfg = get_admin_config() + return username == cfg.get("username") and password == cfg.get("password") + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> str: + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") + try: + payload = jwt.decode( + credentials.credentials, + get_admin_config()["token_secret"], + algorithms=[ALGORITHM], + ) + return payload["sub"] + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录") diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..bb36283 --- /dev/null +++ b/server/app/config.py @@ -0,0 +1,42 @@ +"""应用配置:读取 settings.json,支持多数据源切换(不硬编码连接信息)。""" +import json +import os +from functools import lru_cache + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_SETTINGS_PATH = os.path.join(os.path.dirname(BASE_DIR), "settings.json") + + +@lru_cache(maxsize=1) +def load_settings() -> dict: + path = os.environ.get("EASYSCREEN_SETTINGS", DEFAULT_SETTINGS_PATH) + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def get_active_db() -> dict: + settings = load_settings() + name = settings["active_db"] + return settings["databases"][name] + + +def get_database_url() -> str: + db = get_active_db() + if db["type"] == "sqlite": + return f"sqlite:///{os.path.join(os.path.dirname(BASE_DIR), db['path'])}" + if db["type"] == "mysql": + password = db.get("password", "") + auth = db["user"] if not password else f"{db['user']}:{password}" + return ( + f"mysql+pymysql://{auth}@{db['host']}:{db['port']}/" + f"{db['database_name']}?charset={db.get('charset', 'utf8mb4')}" + ) + raise ValueError(f"不支持的数据库类型: {db['type']}") + + +def get_server_config() -> dict: + return load_settings()["server"] + + +def get_admin_config() -> dict: + return load_settings()["admin"] diff --git a/server/app/database.py b/server/app/database.py new file mode 100644 index 0000000..5c207ca --- /dev/null +++ b/server/app/database.py @@ -0,0 +1,37 @@ +"""数据库连接与会话管理(SQLAlchemy 2.0)。""" +from sqlalchemy import create_engine, event +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from .config import get_database_url + +_database_url = get_database_url() + +_engine = create_engine( + _database_url, + pool_pre_ping=True, + connect_args={"check_same_thread": False} if _database_url.startswith("sqlite") else {}, + echo=False, +) + + +@event.listens_for(_engine, "connect") +def _enable_sqlite_fk(dbapi_connection, connection_record): + """SQLite 默认关闭外键约束,需显式开启以支持 CASCADE。""" + if _database_url.startswith("sqlite"): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + +SessionLocal = sessionmaker(bind=_engine, autocommit=False, autoflush=False) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..64f8614 --- /dev/null +++ b/server/app/main.py @@ -0,0 +1,62 @@ +"""EasyScreen 后端入口。""" +import os + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from .config import get_server_config +from .database import Base, _engine +from .routers import admin_assets, admin_auth, admin_playlists, admin_screens, client + +app = FastAPI(title="EasyScreen 电子菜单后台", version="1.0.0") + +# 开发期允许前端跨域(Vite dev server);生产建议同域部署或收紧来源 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 自动建表 +Base.metadata.create_all(bind=_engine) + +# 媒体文件访问(客户端播放地址 /media/{file}) +_upload_dir = os.path.abspath( + os.path.join(os.path.dirname(os.path.dirname(__file__)), get_server_config().get("upload_dir", "./uploads")) +) +os.makedirs(_upload_dir, exist_ok=True) +app.mount("/media", StaticFiles(directory=_upload_dir), name="media") + +app.include_router(admin_auth.router) +app.include_router(admin_screens.router) +app.include_router(admin_assets.router) +app.include_router(admin_playlists.router) +app.include_router(client.router) + + +@app.get("/api/health") +def health(): + return {"status": "ok"} + + +# ---------------- 管理台静态托管(生产单端口部署) ---------------- +# 若存在 web/dist(npm run build 产物),则由本服务直接托管管理台。 +# 注意:/api 与 /media 路由已在上方注册,此处 catch-all 仅兜底前端路由。 +_WEB_DIST = os.path.abspath( + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "web", "dist") +) + + +@app.get("/{full_path:path}", include_in_schema=False) +def serve_web(full_path: str): + if not os.path.isdir(_WEB_DIST): + return {"message": "EasyScreen API 运行中。管理台未构建:请在 web/ 目录执行 npm run build"} + # 优先返回真实文件(JS/CSS/图片等),其余路径回退到 index.html(SPA 路由) + file_path = os.path.join(_WEB_DIST, full_path) + if full_path and os.path.isfile(file_path): + return FileResponse(file_path) + return FileResponse(os.path.join(_WEB_DIST, "index.html")) diff --git a/server/app/models.py b/server/app/models.py new file mode 100644 index 0000000..4842ccc --- /dev/null +++ b/server/app/models.py @@ -0,0 +1,98 @@ +"""数据模型:屏幕设备、素材、节目、节目项、屏幕绑定。""" +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .database import Base + + +class Screen(Base): + """显示屏设备。device_id 由客户端首次启动时生成并注册。""" + + __tablename__ = "screens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), default="未命名屏幕") + device_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + location: Mapped[str] = mapped_column(String(200), default="") + status: Mapped[str] = mapped_column(String(20), default="offline") # online/offline + last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + bindings: Mapped[list["ScreenBinding"]] = relationship( + back_populates="screen", cascade="all, delete-orphan" + ) + + +class Asset(Base): + """媒体素材:图片或视频。url 为相对于 /media 的路径。""" + + __tablename__ = "assets" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(200)) + type: Mapped[str] = mapped_column(String(20)) # image / video + url: Mapped[str] = mapped_column(String(500)) + size: Mapped[int] = mapped_column(Integer, default=0) + duration: Mapped[int] = mapped_column(Integer, default=0) # 图片默认展示秒数;视频为0表示按视频时长 + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + items: Mapped[list["PlaylistItem"]] = relationship(back_populates="asset") + + +class Playlist(Base): + """节目:一组素材的轮播清单。""" + + __tablename__ = "playlists" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100)) + description: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + items: Mapped[list["PlaylistItem"]] = relationship( + back_populates="playlist", cascade="all, delete-orphan", order_by="PlaylistItem.sort_order" + ) + bindings: Mapped[list["ScreenBinding"]] = relationship( + back_populates="playlist", cascade="all, delete-orphan" + ) + + +class PlaylistItem(Base): + """节目项:素材 + 排序 + 可选展示时长覆盖。""" + + __tablename__ = "playlist_items" + __table_args__ = (UniqueConstraint("playlist_id", "sort_order", name="uq_playlist_sort"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"), index=True) + asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + duration: Mapped[int | None] = mapped_column(Integer, nullable=True) # 覆盖素材默认时长(秒) + + playlist: Mapped[Playlist] = relationship(back_populates="items") + asset: Mapped[Asset] = relationship(back_populates="items") + + +class ScreenBinding(Base): + """屏幕与节目的绑定。一个屏幕同时只有一个 active=True 的绑定。""" + + __tablename__ = "screen_bindings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + screen_id: Mapped[int] = mapped_column(ForeignKey("screens.id", ondelete="CASCADE"), index=True) + playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"), index=True) + active: Mapped[bool] = mapped_column(Boolean, default=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now) + + screen: Mapped[Screen] = relationship(back_populates="bindings") + playlist: Mapped[Playlist] = relationship(back_populates="bindings") diff --git a/server/app/routers/__init__.py b/server/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/routers/admin_assets.py b/server/app/routers/admin_assets.py new file mode 100644 index 0000000..e15b5b4 --- /dev/null +++ b/server/app/routers/admin_assets.py @@ -0,0 +1,92 @@ +"""管理端:素材上传与管理。""" +import os +import uuid + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from ..auth import get_current_user +from ..config import get_server_config +from ..database import get_db +from ..models import Asset, PlaylistItem +from ..schemas import AssetOut + +router = APIRouter(prefix="/api/assets", tags=["assets"], dependencies=[Depends(get_current_user)]) + +_EXT_MAP = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/x-matroska": ".mkv", +} + + +@router.get("", response_model=list[AssetOut]) +def list_assets(db: Session = Depends(get_db)): + return db.scalars(select(Asset).order_by(Asset.created_at.desc())).all() + + +@router.post("", response_model=AssetOut) +async def upload_asset(file: UploadFile = File(...), db: Session = Depends(get_db)): + """上传图片/视频素材。""" + cfg = get_server_config() + content_type = file.content_type or "" + ext = _EXT_MAP.get(content_type) + if not ext: + raise HTTPException(status_code=400, detail=f"不支持的素材类型: {content_type or '未知'}") + + max_bytes = cfg.get("max_upload_mb", 500) * 1024 * 1024 + # 流式读取并限制大小 + data = b"" + while chunk := await file.read(1024 * 1024): + data += chunk + if len(data) > max_bytes: + raise HTTPException(status_code=413, detail=f"文件超过大小限制 {cfg['max_upload_mb']}MB") + + filename = f"{uuid.uuid4().hex}{ext}" + upload_dir = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), cfg.get("upload_dir", "./uploads"))) + os.makedirs(upload_dir, exist_ok=True) + dest = os.path.join(upload_dir, filename) + with open(dest, "wb") as f: + f.write(data) + + is_video = content_type.startswith("video/") + asset = Asset( + name=file.filename or filename, + type="video" if is_video else "image", + url=f"/media/{filename}", + size=len(data), + duration=0 if is_video else 10, # 图片默认展示 10 秒 + ) + db.add(asset) + db.commit() + db.refresh(asset) + return asset + + +@router.delete("/{asset_id}") +def delete_asset(asset_id: int, db: Session = Depends(get_db)): + asset = db.get(Asset, asset_id) + if not asset: + raise HTTPException(status_code=404, detail="素材不存在") + + used = db.scalar(select(func.count(PlaylistItem.id)).where(PlaylistItem.asset_id == asset_id)) + if used: + raise HTTPException( + status_code=409, + detail=f"该素材正被 {used} 个节目引用,请先从节目中移除再删除", + ) + + cfg = get_server_config() + upload_dir = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), cfg.get("upload_dir", "./uploads"))) + file_path = os.path.join(upload_dir, os.path.basename(asset.url)) + if os.path.exists(file_path): + os.remove(file_path) + + db.delete(asset) + db.commit() + return {"ok": True} diff --git a/server/app/routers/admin_auth.py b/server/app/routers/admin_auth.py new file mode 100644 index 0000000..4ecfb25 --- /dev/null +++ b/server/app/routers/admin_auth.py @@ -0,0 +1,14 @@ +"""管理台登录。""" +from fastapi import APIRouter, HTTPException + +from ..auth import create_token, verify_credentials +from ..schemas import LoginRequest, LoginResponse + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +def login(body: LoginRequest): + if not verify_credentials(body.username, body.password): + raise HTTPException(status_code=401, detail="用户名或密码错误") + return LoginResponse(token=create_token(body.username)) diff --git a/server/app/routers/admin_playlists.py b/server/app/routers/admin_playlists.py new file mode 100644 index 0000000..744a1b9 --- /dev/null +++ b/server/app/routers/admin_playlists.py @@ -0,0 +1,89 @@ +"""管理端:节目(播放清单)CRUD。""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import delete, select +from sqlalchemy.orm import Session, selectinload + +from ..auth import get_current_user +from ..database import get_db +from ..models import Asset, Playlist, PlaylistItem, ScreenBinding +from ..schemas import PlaylistCreate, PlaylistItemIn, PlaylistOut, PlaylistUpdate + +router = APIRouter(prefix="/api/playlists", tags=["playlists"], dependencies=[Depends(get_current_user)]) + + +def _get_playlist_or_404(db: Session, playlist_id: int) -> Playlist: + playlist = db.get(Playlist, playlist_id, options=[selectinload(Playlist.items).selectinload(PlaylistItem.asset)]) + if not playlist: + raise HTTPException(status_code=404, detail="节目不存在") + return playlist + + +def _replace_items(db: Session, playlist_id: int, items: list[PlaylistItemIn]): + """全量替换节目项。""" + asset_ids = {item.asset_id for item in items} + if asset_ids: + found = set(db.scalars(select(Asset.id).where(Asset.id.in_(asset_ids))).all()) + missing = asset_ids - found + if missing: + raise HTTPException(status_code=400, detail=f"素材不存在: {sorted(missing)}") + + db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == playlist_id)) + for idx, item in enumerate(items): + db.add( + PlaylistItem( + playlist_id=playlist_id, + asset_id=item.asset_id, + sort_order=idx, + duration=item.duration, + ) + ) + + +@router.get("", response_model=list[PlaylistOut]) +def list_playlists(db: Session = Depends(get_db)): + return db.scalars( + select(Playlist) + .options(selectinload(Playlist.items).selectinload(PlaylistItem.asset)) + .order_by(Playlist.created_at.desc()) + ).all() + + +@router.get("/{playlist_id}", response_model=PlaylistOut) +def get_playlist(playlist_id: int, db: Session = Depends(get_db)): + return _get_playlist_or_404(db, playlist_id) + + +@router.post("", response_model=PlaylistOut) +def create_playlist(body: PlaylistCreate, db: Session = Depends(get_db)): + playlist = Playlist(name=body.name, description=body.description) + db.add(playlist) + db.flush() + _replace_items(db, playlist.id, body.items) + db.commit() + return _get_playlist_or_404(db, playlist.id) + + +@router.put("/{playlist_id}", response_model=PlaylistOut) +def update_playlist(playlist_id: int, body: PlaylistUpdate, db: Session = Depends(get_db)): + playlist = _get_playlist_or_404(db, playlist_id) + if body.name is not None: + playlist.name = body.name + if body.description is not None: + playlist.description = body.description + if body.items is not None: + _replace_items(db, playlist.id, body.items) + db.commit() + return _get_playlist_or_404(db, playlist_id) + + +@router.delete("/{playlist_id}") +def delete_playlist(playlist_id: int, db: Session = Depends(get_db)): + playlist = _get_playlist_or_404(db, playlist_id) + has_active_binding = ( + db.scalar(select(ScreenBinding.id).where(ScreenBinding.playlist_id == playlist_id, ScreenBinding.active == True)) # noqa: E712 + is not None + ) + db.execute(delete(ScreenBinding).where(ScreenBinding.playlist_id == playlist_id)) + db.delete(playlist) + db.commit() + return {"ok": True, "unbound_screens": has_active_binding} diff --git a/server/app/routers/admin_screens.py b/server/app/routers/admin_screens.py new file mode 100644 index 0000000..38ac3ad --- /dev/null +++ b/server/app/routers/admin_screens.py @@ -0,0 +1,147 @@ +"""管理端:屏幕 CRUD、绑定节目。""" +import uuid +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from ..auth import get_current_user +from ..database import get_db +from ..models import Playlist, Screen, ScreenBinding +from ..schemas import ( + BindRequest, + PlaylistOut, + ScreenCreate, + ScreenDetail, + ScreenOut, + ScreenUpdate, +) + +router = APIRouter(prefix="/api/screens", tags=["screens"], dependencies=[Depends(get_current_user)]) + +ONLINE_WINDOW = timedelta(seconds=90) # 与客户端心跳间隔保持一致 + + +def _apply_online_status(screen: Screen) -> Screen: + """根据最后心跳时间动态刷新在线状态(不落库,仅展示层)。""" + if screen.last_seen and datetime.now() - screen.last_seen <= ONLINE_WINDOW: + screen.status = "online" + else: + screen.status = "offline" + return screen + + +def _get_screen_or_404(db: Session, screen_id: int) -> Screen: + screen = db.get(Screen, screen_id) + if not screen: + raise HTTPException(status_code=404, detail="屏幕不存在") + return screen + + +@router.get("", response_model=list[ScreenDetail]) +def list_screens(db: Session = Depends(get_db)): + screens = db.scalars( + select(Screen) + .options(selectinload(Screen.bindings).selectinload(ScreenBinding.playlist)) + .order_by(Screen.id) + ).all() + result = [] + for s in screens: + _apply_online_status(s) + d = ScreenDetail.model_validate(s) + active = next((b for b in s.bindings if b.active), None) + if active and active.playlist: + d.playlist = PlaylistOut.model_validate(active.playlist) + result.append(d) + return result + + +@router.get("/{screen_id}", response_model=ScreenDetail) +def get_screen(screen_id: int, db: Session = Depends(get_db)): + screen = db.get( + Screen, + screen_id, + options=[selectinload(Screen.bindings).selectinload(ScreenBinding.playlist).selectinload(Playlist.items)], + ) + if not screen: + raise HTTPException(status_code=404, detail="屏幕不存在") + _apply_online_status(screen) + detail = ScreenDetail.model_validate(screen) + active = next((b for b in screen.bindings if b.active), None) + if active and active.playlist: + detail.playlist = PlaylistOut.model_validate(active.playlist) + return detail + + +@router.post("", response_model=ScreenOut) +def create_screen(body: ScreenCreate, db: Session = Depends(get_db)): + """手动创建屏幕(可选指定 device_id 用于客户端注册匹配)。""" + device_id = body.device_id + if not device_id: + # 未指定设备 ID 时:先写临时 UUID 满足 NOT NULL 约束,flush 拿到 id 后改为 pre-{id} + screen = Screen(name=body.name, location=body.location, device_id=f"tmp-{uuid.uuid4().hex}") + db.add(screen) + db.flush() + screen.device_id = f"pre-{screen.id}" + else: + exists = db.scalars(select(Screen).where(Screen.device_id == device_id)).first() + if exists: + raise HTTPException(status_code=409, detail=f"device_id 已存在(屏幕: {exists.name})") + screen = Screen(name=body.name, location=body.location, device_id=device_id) + db.add(screen) + db.commit() + db.refresh(screen) + return screen + + +@router.put("/{screen_id}", response_model=ScreenOut) +def update_screen(screen_id: int, body: ScreenUpdate, db: Session = Depends(get_db)): + screen = _get_screen_or_404(db, screen_id) + if body.name is not None: + screen.name = body.name + if body.location is not None: + screen.location = body.location + db.commit() + db.refresh(screen) + return screen + + +@router.delete("/{screen_id}") +def delete_screen(screen_id: int, db: Session = Depends(get_db)): + screen = _get_screen_or_404(db, screen_id) + db.delete(screen) + db.commit() + return {"ok": True} + + +@router.post("/{screen_id}/bind", response_model=ScreenDetail) +def bind_playlist(screen_id: int, body: BindRequest, db: Session = Depends(get_db)): + """绑定节目到屏幕(置为 active,其它绑定取消 active)。""" + screen = _get_screen_or_404(db, screen_id) + playlist = db.get(Playlist, body.playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="节目不存在") + + for b in screen.bindings: + b.active = False + existing = next((b for b in screen.bindings if b.playlist_id == body.playlist_id), None) + if existing: + existing.active = True + else: + db.add(ScreenBinding(screen_id=screen.id, playlist_id=body.playlist_id, active=True)) + db.commit() + + detail = get_screen(screen_id, db) + return detail + + +@router.delete("/{screen_id}/bind") +def unbind_playlist(screen_id: int, db: Session = Depends(get_db)): + """解除屏幕当前绑定。""" + screen = _get_screen_or_404(db, screen_id) + for b in screen.bindings: + if b.active: + db.delete(b) + db.commit() + return {"ok": True} diff --git a/server/app/routers/client.py b/server/app/routers/client.py new file mode 100644 index 0000000..87dfc41 --- /dev/null +++ b/server/app/routers/client.py @@ -0,0 +1,118 @@ +"""客户端接口:设备注册、拉取配置、心跳上报、崩溃上报。无鉴权,以 device_id 标识设备。""" +import os +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from ..database import get_db +from ..models import Playlist, PlaylistItem, Screen, ScreenBinding +from ..schemas import ( + ClientConfigItem, + ClientConfigResponse, + ClientHeartbeatRequest, + ClientRegisterRequest, + CrashReportRequest, +) + +router = APIRouter(prefix="/api/client", tags=["client"]) + +HEARTBEAT_ONLINE_SECONDS = 90 # 心跳间隔建议 30-60 秒 + + +@router.post("/register") +def register(body: ClientRegisterRequest, db: Session = Depends(get_db)): + """客户端首次启动时注册设备。device_id 已存在则更新名称(若为占位名)。""" + if not body.device_id or len(body.device_id) > 64: + raise HTTPException(status_code=400, detail="device_id 非法") + + screen = db.scalar(select(Screen).where(Screen.device_id == body.device_id)) + if screen: + if body.name and (screen.name.startswith("pre-") or screen.name == "未命名屏幕"): + screen.name = body.name[:100] + db.commit() + return {"screen_id": screen.id, "registered": True} + + screen = Screen( + name=(body.name or "未命名屏幕")[:100], + device_id=body.device_id, + status="online", + last_seen=datetime.now(), + ) + db.add(screen) + db.commit() + db.refresh(screen) + return {"screen_id": screen.id, "registered": True} + + +@router.get("/check") +def check_screen(device_id: str, db: Session = Depends(get_db)): + """校验屏幕(设备 ID)是否已存在,供 App 端配置时提示。只查询,不创建。""" + screen = db.scalar(select(Screen.id).where(Screen.device_id == device_id)) + return {"exists": screen is not None} + + +@router.get("/config", response_model=ClientConfigResponse) +def get_client_config(device_id: str, db: Session = Depends(get_db)): + """返回该屏幕当前生效的播放配置。""" + screen = db.scalar( + select(Screen) + .where(Screen.device_id == device_id) + .options(selectinload(Screen.bindings).selectinload(ScreenBinding.playlist)) + ) + if not screen: + raise HTTPException(status_code=404, detail="设备未注册") + + binding = next((b for b in screen.bindings if b.active), None) + if not binding or not binding.playlist: + return ClientConfigResponse(configured=False, version=0, items=[]) + + playlist: Playlist = db.get( + Playlist, + binding.playlist_id, + options=[selectinload(Playlist.items).selectinload(PlaylistItem.asset)], + ) + items = [ + ClientConfigItem( + asset_id=item.asset_id, + type=item.asset.type, + url=item.asset.url, + name=item.asset.name, + duration=item.duration if item.duration is not None else item.asset.duration, + ) + for item in playlist.items + ] + version = int(binding.updated_at.timestamp()) if binding.updated_at else int(datetime.now().timestamp()) + return ClientConfigResponse( + configured=True, + playlist_id=playlist.id, + playlist_name=playlist.name, + version=version, + items=items, + ) + + +@router.post("/heartbeat") +def heartbeat(body: ClientHeartbeatRequest, db: Session = Depends(get_db)): + """心跳上报,更新在线状态。未注册设备也返回 ok(客户端先注册再心跳)。""" + screen = db.scalar(select(Screen).where(Screen.device_id == body.device_id)) + if screen: + screen.status = "online" + screen.last_seen = datetime.now() + db.commit() + return {"ok": True} + + +@router.post("/crash") +def report_crash(body: CrashReportRequest): + """客户端崩溃上报:堆栈写入 logs/crashes/ 目录,便于远程排查显示屏异常。""" + crash_dir = os.path.abspath( + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs", "crashes") + ) + os.makedirs(crash_dir, exist_ok=True) + safe_id = "".join(c for c in body.device_id if c.isalnum() or c in "-_")[:40] or "unknown" + filename = f"{safe_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" + with open(os.path.join(crash_dir, filename), "w", encoding="utf-8") as f: + f.write(f"device_id: {body.device_id}\ntime: {datetime.now().isoformat()}\n\n{body.stacktrace}\n") + return {"ok": True} diff --git a/server/app/schemas.py b/server/app/schemas.py new file mode 100644 index 0000000..fcffdd3 --- /dev/null +++ b/server/app/schemas.py @@ -0,0 +1,130 @@ +"""Pydantic 请求/响应模型。""" +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class ScreenCreate(BaseModel): + name: str = "未命名屏幕" + location: str = "" + device_id: str | None = None # 可选:预建屏幕时手动指定客户端设备 ID + + +class ScreenUpdate(BaseModel): + name: str | None = None + location: str | None = None + + +class ScreenOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + device_id: str + location: str + status: str + last_seen: datetime | None + created_at: datetime + + +class ScreenDetail(ScreenOut): + """屏幕详情:附带当前绑定的节目。""" + + playlist: "PlaylistOut | None" = None + + +class AssetOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + type: str + url: str + size: int + duration: int + created_at: datetime + + +class PlaylistItemIn(BaseModel): + asset_id: int + duration: int | None = None # None 表示使用素材默认时长 + + +class PlaylistCreate(BaseModel): + name: str + description: str = "" + items: list[PlaylistItemIn] = [] + + +class PlaylistUpdate(BaseModel): + name: str | None = None + description: str | None = None + items: list[PlaylistItemIn] | None = None + + +class PlaylistItemOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + asset_id: int + sort_order: int + duration: int | None + asset: AssetOut + + +class PlaylistOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + description: str + created_at: datetime + items: list[PlaylistItemOut] = [] + + +class BindRequest(BaseModel): + playlist_id: int + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + token: str + + +# ---------------- 客户端接口模型 ---------------- + +class ClientRegisterRequest(BaseModel): + device_id: str + name: str | None = None + + +class ClientConfigItem(BaseModel): + asset_id: int + type: str # image / video + url: str # 相对路径,客户端拼接服务器 base_url + name: str + duration: int # 秒;视频为 0 表示按视频自身时长 + + +class ClientConfigResponse(BaseModel): + configured: bool # False 表示该屏幕尚未绑定节目 + playlist_id: int | None = None + playlist_name: str | None = None + version: int # 绑定更新计数,客户端可缓存判断 + items: list[ClientConfigItem] = [] + + +class ClientHeartbeatRequest(BaseModel): + device_id: str + + +class CrashReportRequest(BaseModel): + device_id: str + stacktrace: str + + +ScreenDetail.model_rebuild() diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..e2f229b --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +gunicorn>=21.2 +sqlalchemy>=2.0 +pymysql>=1.1 +python-multipart>=0.0.9 +PyJWT>=2.8 diff --git a/server/run_local.py b/server/run_local.py new file mode 100644 index 0000000..9d2a7f6 --- /dev/null +++ b/server/run_local.py @@ -0,0 +1,13 @@ +"""本地开发启动入口:python run_local.py""" +import uvicorn + +from app.config import get_server_config + +if __name__ == "__main__": + cfg = get_server_config() + uvicorn.run( + "app.main:app", + host=cfg.get("host", "0.0.0.0"), + port=cfg.get("port", 5889), + reload=True, + ) diff --git a/server/scripts/start_prod.bat b/server/scripts/start_prod.bat new file mode 100644 index 0000000..961fc92 --- /dev/null +++ b/server/scripts/start_prod.bat @@ -0,0 +1,10 @@ +@echo off +rem 生产启动(Windows):gunicorn 在 Windows 不支持 --daemon,前台运行 +cd /d %~dp0.. +if not exist logs mkdir logs +if not exist .venv\Scripts\gunicorn.exe ( + echo 未找到 .venv,请先执行: python -m venv .venv ^&^& .venv\Scripts\pip install -r requirements.txt + exit /b 1 +) +echo 启动 EasyScreen 生产服务,端口 5889 ... +".venv\Scripts\gunicorn.exe" app.main:app -k uvicorn.workers.UvicornWorker -b 0.0.0.0:5889 -w 2 --access-logfile logs/access.log --error-logfile logs/error.log diff --git a/server/scripts/start_prod.sh b/server/scripts/start_prod.sh new file mode 100644 index 0000000..392077b --- /dev/null +++ b/server/scripts/start_prod.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# 生产启动(Linux / 正式环境):gunicorn + UvicornWorker + 日志文件 +set -e +cd "$(dirname "$0")/.." + +if [ ! -d .venv ]; then + echo "未找到 .venv,请先创建虚拟环境并安装依赖" + exit 1 +fi +mkdir -p logs +source .venv/bin/activate + +PORT="${PORT:-5889}" +echo "启动 EasyScreen 生产服务,端口 $PORT ..." +gunicorn app.main:app \ + -k uvicorn.workers.UvicornWorker \ + -b 0.0.0.0:$PORT \ + -w 2 \ + --daemon \ + --pid logs/gunicorn.pid \ + --access-logfile logs/access.log \ + --error-logfile logs/error.log + +sleep 2 +if [ -f logs/gunicorn.pid ]; then + echo "已启动 (pid $(cat logs/gunicorn.pid)),日志: logs/access.log" +else + echo "启动失败,请查看 logs/error.log" +fi diff --git a/server/scripts/stop_prod.sh b/server/scripts/stop_prod.sh new file mode 100644 index 0000000..4d5f455 --- /dev/null +++ b/server/scripts/stop_prod.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# 停止生产服务 +cd "$(dirname "$0")/.." +if [ -f logs/gunicorn.pid ]; then + kill "$(cat logs/gunicorn.pid)" && rm -f logs/gunicorn.pid && echo "已停止" +else + echo "未找到 pid 文件(可能未启动)" +fi diff --git a/server/settings.json b/server/settings.json new file mode 100644 index 0000000..f30319f --- /dev/null +++ b/server/settings.json @@ -0,0 +1,40 @@ +{ + "active_db": "dev_sqlite", + "databases": { + "dev_sqlite": { + "type": "sqlite", + "path": "./easyscreen.db" + }, + "dev": { + "type": "mysql", + "host": "127.0.0.1", + "port": 3306, + "user": "easyscreen", + "password": "", + "database_name": "easyscreen", + "charset": "utf8mb4" + }, + "production": { + "type": "mysql", + "host": "你的MySQL内网地址", + "port": 3306, + "user": "easyscreen", + "password": "", + "database_name": "easyscreen", + "charset": "utf8mb4" + } + }, + "server": { + "host": "0.0.0.0", + "port": 5889, + "base_url": "http://192.168.1.100:5889", + "upload_dir": "./uploads", + "max_upload_mb": 500, + "allowed_types": ["image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "video/x-matroska"] + }, + "admin": { + "username": "admin", + "password": "admin123", + "token_secret": "请修改为随机字符串" + } +} diff --git a/server/uploads/.gitkeep b/server/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..df8a64c --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + EasyScreen 电子菜单后台 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..48407d5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3116 @@ +{ + "name": "easyscreen-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "easyscreen-web", + "version": "1.0.0", + "dependencies": { + "antd": "^5.21.0", + "axios": "^1.7.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.4.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz", + "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.3.tgz", + "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.404", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", + "integrity": "sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..d827cf8 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "easyscreen-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "antd": "^5.21.0", + "axios": "^1.7.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.4.0" + } +} diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..2d01d8c --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,31 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import Layout from './components/Layout.jsx' +import Login from './pages/Login.jsx' +import Screens from './pages/Screens.jsx' +import Assets from './pages/Assets.jsx' +import Playlists from './pages/Playlists.jsx' + +export default function App() { + const authed = !!localStorage.getItem('easyscreen_token') + return ( + + } /> + + ) : ( + + ) + } + > + } /> + } /> + } /> + } /> + + } /> + + ) +} diff --git a/web/src/api/client.js b/web/src/api/client.js new file mode 100644 index 0000000..33b1710 --- /dev/null +++ b/web/src/api/client.js @@ -0,0 +1,50 @@ +import axios from 'axios' + +const api = axios.create({ baseURL: '/api', timeout: 60000 }) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('easyscreen_token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401 && !location.pathname.startsWith('/login')) { + localStorage.removeItem('easyscreen_token') + location.href = '/login' + } + return Promise.reject(err) + }, +) + +// ---- 认证 ---- +export const login = (username, password) => + api.post('/auth/login', { username, password }).then((r) => r.data.token) + +// ---- 屏幕 ---- +export const getScreens = () => api.get('/screens').then((r) => r.data) +export const createScreen = (data) => api.post('/screens', data).then((r) => r.data) +export const updateScreen = (id, data) => api.put(`/screens/${id}`, data).then((r) => r.data) +export const deleteScreen = (id) => api.delete(`/screens/${id}`) +export const bindPlaylist = (id, playlistId) => + api.post(`/screens/${id}/bind`, { playlist_id: playlistId }).then((r) => r.data) +export const unbindPlaylist = (id) => api.delete(`/screens/${id}/bind`) + +// ---- 素材 ---- +export const getAssets = () => api.get('/assets').then((r) => r.data) +export const uploadAsset = (file, onProgress) => { + const fd = new FormData() + fd.append('file', file) + return api.post('/assets', fd, { + onUploadProgress: (e) => onProgress?.(Math.round((e.loaded / e.total) * 100)), + }).then((r) => r.data) +} +export const deleteAsset = (id) => api.delete(`/assets/${id}`) + +// ---- 节目 ---- +export const getPlaylists = () => api.get('/playlists').then((r) => r.data) +export const createPlaylist = (data) => api.post('/playlists', data).then((r) => r.data) +export const updatePlaylist = (id, data) => api.put(`/playlists/${id}`, data).then((r) => r.data) +export const deletePlaylist = (id) => api.delete(`/playlists/${id}`) diff --git a/web/src/components/Layout.jsx b/web/src/components/Layout.jsx new file mode 100644 index 0000000..cc26bd6 --- /dev/null +++ b/web/src/components/Layout.jsx @@ -0,0 +1,78 @@ +import { Layout as AntLayout, Menu, Typography } from 'antd' +import { + AppstoreOutlined, + MonitorOutlined, + PictureOutlined, + LogoutOutlined, +} from '@ant-design/icons' +import { Outlet, useLocation, useNavigate } from 'react-router-dom' + +const { Sider, Content, Header } = AntLayout + +export default function Layout() { + const navigate = useNavigate() + const location = useLocation() + + const selected = location.pathname.startsWith('/assets') + ? 'assets' + : location.pathname.startsWith('/playlists') + ? 'playlists' + : 'screens' + + const logout = () => { + localStorage.removeItem('easyscreen_token') + navigate('/login') + } + + return ( + + +
+ EasyScreen +
+ , label: '屏幕管理' }, + { key: 'assets', icon: , label: '素材管理' }, + { key: 'playlists', icon: , label: '节目编排' }, + ]} + onClick={({ key }) => navigate(`/${key}`)} + /> + + +
+ + 电子菜单管理后台 + + + 退出登录 + +
+ + + +
+ + ) +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..a740df4 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,6 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', + 'Microsoft YaHei', sans-serif; + background: #f0f2f5; +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..d9cfe84 --- /dev/null +++ b/web/src/main.jsx @@ -0,0 +1,17 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { ConfigProvider } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + , +) diff --git a/web/src/pages/Assets.jsx b/web/src/pages/Assets.jsx new file mode 100644 index 0000000..9b67aa6 --- /dev/null +++ b/web/src/pages/Assets.jsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Card, + Col, + Empty, + Popconfirm, + Row, + Tag, + Upload, + message, +} from 'antd' +import { + InboxOutlined, + PlayCircleOutlined, +} from '@ant-design/icons' +import { deleteAsset, getAssets, uploadAsset } from '../api/client.js' + +const { Dragger } = Upload + +const fmtSize = (bytes) => { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / 1024 / 1024).toFixed(1) + ' MB' +} + +export default function Assets() { + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(false) + const [uploading, setUploading] = useState(false) + const [msg, msgCtx] = message.useMessage() + + const load = useCallback(async () => { + setLoading(true) + try { + setAssets(await getAssets()) + } catch (e) { + msg.error('加载失败: ' + (e.response?.data?.detail || e.message)) + } finally { + setLoading(false) + } + }, [msg]) + + useEffect(() => { load() }, [load]) + + const customRequest = async ({ file, onSuccess, onError, onProgress }) => { + setUploading(true) + try { + await uploadAsset(file, (pct) => onProgress?.({ percent: pct })) + onSuccess?.() + msg.success(`「${file.name}」上传成功`) + load() + } catch (e) { + const detail = e.response?.data?.detail + onError?.(new Error(detail)) + msg.error(`上传失败: ${detail || e.message}`) + } finally { + setUploading(false) + } + } + + const onDelete = async (asset) => { + try { + await deleteAsset(asset.id) + msg.success('已删除') + load() + } catch (e) { + msg.error(e.response?.data?.detail || '删除失败') + } + } + + return ( + <> + {msgCtx} + + +

+ +

+

+ {uploading ? '正在上传...' : '点击或拖拽图片 / 视频到此区域上传'} +

+

支持 JPG / PNG / GIF / WebP / MP4 / WebM,单文件最大 500MB

+
+
+ + + {assets.length === 0 ? ( + + ) : ( + + {assets.map((a) => ( + + window.open(a.url, '_blank')} + /> + ) : ( +
+ +
+ ) + } + actions={[ + onDelete(a)} + > + 删除 + , + ]} + > +
+ {a.name} +
+
+ + {a.type === 'image' ? '图片' : '视频'} + + {fmtSize(a.size)} + {a.type === 'image' && ` · 默认 ${a.duration}s`} +
+
+ + ))} +
+ )} +
+ + ) +} diff --git a/web/src/pages/Login.jsx b/web/src/pages/Login.jsx new file mode 100644 index 0000000..84ed88e --- /dev/null +++ b/web/src/pages/Login.jsx @@ -0,0 +1,53 @@ +import { useState } from 'react' +import { Button, Card, Form, Input, Typography, message } from 'antd' +import { UserOutlined, LockOutlined } from '@ant-design/icons' +import { login } from '../api/client.js' + +export default function Login() { + const [loading, setLoading] = useState(false) + const [msg, msgCtx] = message.useMessage() + + const onFinish = async (values) => { + setLoading(true) + try { + const token = await login(values.username, values.password) + localStorage.setItem('easyscreen_token', token) + // 整页跳转:让 App 重新初始化并读取登录态 + // (仅 React Router navigate 不会触发 App 重渲染,authed 判断仍是旧的 false) + window.location.href = '/screens' + } catch (e) { + msg.error(e.response?.data?.detail || '登录失败,请检查用户名和密码') + } finally { + setLoading(false) + } + } + + return ( +
+ {msgCtx} + EasyScreen 后台}> +
+ + } placeholder="用户名" /> + + + } placeholder="密码" /> + + + + +
+
+
+ ) +} diff --git a/web/src/pages/Playlists.jsx b/web/src/pages/Playlists.jsx new file mode 100644 index 0000000..b1e94fd --- /dev/null +++ b/web/src/pages/Playlists.jsx @@ -0,0 +1,375 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Button, + Card, + Col, + Empty, + Form, + Input, + InputNumber, + List, + Modal, + Pagination, + Popconfirm, + Row, + Space, + Table, + Tag, + message, +} from 'antd' +import { + ArrowDownOutlined, + ArrowUpOutlined, + CheckCircleFilled, + DeleteOutlined, + PlayCircleOutlined, + PlusOutlined, +} from '@ant-design/icons' +import { + createPlaylist, + deletePlaylist, + getAssets, + getPlaylists, + updatePlaylist, +} from '../api/client.js' + +const PAGE_SIZE = 12 + +export default function Playlists() { + const [playlists, setPlaylists] = useState([]) + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [items, setItems] = useState([]) // [{asset_id, duration}] + const [page, setPage] = useState(1) + const [form] = Form.useForm() + const [msg, msgCtx] = message.useMessage() + + const load = useCallback(async () => { + setLoading(true) + try { + setPlaylists(await getPlaylists()) + setAssets(await getAssets()) + } catch (e) { + msg.error('加载失败: ' + (e.response?.data?.detail || e.message)) + } finally { + setLoading(false) + } + }, [msg]) + + useEffect(() => { load() }, [load]) + + const assetMap = Object.fromEntries(assets.map((a) => [a.id, a])) + + const openCreate = () => { + setEditing(null) + form.resetFields() + setItems([]) + setPage(1) + setModalOpen(true) + } + + const openEdit = (playlist) => { + setEditing(playlist) + form.setFieldsValue({ name: playlist.name, description: playlist.description }) + setItems(playlist.items.map((it) => ({ asset_id: it.asset_id, duration: it.duration }))) + setPage(1) + setModalOpen(true) + } + + // 点击素材卡片:已选则移除,未选则添加(图片默认 10 秒,视频 0=按视频时长) + const toggleAsset = (asset) => { + setItems((prev) => { + const exists = prev.some((it) => it.asset_id === asset.id) + if (exists) return prev.filter((it) => it.asset_id !== asset.id) + return [...prev, { asset_id: asset.id, duration: asset.type === 'video' ? 0 : 10 }] + }) + } + + const moveItem = (index, dir) => { + const next = [...items] + const target = index + dir + if (target < 0 || target >= next.length) return + ;[next[index], next[target]] = [next[target], next[index]] + setItems(next) + } + + const onSave = async () => { + const values = await form.validateFields() + if (items.length === 0) { + msg.warning('请先在左侧选择至少一个素材') + return + } + setSaving(true) + try { + const payload = { + name: values.name, + description: values.description || '', + items: items.map((it) => ({ asset_id: it.asset_id, duration: it.duration })), + } + if (editing) { + await updatePlaylist(editing.id, payload) + } else { + await createPlaylist(payload) + } + msg.success('保存成功') + setModalOpen(false) + load() + } catch (e) { + msg.error(e.response?.data?.detail || '保存失败') + } finally { + setSaving(false) + } + } + + const onDelete = async (playlist) => { + try { + const r = await deletePlaylist(playlist.id) + msg.success(r.data?.unbound_screens ? '已删除,并解除相关屏幕绑定' : '已删除') + load() + } catch (e) { + msg.error(e.response?.data?.detail || '删除失败') + } + } + + const columns = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '名称', dataIndex: 'name' }, + { title: '描述', dataIndex: 'description', render: (v) => v || '—' }, + { title: '素材数', width: 90, render: (_, row) => row.items.length }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 170, + render: (v) => new Date(v).toLocaleString(), + }, + { + title: '操作', + width: 160, + render: (_, row) => ( + + + onDelete(row)} + > + + + + ), + }, + ] + + // 当前页素材 + const pageAssets = assets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + const selectedIds = new Set(items.map((it) => it.asset_id)) + + return ( + <> + {msgCtx} + } onClick={openCreate}>新建节目} + > + + + + setModalOpen(false)} + confirmLoading={saving} + width={1100} + destroyOnClose + > + + {/* 左:素材库(分页网格,可视化选择) */} + +
+ 素材库(点击选择,已选素材会在右侧显示) +
+ {assets.length === 0 ? ( + + ) : ( + <> + + {pageAssets.map((a) => { + const selected = selectedIds.has(a.id) + return ( + +
toggleAsset(a)} + style={{ + position: 'relative', + border: selected ? '2px solid #1677ff' : '2px solid #f0f0f0', + borderRadius: 8, + cursor: 'pointer', + overflow: 'hidden', + background: '#fff', + }} + > + {a.type === 'image' ? ( + {a.name} + ) : ( +
+ +
+ )} +
+ + {a.type === 'image' ? '图片' : '视频'} + + {a.name} +
+ {selected && ( + + )} +
+ + ) + })} + +
+ `共 ${t} 个素材`} + /> +
+ + )} + + + {/* 右:已选素材 + 节目信息 */} + +
+ 已选素材({items.length})— 按顺序轮播 +
+ {items.length === 0 ? ( +
+ 点击左侧素材添加 +
+ ) : ( + { + const asset = assetMap[it.asset_id] + return ( + } + disabled={index === 0} onClick={() => moveItem(index, -1)} + />, + + + {row.playlist && ( + onUnbind(row.id)}> + + + )} + onDelete(row.id)}> + + + + ), + }, + ] + + return ( + <> + {msgCtx} + } onClick={openCreate}>新增屏幕} + > +
+ + + {/* 新增/编辑 */} + setModalOpen(false)} + confirmLoading={saving} + destroyOnClose + > +
+ + + + + + + {!editing && ( + + + + )} + +
+ + {/* 绑定节目 */} + bindForm.validateFields().then(({ playlist_id }) => onBind(playlist_id))} + onCancel={() => setBindTarget(null)} + confirmLoading={saving} + destroyOnClose + > +

+ 当前绑定:{bindTarget?.playlist?.name || '无'}(绑定新节目将替换当前节目) +

+
+ +