init: EasyScreen 电子菜单系统
- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管) - android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报) - web: React + Vite + antd 管理台(屏幕/素材/节目管理) - 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
+44
@@ -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/
|
||||
@@ -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 <token>`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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**:素材正被节目引用,先在「节目编排」中移除。
|
||||
@@ -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'
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
# 保留 Gson 反射所需类
|
||||
-keep class com.easyscreen.player.api.** { *; }
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- 电子屏设备:允许明文 HTTP 访问内网服务器 -->
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<!-- 主播放界面(全屏、横屏锁定) -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- 作为信息发布盒子时,可设为默认桌面开机直达 -->
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- 首次启动的服务器地址配置界面 -->
|
||||
<activity
|
||||
android:name=".SetupActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="sensorLandscape" />
|
||||
|
||||
<!-- 开机自启 -->
|
||||
<receiver
|
||||
android:name=".receiver.BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<ClientModels.ConfigItem> 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<ClientModels.OkResponse>() {
|
||||
@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<ClientModels.OkResponse>() {
|
||||
@Override
|
||||
public void onSuccess(ClientModels.OkResponse result) {
|
||||
api.fetchConfig(deviceId, new ApiClient.ApiCallback<ClientModels.ClientConfig>() {
|
||||
@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 会自动清理其资源)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<T> {
|
||||
void onSuccess(T result);
|
||||
|
||||
void onError(String message);
|
||||
}
|
||||
|
||||
private <T> void get(String path, Class<T> clazz, ApiCallback<T> cb) {
|
||||
Request request = new Request.Builder().url(baseUrl + path).get().build();
|
||||
enqueue(request, clazz, cb);
|
||||
}
|
||||
|
||||
private <T> void postJson(String path, Object body, Class<T> clazz, ApiCallback<T> cb) {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(GSON.toJson(body), JSON))
|
||||
.build();
|
||||
enqueue(request, clazz, cb);
|
||||
}
|
||||
|
||||
private <T> void enqueue(Request request, Class<T> clazz, ApiCallback<T> 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<ClientModels.OkResponse> cb) {
|
||||
// 后端 register 返回 {screen_id, registered},此处不关心具体字段,仅确认成功
|
||||
postJson("/api/client/register", new ClientModels.RegisterRequest(deviceId, deviceName), ClientModels.OkResponse.class, cb);
|
||||
}
|
||||
|
||||
/** 拉取播放配置。 */
|
||||
public void fetchConfig(String deviceId, ApiCallback<ClientModels.ClientConfig> cb) {
|
||||
get("/api/client/config?device_id=" + deviceId, ClientModels.ClientConfig.class, cb);
|
||||
}
|
||||
|
||||
/** 心跳上报。 */
|
||||
public void heartbeat(String deviceId, ApiCallback<ClientModels.OkResponse> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<ConfigItem> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 B |
Binary file not shown.
|
After Width: | Height: | Size: 185 B |
Binary file not shown.
|
After Width: | Height: | Size: 301 B |
Binary file not shown.
|
After Width: | Height: | Size: 457 B |
Binary file not shown.
|
After Width: | Height: | Size: 634 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">EasyScreen</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 全屏黑底主题(使用原生主题,无需 AppCompat) -->
|
||||
<style name="AppTheme" parent="android:Theme.Black.NoTitleBar.Fullscreen">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
// Top-level build file
|
||||
plugins {
|
||||
id 'com.android.application' version '8.9.2' apply false
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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="登录已过期,请重新登录")
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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))
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": "请修改为随机字符串"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EasyScreen 电子菜单后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3116
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
authed ? (
|
||||
<Layout />
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/screens" replace />} />
|
||||
<Route path="screens" element={<Screens />} />
|
||||
<Route path="assets" element={<Assets />} />
|
||||
<Route path="playlists" element={<Playlists />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
@@ -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 (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Sider theme="dark" width={200}>
|
||||
<div
|
||||
style={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
EasyScreen
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selected]}
|
||||
items={[
|
||||
{ key: 'screens', icon: <MonitorOutlined />, label: '屏幕管理' },
|
||||
{ key: 'assets', icon: <PictureOutlined />, label: '素材管理' },
|
||||
{ key: 'playlists', icon: <AppstoreOutlined />, label: '节目编排' },
|
||||
]}
|
||||
onClick={({ key }) => navigate(`/${key}`)}
|
||||
/>
|
||||
</Sider>
|
||||
<AntLayout>
|
||||
<Header
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
电子菜单管理后台
|
||||
</Typography.Text>
|
||||
<a onClick={logout} style={{ cursor: 'pointer' }}>
|
||||
<LogoutOutlined /> 退出登录
|
||||
</a>
|
||||
</Header>
|
||||
<Content style={{ margin: 16 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</AntLayout>
|
||||
</AntLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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}
|
||||
<Card title="素材管理" style={{ marginBottom: 16 }}>
|
||||
<Dragger
|
||||
customRequest={customRequest}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
disabled={uploading}
|
||||
accept="image/*,video/mp4,video/webm"
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{uploading ? '正在上传...' : '点击或拖拽图片 / 视频到此区域上传'}
|
||||
</p>
|
||||
<p className="ant-upload-hint">支持 JPG / PNG / GIF / WebP / MP4 / WebM,单文件最大 500MB</p>
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
<Card title={`素材列表(${assets.length})`} loading={loading}>
|
||||
{assets.length === 0 ? (
|
||||
<Empty description="暂无素材,请先上传" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{assets.map((a) => (
|
||||
<Col key={a.id} xs={12} sm={8} md={6} lg={4}>
|
||||
<Card
|
||||
size="small"
|
||||
cover={
|
||||
a.type === 'image' ? (
|
||||
<img
|
||||
src={a.url}
|
||||
alt={a.name}
|
||||
style={{ height: 140, objectFit: 'cover' }}
|
||||
onClick={() => window.open(a.url, '_blank')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: 140,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111',
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="del"
|
||||
title="删除该素材?"
|
||||
description="被节目引用的素材需先从节目中移除"
|
||||
onConfirm={() => onDelete(a)}
|
||||
>
|
||||
<span style={{ color: '#ff4d4f' }}>删除</span>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={a.name}>
|
||||
{a.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888' }}>
|
||||
<Tag color={a.type === 'image' ? 'green' : 'purple'}>
|
||||
{a.type === 'image' ? '图片' : '视频'}
|
||||
</Tag>
|
||||
{fmtSize(a.size)}
|
||||
{a.type === 'image' && ` · 默认 ${a.duration}s`}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #141e30, #243b55)',
|
||||
}}
|
||||
>
|
||||
{msgCtx}
|
||||
<Card style={{ width: 380 }} title={<Typography.Title level={3} style={{ margin: 0, textAlign: 'center' }}>EasyScreen 后台</Typography.Title>}>
|
||||
<Form onFinish={onFinish} size="large">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登 录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
<Popconfirm
|
||||
title="删除该节目?"
|
||||
description="删除后相关屏幕将不再播放此节目"
|
||||
onConfirm={() => onDelete(row)}
|
||||
>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// 当前页素材
|
||||
const pageAssets = assets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
const selectedIds = new Set(items.map((it) => it.asset_id))
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Card
|
||||
title="节目编排"
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建节目</Button>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={playlists}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? `编辑节目 - ${editing.name}` : '新建节目'}
|
||||
open={modalOpen}
|
||||
onOk={onSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
width={1100}
|
||||
destroyOnClose
|
||||
>
|
||||
<Row gutter={16}>
|
||||
{/* 左:素材库(分页网格,可视化选择) */}
|
||||
<Col span={14}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
素材库(点击选择,已选素材会在右侧显示)
|
||||
</div>
|
||||
{assets.length === 0 ? (
|
||||
<Empty description="暂无素材,请先到「素材管理」上传" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[12, 12]}>
|
||||
{pageAssets.map((a) => {
|
||||
const selected = selectedIds.has(a.id)
|
||||
return (
|
||||
<Col key={a.id} span={6}>
|
||||
<div
|
||||
onClick={() => toggleAsset(a)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
border: selected ? '2px solid #1677ff' : '2px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
{a.type === 'image' ? (
|
||||
<img
|
||||
src={a.url}
|
||||
alt={a.name}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 80,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111',
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: '#fff' }} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
fontSize: 12,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={a.name}
|
||||
>
|
||||
<Tag color={a.type === 'image' ? 'green' : 'purple'} style={{ marginRight: 4 }}>
|
||||
{a.type === 'image' ? '图片' : '视频'}
|
||||
</Tag>
|
||||
{a.name}
|
||||
</div>
|
||||
{selected && (
|
||||
<CheckCircleFilled
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
fontSize: 18,
|
||||
color: '#1677ff',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={assets.length}
|
||||
onChange={setPage}
|
||||
showSizeChanger={false}
|
||||
showTotal={(t) => `共 ${t} 个素材`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{/* 右:已选素材 + 节目信息 */}
|
||||
<Col span={10}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
已选素材({items.length})— 按顺序轮播
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed #d9d9d9',
|
||||
borderRadius: 8,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
padding: '24px 0',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
点击左侧素材添加
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
style={{ maxHeight: 280, overflowY: 'auto', marginBottom: 12 }}
|
||||
dataSource={items}
|
||||
renderItem={(it, index) => {
|
||||
const asset = assetMap[it.asset_id]
|
||||
return (
|
||||
<List.Item
|
||||
key={it.asset_id}
|
||||
actions={[
|
||||
<Button
|
||||
key="up" size="small" icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0} onClick={() => moveItem(index, -1)}
|
||||
/>,
|
||||
<Button
|
||||
key="down" size="small" icon={<ArrowDownOutlined />}
|
||||
disabled={index === items.length - 1} onClick={() => moveItem(index, 1)}
|
||||
/>,
|
||||
<Button
|
||||
key="del" size="small" danger icon={<DeleteOutlined />}
|
||||
onClick={() => setItems(items.filter((_, i) => i !== index))}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space size={4}>
|
||||
<Tag color={asset?.type === 'image' ? 'green' : 'purple'} style={{ marginRight: 0 }}>
|
||||
{asset?.type === 'image' ? '图' : '视'}
|
||||
</Tag>
|
||||
<span style={{ fontSize: 13 }}>{asset?.name || `素材 #${it.asset_id}`}</span>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Space size={4}>
|
||||
<span style={{ color: '#888', fontSize: 12 }}>时长(秒)</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
value={it.duration}
|
||||
onChange={(v) => {
|
||||
const next = [...items]
|
||||
next[index] = { ...it, duration: v }
|
||||
setItems(next)
|
||||
}}
|
||||
style={{ width: 72 }}
|
||||
/>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="节目名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:午餐套餐轮播" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述(可选)">
|
||||
<Input placeholder="节目用途说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
bindPlaylist,
|
||||
createScreen,
|
||||
deleteScreen,
|
||||
getPlaylists,
|
||||
getScreens,
|
||||
unbindPlaylist,
|
||||
updateScreen,
|
||||
} from '../api/client.js'
|
||||
|
||||
export default function Screens() {
|
||||
const [screens, setScreens] = useState([])
|
||||
const [playlists, setPlaylists] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState(null) // null=新增, 对象=编辑
|
||||
const [bindTarget, setBindTarget] = useState(null) // 绑定弹窗的目标屏幕
|
||||
const [bindForm] = Form.useForm()
|
||||
const [form] = Form.useForm()
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setScreens(await getScreens())
|
||||
setPlaylists(await getPlaylists())
|
||||
} catch (e) {
|
||||
msg.error('加载失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [msg])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (screen) => {
|
||||
setEditing(screen)
|
||||
form.setFieldsValue({ name: screen.name, location: screen.location })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateScreen(editing.id, values)
|
||||
} else {
|
||||
await createScreen(values)
|
||||
}
|
||||
msg.success('保存成功')
|
||||
setModalOpen(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id) => {
|
||||
try {
|
||||
await deleteScreen(id)
|
||||
msg.success('已删除')
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onBind = async (playlistId) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await bindPlaylist(bindTarget.id, playlistId)
|
||||
msg.success(`已将「${bindTarget.name}」绑定节目`)
|
||||
setBindTarget(null)
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error(e.response?.data?.detail || '绑定失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onUnbind = async (id) => {
|
||||
try {
|
||||
await unbindPlaylist(id)
|
||||
msg.success('已解除绑定')
|
||||
load()
|
||||
} catch (e) {
|
||||
msg.error('解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '设备 ID', dataIndex: 'device_id', render: (v) => (
|
||||
<Typography.Text code copyable={{ text: v }}>{v}</Typography.Text>
|
||||
) },
|
||||
{ title: '位置', dataIndex: 'location', render: (v) => v || '—' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => (s === 'online' ? <Tag color="green">在线</Tag> : <Tag color="red">离线</Tag>),
|
||||
},
|
||||
{
|
||||
title: '最后在线',
|
||||
dataIndex: 'last_seen',
|
||||
width: 170,
|
||||
render: (v) => (v ? new Date(v).toLocaleString() : '从未'),
|
||||
},
|
||||
{
|
||||
title: '绑定节目',
|
||||
dataIndex: 'playlist',
|
||||
render: (p) => (p ? <Tag color="blue">{p.name}</Tag> : <Tag>未绑定</Tag>),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 230,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setBindTarget(row)}>绑定节目</Button>
|
||||
<Button size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.playlist && (
|
||||
<Popconfirm title="确定解除当前绑定?" onConfirm={() => onUnbind(row.id)}>
|
||||
<Button size="small" danger>解绑</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm title="删除该屏幕?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Card
|
||||
title="屏幕管理"
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增屏幕</Button>}
|
||||
>
|
||||
<Table rowKey="id" columns={columns} dataSource={screens} loading={loading} pagination={false} />
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑 */}
|
||||
<Modal
|
||||
title={editing ? '编辑屏幕' : '新增屏幕'}
|
||||
open={modalOpen}
|
||||
onOk={onSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="屏幕名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:大厅菜单屏" />
|
||||
</Form.Item>
|
||||
<Form.Item name="location" label="位置描述">
|
||||
<Input placeholder="如:1楼大厅" />
|
||||
</Form.Item>
|
||||
{!editing && (
|
||||
<Form.Item
|
||||
name="device_id"
|
||||
label="设备 ID(推荐填写)"
|
||||
tooltip="自定义标识(如 screen-001)。需与 App 端配置的『屏幕 ID』完全一致,App 才能关联到这块屏幕并播放它的节目。留空则生成 pre- 占位 ID。"
|
||||
>
|
||||
<Input placeholder="如 screen-001(需与 App 端屏幕 ID 一致)" />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 绑定节目 */}
|
||||
<Modal
|
||||
title={`绑定节目 - ${bindTarget?.name || ''}`}
|
||||
open={!!bindTarget}
|
||||
onOk={() => bindForm.validateFields().then(({ playlist_id }) => onBind(playlist_id))}
|
||||
onCancel={() => setBindTarget(null)}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<p style={{ color: '#888' }}>
|
||||
当前绑定:{bindTarget?.playlist?.name || '无'}(绑定新节目将替换当前节目)
|
||||
</p>
|
||||
<Form form={bindForm} layout="vertical">
|
||||
<Form.Item name="playlist_id" label="选择节目" rules={[{ required: true, message: '请选择节目' }]}>
|
||||
<Select
|
||||
placeholder="选择要绑定的节目"
|
||||
options={playlists.map((p) => ({ label: p.name, value: p.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:5889',
|
||||
'/media': 'http://127.0.0.1:5889',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user