init: EasyScreen 电子菜单系统

- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管)
- android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报)
- web: React + Vite + antd 管理台(屏幕/素材/节目管理)
- 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
Tatta
2026-08-12 20:57:55 +08:00
commit 4e2775c8c3
58 changed files with 6451 additions and 0 deletions
+44
View File
@@ -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/
+213
View File
@@ -0,0 +1,213 @@
# EasyScreen 电子菜单系统
基于 **Android 6.0 电子显示屏** 的电子菜单(广告屏)解决方案。支持多块屏幕独立配置、远程下发图片/视频节目、开机自启全屏播放。
系统分三端:
| 端 | 技术栈 | 说明 |
|---|---|---|
| `android/` | 原生 JavaminSdk 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/distFastAPI 启动时自动托管
```
开发模式(热更新):
```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 # Linuxgunicorn + 2 workers + 日志(logs/
scripts/start_prod.bat # Windowsgunicorn 前台运行
```
### 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**:素材正被节目引用,先在「节目编排」中移除。
+51
View File
@@ -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'
}
+2
View File
@@ -0,0 +1,2 @@
# 保留 Gson 反射所需类
-keep class com.easyscreen.player.api.** { *; }
+51
View File
@@ -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) 会抛 IllegalArgumentExceptionGlide 会自动清理其资源)
}
}
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
// Top-level build file
plugins {
id 'com.android.application' version '8.9.2' apply false
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
+5
View File
@@ -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
+22
View File
@@ -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'
View File
+44
View File
@@ -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="登录已过期,请重新登录")
+42
View File
@@ -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"]
+37
View File
@@ -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()
+62
View File
@@ -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/distnpm 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"))
+98
View File
@@ -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")
View File
+92
View File
@@ -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}
+14
View File
@@ -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))
+89
View File
@@ -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}
+147
View File
@@ -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}
+118
View File
@@ -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}
+130
View File
@@ -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()
+7
View File
@@ -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
+13
View File
@@ -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,
)
+10
View File
@@ -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
+29
View File
@@ -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
+8
View File
@@ -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
+40
View File
@@ -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": "请修改为随机字符串"
}
}
View File
+12
View File
@@ -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>
+3116
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+31
View File
@@ -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>
)
}
+50
View File
@@ -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}`)
+78
View File
@@ -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>
)
}
+6
View File
@@ -0,0 +1,6 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Microsoft YaHei', sans-serif;
background: #f0f2f5;
}
+17
View File
@@ -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>,
)
+152
View File
@@ -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>
</>
)
}
+53
View File
@@ -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>
)
}
+375
View File
@@ -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>
</>
)
}
+223
View File
@@ -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>
</>
)
}
+13
View File
@@ -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',
},
},
})