feat: 第二轮核心插件完成 + QA 团队组建

第二轮任务完成:
- Message Clone + ServiceManager Broadcast (张明远)
- VideoProcessor 完整迁移 1349行 (李思琪)
- BlePlugin 双连接修复 590行 (王浩然)
- HttpPlugin + Web UI 914行 (赵雨薇)

总计新增/修改 1303行代码,cargo check 通过

QA 团队组建:
- 新增 QA 负责人林晓峰(前腾讯测试专家)
- 新增测试工程师周雅婷(前字节测试工程师)
- 更新工作流程:开发 → PM 初审 → QA 测试 → CEO 终审
- 开发和 QA 并行工作,提高效率
This commit is contained in:
showen
2026-03-12 06:30:08 +08:00
parent d443f28f6e
commit 6940f03187
9 changed files with 1631 additions and 635 deletions

View File

@@ -1,13 +1,16 @@
use super::HttpState;
use crate::core::config;
use crate::core::config::{self, AppConfig};
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use bytes::Buf;
use futures_util::TryStreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::{mpsc, Arc};
use std::time::{Duration, Instant};
use warp::http::StatusCode;
use warp::multipart::{FormData, Part};
use warp::{Filter, Reply};
#[derive(Deserialize)]
@@ -18,8 +21,13 @@ struct WifiConnectRequest {
#[derive(Deserialize)]
struct WifiApStartRequest {
ssid: String,
password: String,
ssid: Option<String>,
password: Option<String>,
}
#[derive(Deserialize)]
struct BleStartRequest {
device_name: Option<String>,
}
#[derive(Serialize)]
@@ -28,40 +36,77 @@ struct ApiMessage<'a> {
message: String,
}
#[derive(Serialize)]
struct VideoFileInfo {
name: String,
size: u64,
}
#[derive(Serialize)]
struct WifiStatusResponse {
connected: bool,
ssid: String,
ip: String,
}
#[derive(Serialize)]
struct BleStatusResponse {
running: bool,
embedded: bool,
device_name: String,
}
pub(crate) fn build_routes(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
let api = play_route(tx.clone())
let api = status_route(Arc::clone(&state))
.or(play_route(tx.clone()))
.or(pause_route(tx.clone()))
.or(next_route(tx.clone()))
.or(previous_route(tx.clone()))
.or(goto_route(tx.clone()))
.or(trigger_route(tx.clone()))
.or(goto_route(tx.clone(), Arc::clone(&state)))
.or(playlist_route(Arc::clone(&state)))
.or(scene_route(tx.clone()))
.or(status_route(Arc::clone(&state)))
.or(trigger_route(tx.clone()))
.or(config_get_route(Arc::clone(&state)))
.or(config_post_route(tx.clone(), Arc::clone(&state)))
.or(config_display_route(Arc::clone(&state)))
.or(config_update_route(tx.clone(), Arc::clone(&state)))
.or(video_list_route(Arc::clone(&state)))
.or(video_upload_route(Arc::clone(&state)))
.or(video_delete_route(Arc::clone(&state)))
.or(wifi_status_route(tx.clone(), Arc::clone(&state)))
.or(wifi_scan_route(tx.clone(), Arc::clone(&state)))
.or(wifi_connect_route(tx.clone(), Arc::clone(&state)))
.or(wifi_ap_start_route(tx.clone(), Arc::clone(&state)))
.or(wifi_ap_stop_route(tx, state));
.or(wifi_ap_stop_route(tx.clone(), Arc::clone(&state)))
.or(ble_start_route(Arc::clone(&state)))
.or(ble_stop_route())
.or(ble_status_route(state));
let cors = warp::cors()
.allow_any_origin()
.allow_headers(["content-type"])
.allow_methods(["GET", "POST", "OPTIONS"]);
root_route().or(api).with(cors)
root_route().or(api).with(
warp::cors()
.allow_any_origin()
.allow_headers(["content-type"])
.allow_methods(["GET", "POST", "DELETE", "OPTIONS"]),
)
}
fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path::end().and(warp::get()).map(|| {
warp::reply::html(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>ShowenV2 HTTP API</title></head><body><h1>ShowenV2 HTTP API</h1><p>HTTP API is running.</p></body></html>",
)
})
warp::path::end()
.and(warp::get())
.map(|| warp::reply::html(WEB_UI_HTML))
}
fn status_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "status")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
Ok::<_, Infallible>(json_response(StatusCode::OK, &state.player_status()))
})
}
fn play_route(
@@ -70,7 +115,9 @@ fn play_route(
warp::path!("api" / "play")
.and(warp::post())
.and(with_tx(tx))
.and_then(|tx| command_reply(tx, Message::PlayerCommand(PlayerCommand::Play), "开始播放"))
.and_then(|tx| async move {
send_video_command(tx, Message::PlayerCommand(PlayerCommand::Play), "开始播放").await
})
}
fn pause_route(
@@ -79,7 +126,9 @@ fn pause_route(
warp::path!("api" / "pause")
.and(warp::post())
.and(with_tx(tx))
.and_then(|tx| command_reply(tx, Message::PlayerCommand(PlayerCommand::Pause), "已暂停"))
.and_then(|tx| async move {
send_video_command(tx, Message::PlayerCommand(PlayerCommand::Pause), "已暂停").await
})
}
fn next_route(
@@ -88,7 +137,14 @@ fn next_route(
warp::path!("api" / "next")
.and(warp::post())
.and(with_tx(tx))
.and_then(|tx| command_reply(tx, Message::PlayerCommand(PlayerCommand::Next), "切换到下一个视频"))
.and_then(|tx| async move {
send_video_command(
tx,
Message::PlayerCommand(PlayerCommand::Next),
"切换到下一个视频",
)
.await
})
}
fn previous_route(
@@ -97,33 +153,47 @@ fn previous_route(
warp::path!("api" / "previous")
.and(warp::post())
.and(with_tx(tx))
.and_then(|tx| command_reply(tx, Message::PlayerCommand(PlayerCommand::Previous), "切换到上一个视频"))
.and_then(|tx| async move {
send_video_command(
tx,
Message::PlayerCommand(PlayerCommand::Previous),
"切换到上一个视频",
)
.await
})
}
fn goto_route(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "goto" / usize)
.and(warp::post())
.and(with_tx(tx))
.and_then(|index, tx| {
command_reply(
.and(with_state(state))
.and_then(|index, tx, state: Arc<HttpState>| async move {
if index >= state.player_status().playlist_length {
return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "无效的视频索引"));
}
send_video_command(
tx,
Message::PlayerCommand(PlayerCommand::Goto(index)),
format!("跳转到视频 {index}"),
)
.await
})
}
fn trigger_route(
tx: mpsc::Sender<Envelope>,
fn playlist_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "trigger" / String / String)
.and(warp::post())
.and(with_tx(tx))
.and_then(|name, value, tx| {
let message = format!("触发器 '{name}' 已发送,值: {value}");
command_reply(tx, Message::Trigger { name, value }, message)
warp::path!("api" / "playlist")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
let config = state.config();
Ok::<_, Infallible>(json_response(StatusCode::OK, &config.playlist))
})
}
@@ -133,19 +203,33 @@ fn scene_route(
warp::path!("api" / "scene" / String)
.and(warp::post())
.and(with_tx(tx))
.and_then(|name, tx| {
let message = format!("切换到场景: {name}");
command_reply(tx, Message::PlayerCommand(PlayerCommand::ChangeScene(name)), message)
.and_then(|name: String, tx| async move {
send_video_command(
tx,
Message::PlayerCommand(PlayerCommand::ChangeScene(name.clone())),
format!("切换到场景: {name}"),
)
.await
})
}
fn status_route(
state: Arc<HttpState>,
fn trigger_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "status")
.and(warp::get())
.and(with_state(state))
.and_then(status_reply)
warp::path!("api" / "trigger" / String / String)
.and(warp::post())
.and(with_tx(tx))
.and_then(|name: String, value: String, tx| async move {
send_video_command(
tx,
Message::Trigger {
name: name.clone(),
value: value.clone(),
},
format!("触发器 '{name}' 已发送,值: {value}"),
)
.await
})
}
fn config_get_route(
@@ -154,10 +238,25 @@ fn config_get_route(
warp::path!("api" / "config")
.and(warp::get())
.and(with_state(state))
.and_then(config_get_reply)
.and_then(|state: Arc<HttpState>| async move {
let config = state.config();
Ok::<_, Infallible>(json_response(StatusCode::OK, config.as_ref()))
})
}
fn config_post_route(
fn config_display_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "config" / "display")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
let config = state.config();
Ok::<_, Infallible>(json_response(StatusCode::OK, &config.display))
})
}
fn config_update_route(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
@@ -167,7 +266,38 @@ fn config_post_route(
.and(warp::body::bytes())
.and(with_tx(tx))
.and(with_state(state))
.and_then(handle_config_post)
.and_then(handle_config_update)
}
fn video_list_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "videos")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
let dir = video_dir(state.config().as_ref());
Ok::<_, Infallible>(json_response(StatusCode::OK, &list_video_files(&dir)))
})
}
fn video_upload_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "videos" / "upload")
.and(warp::post())
.and(warp::multipart::form().max_length(500 * 1024 * 1024))
.and(with_state(state))
.and_then(handle_video_upload)
}
fn video_delete_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "videos" / String)
.and(warp::delete())
.and(with_state(state))
.and_then(handle_video_delete)
}
fn wifi_status_route(
@@ -178,7 +308,7 @@ fn wifi_status_route(
.and(warp::get())
.and(with_tx(tx))
.and(with_state(state))
.and_then(|tx, state| wifi_reply(tx, state, WifiCommand::Status))
.and_then(handle_wifi_status)
}
fn wifi_scan_route(
@@ -189,7 +319,7 @@ fn wifi_scan_route(
.and(warp::get())
.and(with_tx(tx))
.and(with_state(state))
.and_then(|tx, state| wifi_reply(tx, state, WifiCommand::Scan))
.and_then(handle_wifi_scan)
}
fn wifi_connect_route(
@@ -201,15 +331,23 @@ fn wifi_connect_route(
.and(warp::body::json())
.and(with_tx(tx))
.and(with_state(state))
.and_then(|req: WifiConnectRequest, tx, state| {
wifi_reply(
.and_then(|req: WifiConnectRequest, tx, state| async move {
wifi_action_reply(
tx,
state,
WifiCommand::Connect {
ssid: req.ssid,
password: req.password,
},
|payload| {
let ssid = payload
.get("ssid")
.and_then(Value::as_str)
.unwrap_or("未知网络");
format!("WiFi 连接成功: {ssid}")
},
)
.await
})
}
@@ -222,15 +360,17 @@ fn wifi_ap_start_route(
.and(warp::body::json())
.and(with_tx(tx))
.and(with_state(state))
.and_then(|req: WifiApStartRequest, tx, state| {
wifi_reply(
.and_then(|req: WifiApStartRequest, tx, state| async move {
let ssid = req.ssid.unwrap_or_else(|| "showen".to_string());
let password = req.password.unwrap_or_else(|| "12345678".to_string());
let success_ssid = ssid.clone();
wifi_action_reply(
tx,
state,
WifiCommand::ApStart {
ssid: req.ssid,
password: req.password,
},
WifiCommand::ApStart { ssid, password },
move |_| format!("AP 热点已启动: SSID={success_ssid}"),
)
.await
})
}
@@ -242,40 +382,72 @@ fn wifi_ap_stop_route(
.and(warp::post())
.and(with_tx(tx))
.and(with_state(state))
.and_then(|tx, state| wifi_reply(tx, state, WifiCommand::ApStop))
.and_then(|tx, state| async move {
wifi_action_reply(tx, state, WifiCommand::ApStop, |_| "AP 热点已关闭".to_string())
.await
})
}
async fn status_reply(state: Arc<HttpState>) -> Result<warp::reply::Response, Infallible> {
Ok(warp::reply::json(&json!({
"player": state.player_status(),
"ble_ready": state.ble_ready(),
}))
.into_response())
fn ble_start_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "ble" / "start")
.and(warp::post())
.and(warp::body::json())
.and(with_state(state))
.and_then(|req: BleStartRequest, state: Arc<HttpState>| async move {
let config = state.config();
let device_name = req.device_name.unwrap_or_else(|| config.ble.device_name.clone());
Ok::<_, Infallible>(success_json(format!(
"BLE 配网服务已内嵌运行中,设备名: {device_name}"
)))
})
}
async fn config_get_reply(state: Arc<HttpState>) -> Result<warp::reply::Response, Infallible> {
Ok(warp::reply::json(state.config().as_ref()).into_response())
fn ble_stop_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "ble" / "stop")
.and(warp::post())
.map(|| success_json("BLE 配网服务随主进程运行,无需手动停止"))
}
async fn handle_config_post(
fn ble_status_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "ble" / "status")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
let config = state.config();
Ok::<_, Infallible>(json_response(
StatusCode::OK,
&BleStatusResponse {
running: state.ble_ready(),
embedded: true,
device_name: config.ble.device_name.clone(),
},
))
})
}
async fn handle_config_update(
body: bytes::Bytes,
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> Result<warp::reply::Response, Infallible> {
let current_config = state.config();
let raw = match std::str::from_utf8(&body) {
Ok(raw) => raw,
Err(_) => return Ok(error_json(StatusCode::BAD_REQUEST, "请求体不是有效的 UTF-8")),
};
if let Err(error) = config::parse_str(raw, &current_config.source_path) {
let current = state.config();
if let Err(error) = config::parse_str(raw, current.source_path.clone()) {
return Ok(error_json(
StatusCode::BAD_REQUEST,
&format!("配置验证失败: {error}"),
));
}
if let Err(error) = std::fs::write(&current_config.source_path, raw) {
if let Err(error) = std::fs::write(&current.source_path, raw) {
return Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("写入配置文件失败: {error}"),
@@ -293,10 +465,154 @@ async fn handle_config_post(
));
}
Ok(success_json("配置已保存并请求重载"))
Ok(success_json("配置已保存,热重载将自动生效"))
}
async fn command_reply(
async fn handle_video_upload(
form: FormData,
state: Arc<HttpState>,
) -> Result<warp::reply::Response, Infallible> {
let dir = video_dir(state.config().as_ref());
let parts: Result<Vec<Part>, _> = form.try_collect().await;
let parts = match parts {
Ok(parts) => parts,
Err(error) => {
return Ok(error_json(
StatusCode::BAD_REQUEST,
&format!("上传失败: {error}"),
));
}
};
let mut uploaded = Vec::new();
for part in parts {
let Some(filename) = part.filename() else {
continue;
};
let safe_name = sanitize_filename(filename);
if safe_name.is_empty() {
continue;
}
let data = match part
.stream()
.try_fold(Vec::new(), |mut acc, buf| async move {
acc.extend_from_slice(buf.chunk());
Ok(acc)
})
.await
{
Ok(data) => data,
Err(error) => {
return Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("读取文件失败: {error}"),
));
}
};
if let Err(error) = std::fs::write(dir.join(&safe_name), &data) {
return Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("保存文件失败: {error}"),
));
}
uploaded.push(safe_name);
}
if uploaded.is_empty() {
Ok(error_json(StatusCode::BAD_REQUEST, "未找到上传文件"))
} else {
Ok(success_json(format!(
"已上传 {} 个文件: {}",
uploaded.len(),
uploaded.join(", ")
)))
}
}
async fn handle_video_delete(
filename: String,
state: Arc<HttpState>,
) -> Result<impl Reply, Infallible> {
if filename.contains("..") {
return Ok(error_json(StatusCode::BAD_REQUEST, "无效的文件名"));
}
let target = video_dir(state.config().as_ref()).join(&filename);
if !target.exists() {
return Ok(error_json(StatusCode::NOT_FOUND, "文件不存在"));
}
match std::fs::remove_file(&target) {
Ok(()) => Ok(success_json(format!("已删除: {filename}"))),
Err(error) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("删除失败: {error}"),
)),
}
}
async fn handle_wifi_status(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> Result<warp::reply::Response, Infallible> {
let payload = match wifi_request(tx, state, WifiCommand::Status).await {
Ok(payload) => payload,
Err(reply) => return Ok(reply),
};
let device = payload
.get("devices")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find(|item| {
item.get("device_type").and_then(Value::as_str) == Some("wifi")
&& item.get("state").and_then(Value::as_str) == Some("connected")
});
let ssid = device
.and_then(|item| item.get("connection"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let ip = device
.and_then(|item| item.get("ip4_addresses"))
.and_then(Value::as_array)
.and_then(|ips| ips.first())
.and_then(Value::as_str)
.map(strip_cidr)
.unwrap_or_default();
Ok(json_response(
StatusCode::OK,
&WifiStatusResponse {
connected: !ssid.is_empty(),
ssid,
ip,
},
))
}
async fn handle_wifi_scan(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> Result<warp::reply::Response, Infallible> {
let payload = match wifi_request(tx, state, WifiCommand::Scan).await {
Ok(payload) => payload,
Err(reply) => return Ok(reply),
};
let networks = payload
.get("networks")
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new()));
Ok(json_response(StatusCode::OK, &networks))
}
async fn send_video_command(
tx: mpsc::Sender<Envelope>,
message: Message,
success_message: impl Into<String>,
@@ -306,7 +622,7 @@ async fn command_reply(
to: Destination::Plugin("video"),
message,
}) {
Ok(()) => Ok(success_json(success_message)),
Ok(()) => Ok(success_json(success_message.into())),
Err(error) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("发送命令失败: {error}"),
@@ -314,15 +630,32 @@ async fn command_reply(
}
}
async fn wifi_reply(
async fn wifi_action_reply<F>(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
command: WifiCommand,
) -> Result<warp::reply::Response, Infallible> {
build_message: F,
) -> Result<warp::reply::Response, Infallible>
where
F: FnOnce(&Value) -> String,
{
let payload = match wifi_request(tx, state, command).await {
Ok(payload) => payload,
Err(reply) => return Ok(reply),
};
Ok(success_json(build_message(&payload)))
}
async fn wifi_request(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
command: WifiCommand,
) -> Result<Value, warp::reply::Response> {
let version = match state.wifi_response.lock() {
Ok(guard) => guard.version,
Err(_) => {
return Ok(error_json(
return Err(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
"WiFi 响应状态锁已损坏",
));
@@ -334,7 +667,7 @@ async fn wifi_reply(
to: Destination::Plugin("wifi"),
message: Message::WifiCommand(command),
}) {
return Ok(error_json(
return Err(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("发送 WiFi 命令失败: {error}"),
));
@@ -344,7 +677,7 @@ async fn wifi_reply(
let mut guard = match state.wifi_response.lock() {
Ok(guard) => guard,
Err(_) => {
return Ok(error_json(
return Err(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
"WiFi 响应状态锁已损坏",
));
@@ -354,14 +687,16 @@ async fn wifi_reply(
while guard.version == version {
let now = Instant::now();
if now >= deadline {
return Ok(error_json(StatusCode::GATEWAY_TIMEOUT, "等待 WiFi 响应超时"));
return Err(error_json(StatusCode::GATEWAY_TIMEOUT, "等待 WiFi 响应超时"));
}
let timeout = deadline.saturating_duration_since(now);
let (next_guard, wait_result) = match state.wifi_response_cv.wait_timeout(guard, timeout) {
let result = state
.wifi_response_cv
.wait_timeout(guard, deadline.saturating_duration_since(now));
let (next_guard, wait_result) = match result {
Ok(result) => result,
Err(_) => {
return Ok(error_json(
return Err(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
"等待 WiFi 响应失败",
));
@@ -370,11 +705,91 @@ async fn wifi_reply(
guard = next_guard;
if wait_result.timed_out() && guard.version == version {
return Ok(error_json(StatusCode::GATEWAY_TIMEOUT, "等待 WiFi 响应超时"));
return Err(error_json(StatusCode::GATEWAY_TIMEOUT, "等待 WiFi 响应超时"));
}
}
Ok(warp::reply::with_status(guard.payload.clone().unwrap_or_default(), StatusCode::OK).into_response())
let raw = guard.payload.clone().unwrap_or_default();
let payload: Value = match serde_json::from_str(&raw) {
Ok(payload) => payload,
Err(error) => {
return Err(error_json(
StatusCode::BAD_GATEWAY,
&format!("WiFi 返回了无效 JSON: {error}"),
));
}
};
if payload.get("ok").and_then(Value::as_bool) == Some(false) {
let message = payload
.get("error")
.and_then(Value::as_str)
.unwrap_or("WiFi 操作失败");
return Err(error_json(StatusCode::INTERNAL_SERVER_ERROR, message));
}
Ok(payload)
}
fn list_video_files(dir: &Path) -> Vec<VideoFileInfo> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_file() {
files.push(VideoFileInfo {
name: entry.file_name().to_string_lossy().into_owned(),
size: meta.len(),
});
}
if meta.is_dir() {
let prefix = entry.file_name().to_string_lossy().into_owned();
if let Ok(sub_entries) = std::fs::read_dir(entry.path()) {
for sub_entry in sub_entries.flatten() {
if let Ok(sub_meta) = sub_entry.metadata() {
if sub_meta.is_file() {
files.push(VideoFileInfo {
name: format!(
"{prefix}/{}",
sub_entry.file_name().to_string_lossy()
),
size: sub_meta.len(),
});
}
}
}
}
}
}
}
}
files.sort_by(|left, right| left.name.cmp(&right.name));
files
}
fn sanitize_filename(name: &str) -> String {
name.replace('/', "_")
.replace('\\', "_")
.replace("..", "_")
}
fn video_dir(config: &AppConfig) -> PathBuf {
if let Some(first) = config.playlist.first() {
let resolved = config.resolve_media_path(first);
return resolved
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| config.source_dir.clone());
}
config.source_dir.clone()
}
fn strip_cidr(value: &str) -> String {
value.split('/').next().unwrap_or_default().to_string()
}
fn with_tx(
@@ -390,23 +805,110 @@ fn with_state(
}
fn success_json(message: impl Into<String>) -> warp::reply::Response {
warp::reply::with_status(
warp::reply::json(&ApiMessage {
json_response(
StatusCode::OK,
&ApiMessage {
status: "ok",
message: message.into(),
}),
StatusCode::OK,
},
)
.into_response()
}
fn error_json(status: StatusCode, message: &str) -> warp::reply::Response {
warp::reply::with_status(
warp::reply::json(&json!({
"status": "error",
"message": message,
})),
json_response(
status,
&ApiMessage {
status: "error",
message: message.to_string(),
},
)
.into_response()
}
fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::Response {
warp::reply::with_status(warp::reply::json(payload), status).into_response()
}
const WEB_UI_HTML: &str = r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Showen 控制台</title>
<style>
:root{--bg:#f6efe2;--panel:#fffaf3;--border:#d7c5aa;--ink:#2e261d;--muted:#7f6d5c;--accent:#0f766e;--accent2:#b86a24;--danger:#b42318}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top,#fff8eb 0,#f6efe2 45%,#ebddc5 100%);color:var(--ink);font:15px/1.5 "Noto Serif SC","PingFang SC",serif}
.wrap{max-width:1080px;margin:0 auto;padding:16px}.hero,.card{background:var(--panel);border:1px solid var(--border);border-radius:24px;box-shadow:0 18px 42px rgba(74,48,21,.08)}
.hero{padding:24px}.hero h1{margin:0;font-size:38px}.hero p{margin:8px 0 0;color:var(--muted)}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin-top:16px}
.card{padding:18px}.card h2{margin:0 0 12px;font-size:18px}.status{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.status div{padding:12px;border:1px solid var(--border);border-radius:16px;background:#fffdf9}
.label{display:block;color:var(--muted);font-size:12px}.val{font-weight:700;color:var(--accent)}.paused{color:var(--accent2)}.row,.btns{display:flex;gap:10px;flex-wrap:wrap}.row>*{flex:1}
input,textarea{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:14px;background:#fffefb;color:var(--ink);font:inherit}textarea{min-height:220px;font-family:monospace}
button{border:0;border-radius:999px;padding:10px 16px;background:var(--accent);color:#fff;cursor:pointer;font:inherit}.secondary{background:#6c5c4f}.danger{background:var(--danger)}
.list{max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:16px;background:#fffdf9}.item{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:12px 14px;border-bottom:1px solid #eee1cb}.item:last-child{border-bottom:0}
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}.tab{padding:10px 14px;border-radius:999px;border:1px solid var(--border);background:rgba(255,250,243,.8);cursor:pointer}.tab.active{background:var(--ink);border-color:var(--ink);color:#fff}.panel{display:none}.panel.active{display:block}.toast{position:fixed;top:18px;left:50%;transform:translateX(-50%);padding:12px 18px;border-radius:999px;background:#1f2937;color:#fff;display:none;z-index:99}
@media (max-width:720px){.status{grid-template-columns:1fr}.hero h1{font-size:28px}}
</style>
</head>
<body>
<div id="toast" class="toast"></div>
<div class="wrap">
<section class="hero">
<h1>Showen 远程控制台</h1>
<p>Warp Web UI + HTTP API覆盖旧 `api_server.rs` 的控制、配置、视频、WiFi 与 BLE 端点。</p>
</section>
<div class="tabs">
<button class="tab active" data-tab="control">播放控制</button>
<button class="tab" data-tab="videos">视频管理</button>
<button class="tab" data-tab="wifi">网络设置</button>
<button class="tab" data-tab="settings">显示与配置</button>
</div>
<section class="panel active" id="panel-control">
<div class="grid">
<div class="card"><h2>播放状态</h2><div class="status"><div><span class="label">状态</span><span id="st-state" class="val">--</span></div><div><span class="label">当前视频</span><span id="st-video" class="val">--</span></div><div><span class="label">索引</span><span id="st-index" class="val">--</span></div><div><span class="label">列表长度</span><span id="st-len" class="val">--</span></div></div></div>
<div class="card"><h2>播放控制</h2><div class="btns"><button class="secondary" onclick="api('POST','/api/previous')">上一个</button><button onclick="api('POST','/api/play')">播放</button><button class="secondary" onclick="api('POST','/api/pause')">暂停</button><button onclick="api('POST','/api/next')">下一个</button></div><div class="row" style="margin-top:10px"><input id="goto-idx" type="number" min="0" placeholder="输入视频索引"><button onclick="gotoVideo()">跳转</button></div></div>
<div class="card"><h2>触发器</h2><div class="btns"><button class="secondary" onclick="triggerPreset('voice','name')">语音唤醒</button><button class="secondary" onclick="triggerPreset('button','button1')">按钮1</button><button class="secondary" onclick="triggerPreset('button','button2')">按钮2</button><button class="secondary" onclick="triggerPreset('sensor','touch')">触摸</button></div><label>名称</label><input id="tr-name" type="text" placeholder="voice"><label>值</label><input id="tr-value" type="text" placeholder="name"><div class="btns" style="margin-top:10px"><button onclick="triggerCustom()">发送触发器</button></div></div>
</div>
</section>
<section class="panel" id="panel-videos"><div class="grid"><div class="card"><h2>上传视频</h2><input id="upload-file" type="file" accept="video/*" multiple><div class="btns" style="margin-top:10px"><button onclick="uploadVideos()">上传</button></div></div><div class="card"><h2>设备文件</h2><div id="video-list" class="list"><div class="item">加载中...</div></div><div class="btns" style="margin-top:10px"><button class="secondary" onclick="loadVideoList()">刷新</button></div></div></div></section>
<section class="panel" id="panel-wifi"><div class="grid"><div class="card"><h2>网络状态</h2><div class="status"><div><span class="label">连接</span><span id="wifi-connected" class="val">--</span></div><div><span class="label">SSID</span><span id="wifi-ssid" class="val">--</span></div><div><span class="label">IP</span><span id="wifi-ip" class="val">--</span></div><div><span class="label">BLE</span><span id="ble-status" class="val">--</span></div></div><div class="btns" style="margin-top:10px"><button class="secondary" onclick="loadWifiStatus();loadBleStatus()">刷新状态</button></div></div><div class="card"><h2>扫描 WiFi</h2><div id="wifi-list" class="list"><div class="item">点击扫描按钮搜索附近网络</div></div><div class="btns" style="margin-top:10px"><button onclick="scanWifi()">扫描</button></div></div><div class="card"><h2>连接 WiFi</h2><label>SSID</label><input id="wifi-ssid-input" type="text"><label>密码</label><input id="wifi-pass-input" type="password"><div class="btns" style="margin-top:10px"><button onclick="connectWifi()">连接</button></div></div><div class="card"><h2>热点与 BLE</h2><label>热点名称</label><input id="ap-ssid" type="text" value="showen"><label>热点密码</label><input id="ap-pass" type="text" value="12345678"><div class="btns" style="margin-top:10px"><button onclick="startAP()">开启热点</button><button class="danger" onclick="stopAP()">关闭热点</button></div><label>BLE 设备名</label><input id="ble-name" type="text" value="showen"><div class="btns" style="margin-top:10px"><button class="secondary" onclick="startBLE()">兼容启动接口</button><button class="danger" onclick="stopBLE()">兼容停止接口</button></div></div></div></section>
<section class="panel" id="panel-settings"><div class="grid"><div class="card"><h2>显示设置</h2><div id="display-form"></div><div class="btns" style="margin-top:10px"><button onclick="saveDisplay()">保存显示设置</button></div></div><div class="card"><h2>配置编辑器</h2><textarea id="cfg-editor"></textarea><div class="btns" style="margin-top:10px"><button class="secondary" onclick="loadConfig()">重新加载</button><button class="secondary" onclick="formatConfig()">格式化</button><button onclick="saveConfig()">保存配置</button></div></div></div></section>
</div>
<script>
var cachedConfig=null;
function $(id){return document.getElementById(id)}
function toast(msg,err){var el=$('toast');el.textContent=msg;el.style.display='block';el.style.background=err?'#7f1d1d':'#1f2937';clearTimeout(el._timer);el._timer=setTimeout(function(){el.style.display='none'},3000)}
document.querySelectorAll('.tab').forEach(function(tab){tab.onclick=function(){document.querySelectorAll('.tab').forEach(function(el){el.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(el){el.classList.remove('active')});tab.classList.add('active');$('panel-'+tab.dataset.tab).classList.add('active');if(tab.dataset.tab==='videos')loadVideoList();if(tab.dataset.tab==='wifi'){loadWifiStatus();loadBleStatus()}if(tab.dataset.tab==='settings'&&!cachedConfig)loadConfig()}})
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');refreshStatus();return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(function(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val paused'}else if(d.paused){el.textContent='已暂停';el.className='val paused'}else{el.textContent='播放中';el.className='val'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}).catch(function(){})}
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}api('POST','/api/goto/'+idx)}
function triggerPreset(name,value){api('POST','/api/trigger/'+encodeURIComponent(name)+'/'+encodeURIComponent(value||''))}
function triggerCustom(){var name=$('tr-name').value;var value=$('tr-value').value;if(!name){toast('请输入触发器名',true);return}triggerPreset(name,value)}
function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item">目录中没有视频文件</div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span>'+escapeHtml(f.name)+' ('+sz+')</span><button class="danger" onclick="deleteVideo(\''+jsString(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})}
function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(function(d){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val':'val paused';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}).catch(function(){})}
function scanWifi(){$('wifi-list').innerHTML='<div class="item">扫描中...</div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item">未发现 WiFi 网络</div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span>'+escapeHtml(n.ssid||'隐藏网络')+' / '+escapeHtml(String(n.signal||0))+' / '+escapeHtml(n.security||'OPEN')+'</span><button class="secondary" onclick="selectWifi(\''+jsString(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})}
function selectWifi(ssid){$('wifi-ssid-input').value=ssid}
function connectWifi(){var ssid=$('wifi-ssid-input').value;var password=$('wifi-pass-input').value;if(!ssid){toast('请输入 WiFi 名称',true);return}api('POST','/api/wifi/connect',{ssid:ssid,password:password}).then(function(){setTimeout(loadWifiStatus,1500)})}
function startAP(){var ssid=$('ap-ssid').value||'showen';var password=$('ap-pass').value||'12345678';if(password.length<8){toast('热点密码至少 8 位',true);return}api('POST','/api/wifi/ap/start',{ssid:ssid,password:password})}
function stopAP(){api('POST','/api/wifi/ap/stop')}
function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val'}else{el.textContent='未就绪';el.className='val paused'}}).catch(function(){})}
function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)}
function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)}
function loadConfig(){fetch('/api/config').then(function(r){return r.json()}).then(function(cfg){cachedConfig=cfg;$('cfg-editor').value=JSON.stringify(cfg,null,2);renderDisplay(cfg.display)}).catch(function(){toast('加载配置失败',true)})}
function formatConfig(){try{$('cfg-editor').value=JSON.stringify(JSON.parse($('cfg-editor').value),null,2)}catch(_){toast('JSON 格式错误',true)}}
function saveConfig(){var raw=$('cfg-editor').value;try{JSON.parse(raw)}catch(_){toast('JSON 格式错误',true);return}api('POST','/api/config',raw).then(loadConfig)}
function renderDisplay(d){if(!d)return;var html='';html+='<label><input id="d-fullscreen" type="checkbox" '+(d.fullscreen?'checked':'')+'> 全屏</label>';html+='<label>窗口标题</label><input id="d-title" type="text" value="'+escapeAttr(d.window_title||'')+'">';html+='<label>旋转角度</label><input id="d-rotation" type="number" value="'+escapeAttr(String(d.rotation||0))+'">';html+='<label>渲染宽度</label><input id="d-render-width" type="number" value="'+escapeAttr(String(d.render_width||1024))+'">';html+='<label>渲染高度</label><input id="d-render-height" type="number" value="'+escapeAttr(String(d.render_height||1024))+'">';html+='<label>色键下限</label><input id="d-ck-min" type="text" value="'+escapeAttr((d.chroma_key&&d.chroma_key.hsv_min?d.chroma_key.hsv_min.join(','):'0,0,200'))+'">';html+='<label>色键上限</label><input id="d-ck-max" type="text" value="'+escapeAttr((d.chroma_key&&d.chroma_key.hsv_max?d.chroma_key.hsv_max.join(','):'180,30,255'))+'">';html+='<label>透视点 (JSON)</label><input id="d-points" type="text" value="'+escapeAttr(JSON.stringify((d.perspective_correction&&d.perspective_correction.points)||[]))+'">';$('display-form').innerHTML=html}
function saveDisplay(){if(!cachedConfig){loadConfig();return}var next=JSON.parse(JSON.stringify(cachedConfig));next.display.fullscreen=$('d-fullscreen').checked;next.display.window_title=$('d-title').value;next.display.rotation=parseInt($('d-rotation').value||'0',10);next.display.render_width=parseInt($('d-render-width').value||'1024',10);next.display.render_height=parseInt($('d-render-height').value||'1024',10);next.display.chroma_key=next.display.chroma_key||{};next.display.chroma_key.hsv_min=$('d-ck-min').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});next.display.chroma_key.hsv_max=$('d-ck-max').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});next.display.perspective_correction=next.display.perspective_correction||{};try{next.display.perspective_correction.points=JSON.parse($('d-points').value)}catch(_){toast('透视点 JSON 无效',true);return}cachedConfig=next;$('cfg-editor').value=JSON.stringify(next,null,2);saveConfig()}
function escapeHtml(v){return String(v).replace(/[&<>\"]/g,function(ch){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[ch]})}
function escapeAttr(v){return escapeHtml(v).replace(/'/g,'&#39;')}
function jsString(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
refreshStatus();setInterval(refreshStatus,3000);
</script>
</body>
</html>"#;