新增notci-client项目
@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Prevent AI from making assumptions or implementing unrequested features
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Core Behavior Constraints (P0)
|
||||
**Do NOT** implement features, code, or logic that was not explicitly requested by the user.
|
||||
**Do NOT** add comments, documentation, or explanatory text unless explicitly requested.
|
||||
**Do NOT** provide multiple solutions. Choose the optimal solution and implement it directly.
|
||||
|
||||
## Handling Ambiguity (P0)
|
||||
If the user's request is ambiguous or critical information is missing, **MUST** ask exactly **ONE** clarifying question before taking any action.
|
||||
**DO NOT** generate any code or assume the missing information.
|
||||
|
||||
## Citation Rules (P0)
|
||||
**MUST** strictly reference rules from the **actual files** located in `.cursor/rules/`.
|
||||
**DO NOT** invent, cite, or assume the existence of any rule that is not explicitly defined.
|
||||
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
src-tauri/target/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@ -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>信息发布屏</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "notice-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build:deb": "tauri build --bundles deb --target x86_64-unknown-linux-gnu",
|
||||
"build:deb:x64": "tauri build --bundles deb --target x86_64-unknown-linux-gnu",
|
||||
"build:deb:arm64": "tauri build --bundles deb --target aarch64-unknown-linux-gnu",
|
||||
"build:deb:all": "bash ../scripts/build-linux-deb-all.sh notice-client",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"icons:generate": "tauri icon ./src-tauri/icons/zh_icon.png"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tauri-apps/api": "^1",
|
||||
"element-plus": "^2.13.6",
|
||||
"sass": "^1.98.0",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.0.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^1",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "notice-client"
|
||||
version = "0.1.0"
|
||||
description = "Notice display client"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "notice_client_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1", features = [] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "1", features = ["api-all", "system-tray"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
fs2 = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
flate2 = "1"
|
||||
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 31 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 733 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@ -0,0 +1,22 @@
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn emit_to_window(
|
||||
app: AppHandle,
|
||||
label: String,
|
||||
event: String,
|
||||
payload: Value,
|
||||
) -> Result<(), String> {
|
||||
let Some(window) = app.get_window(label.as_str()) else {
|
||||
return Err(format!("窗口不存在: {label}"));
|
||||
};
|
||||
window
|
||||
.emit(event.as_str(), payload)
|
||||
.map_err(|error| format!("发送窗口事件失败: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_windows(app: AppHandle) -> Vec<String> {
|
||||
app.windows().keys().cloned().collect()
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
env,
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
const APP_NAME: &str = "com.ziyun.noticeclient";
|
||||
const LOG_FILE_NAME: &str = "app.log";
|
||||
const MAX_LOG_SIZE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
const LOG_RETENTION_DAYS: u64 = 7;
|
||||
|
||||
fn get_home_dir() -> PathBuf {
|
||||
if let Ok(value) = env::var("HOME") {
|
||||
return PathBuf::from(value);
|
||||
}
|
||||
|
||||
if let Ok(value) = env::var("USERPROFILE") {
|
||||
return PathBuf::from(value);
|
||||
}
|
||||
|
||||
PathBuf::from(".")
|
||||
}
|
||||
|
||||
fn get_state_dir() -> PathBuf {
|
||||
if cfg!(target_os = "linux") {
|
||||
if let Ok(value) = env::var("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(value).join(APP_NAME);
|
||||
}
|
||||
|
||||
return get_home_dir().join(".config").join(APP_NAME);
|
||||
}
|
||||
|
||||
if let Ok(value) = env::var("APPDATA") {
|
||||
return PathBuf::from(value).join(APP_NAME);
|
||||
}
|
||||
|
||||
get_home_dir().join(".").join(APP_NAME)
|
||||
}
|
||||
|
||||
fn ensure_parent_dir(path: &Path) -> Result<(), String> {
|
||||
let Some(parent) = path.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
fs::create_dir_all(parent).map_err(|error| format!("创建日志目录失败: {error}"))
|
||||
}
|
||||
|
||||
fn cleanup_old_logs(dir: &Path) -> Result<(), String> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let retention = Duration::from_secs(LOG_RETENTION_DAYS * 24 * 60 * 60);
|
||||
let now = SystemTime::now();
|
||||
let entries = fs::read_dir(dir).map_err(|error| format!("读取日志目录失败: {error}"))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| format!("遍历日志目录失败: {error}"))?;
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = entry
|
||||
.metadata()
|
||||
.map_err(|error| format!("读取日志元数据失败: {error}"))?;
|
||||
|
||||
let Ok(modified_at) = metadata.modified() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if now.duration_since(modified_at).unwrap_or_default() > retention {
|
||||
fs::remove_file(&path).map_err(|error| format!("清理过期日志失败: {error}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate_log_if_needed(path: &Path) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(path).map_err(|error| format!("读取日志文件失败: {error}"))?;
|
||||
if metadata.len() < MAX_LOG_SIZE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| format!("生成日志时间戳失败: {error}"))?
|
||||
.as_secs();
|
||||
|
||||
let rotated_path = path.with_file_name(format!("app-{timestamp}.log"));
|
||||
fs::rename(path, rotated_path).map_err(|error| format!("轮转日志失败: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn app_log(level: String, message: String) -> Result<(), String> {
|
||||
let dir = get_state_dir();
|
||||
let path = dir.join(LOG_FILE_NAME);
|
||||
ensure_parent_dir(&path)?;
|
||||
cleanup_old_logs(&dir)?;
|
||||
rotate_log_if_needed(&path)?;
|
||||
|
||||
let now = SystemTime::now();
|
||||
let unix = now
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| format!("生成日志时间失败: {error}"))?;
|
||||
let ts_secs = unix.as_secs();
|
||||
let ts_millis = unix.as_millis();
|
||||
let local_time: DateTime<Local> = DateTime::from(now);
|
||||
let formatted_time = local_time.format("%Y-%m-%d %H:%M:%S%.3f %:z");
|
||||
let single_line_message = message.replace('\n', "\\n").replace('\r', "");
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|error| format!("打开日志文件失败: {error}"))?;
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
"[{formatted_time}] [unix_s={ts_secs}] [unix_ms={ts_millis}] [{}] {single_line_message}",
|
||||
level.to_uppercase()
|
||||
)
|
||||
.map_err(|error| format!("写入日志失败: {error}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogPaths {
|
||||
pub log_dir: String,
|
||||
pub log_file: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_log_paths() -> Result<LogPaths, String> {
|
||||
let dir = get_state_dir();
|
||||
let file = dir.join(LOG_FILE_NAME);
|
||||
Ok(LogPaths {
|
||||
log_dir: dir.to_string_lossy().to_string(),
|
||||
log_file: file.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod events;
|
||||
pub mod logger;
|
||||
pub mod update;
|
||||
pub mod window;
|
||||
@ -0,0 +1,160 @@
|
||||
use tauri::{AppHandle, LogicalSize, Manager, PhysicalPosition, Size, WindowBuilder, WindowUrl};
|
||||
|
||||
const DEV_MAIN_WIDTH: f64 = 1920.0;
|
||||
const DEV_MAIN_HEIGHT: f64 = 1080.0;
|
||||
|
||||
fn is_dev_main_window() -> bool {
|
||||
cfg!(debug_assertions)
|
||||
}
|
||||
|
||||
pub fn ensure_setup_window(app: AppHandle) -> Result<(), String> {
|
||||
if app.get_window("setup").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
WindowBuilder::new(&app, "setup", WindowUrl::App("/#/setup".into()))
|
||||
.title("服务地址配置")
|
||||
.inner_size(480.0, 560.0)
|
||||
.resizable(false)
|
||||
.maximizable(false)
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.build()
|
||||
.map_err(|error| format!("创建配置窗口失败: {error}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_main_window(app: AppHandle) -> Result<(), String> {
|
||||
if app.get_window("main").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut builder = WindowBuilder::new(&app, "main", WindowUrl::App("/#/index".into()))
|
||||
.title("信息发布屏")
|
||||
.visible(false);
|
||||
|
||||
if is_dev_main_window() {
|
||||
builder = builder
|
||||
.inner_size(DEV_MAIN_WIDTH, DEV_MAIN_HEIGHT)
|
||||
.min_inner_size(960.0, 540.0)
|
||||
.resizable(true)
|
||||
.decorations(true)
|
||||
.fullscreen(false);
|
||||
} else {
|
||||
builder = builder
|
||||
.decorations(false)
|
||||
.fullscreen(true)
|
||||
.resizable(false)
|
||||
.maximizable(false);
|
||||
}
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|error| format!("创建主窗口失败: {error}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn center_window_on_primary_monitor(window: &tauri::Window) {
|
||||
let Ok(Some(monitor)) = window.primary_monitor() else {
|
||||
let _ = window.center();
|
||||
return;
|
||||
};
|
||||
|
||||
let mon_pos = monitor.position();
|
||||
let mon_size = monitor.size();
|
||||
let Ok(outer) = window.outer_size() else {
|
||||
let _ = window.center();
|
||||
return;
|
||||
};
|
||||
|
||||
let w = outer.width as i32;
|
||||
let h = outer.height as i32;
|
||||
let screen_w = mon_size.width as i32;
|
||||
let screen_h = mon_size.height as i32;
|
||||
let x = mon_pos.x + (screen_w - w) / 2;
|
||||
let y = mon_pos.y + (screen_h - h) / 2;
|
||||
let _ = window.set_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
|
||||
fn present_main_window(main: &tauri::Window) {
|
||||
if is_dev_main_window() {
|
||||
let _ = main.set_fullscreen(false);
|
||||
let _ = main.set_decorations(true);
|
||||
let _ = main.set_size(Size::Logical(LogicalSize::new(
|
||||
DEV_MAIN_WIDTH,
|
||||
DEV_MAIN_HEIGHT,
|
||||
)));
|
||||
center_window_on_primary_monitor(main);
|
||||
} else {
|
||||
let _ = main.set_decorations(false);
|
||||
let _ = main.set_fullscreen(true);
|
||||
}
|
||||
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_main_window(app: AppHandle) -> Result<(), String> {
|
||||
ensure_main_window(app.clone())?;
|
||||
|
||||
if let Some(setup) = app.get_window("setup") {
|
||||
let _ = setup.hide();
|
||||
}
|
||||
|
||||
if let Some(main) = app.get_window("main") {
|
||||
let _ = main.eval("window.location.hash = '#/index';");
|
||||
present_main_window(&main);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_setup_window(app: AppHandle) -> Result<(), String> {
|
||||
ensure_setup_window(app.clone())?;
|
||||
|
||||
if let Some(main) = app.get_window("main") {
|
||||
let _ = main.hide();
|
||||
}
|
||||
|
||||
if let Some(setup) = app.get_window("setup") {
|
||||
let _ = setup.show();
|
||||
let _ = setup.set_focus();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn quit_app(app: AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
pub fn toggle_main_window_from_tray(app: &AppHandle) {
|
||||
if ensure_main_window(app.clone()).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(main) = app.get_window("main") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let visible = main.is_visible().unwrap_or(false);
|
||||
let minimized = main.is_minimized().unwrap_or(false);
|
||||
|
||||
if minimized {
|
||||
let _ = main.unminimize();
|
||||
present_main_window(&main);
|
||||
return;
|
||||
}
|
||||
|
||||
if visible {
|
||||
let _ = main.minimize();
|
||||
return;
|
||||
}
|
||||
|
||||
present_main_window(&main);
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
mod commands;
|
||||
|
||||
use std::{
|
||||
fs::{create_dir_all, File, OpenOptions},
|
||||
io,
|
||||
};
|
||||
|
||||
use commands::{
|
||||
config::{config_get_all, config_merge},
|
||||
events::{emit_to_window, list_windows},
|
||||
logger::{app_log, get_log_paths},
|
||||
update::{
|
||||
check_apt_update, setup_zyyun_apt_source,
|
||||
spawn_background_pending_notice_client_update_sync, upgrade_notice_client_via_apt,
|
||||
},
|
||||
window::{
|
||||
ensure_main_window, ensure_setup_window, open_main_window, open_setup_window, quit_app,
|
||||
toggle_main_window_from_tray,
|
||||
},
|
||||
};
|
||||
use fs2::FileExt;
|
||||
use tauri::{
|
||||
CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu,
|
||||
};
|
||||
|
||||
const TRAY_MENU_SHOW_ID: &str = "tray_show_window";
|
||||
const TRAY_MENU_QUIT_ID: &str = "tray_quit";
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct SingleInstanceLock(File);
|
||||
|
||||
fn acquire_single_instance_lock(app: &tauri::App) -> Result<SingleInstanceLock, String> {
|
||||
let base_dir = app.path_resolver().app_data_dir().ok_or("无法获取应用数据目录")?;
|
||||
create_dir_all(&base_dir).map_err(|err| format!("创建锁目录失败: {err}"))?;
|
||||
|
||||
let lock_path = base_dir.join("single-instance.lock");
|
||||
let lock_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(lock_path)
|
||||
.map_err(|err| format!("打开锁文件失败: {err}"))?;
|
||||
|
||||
match lock_file.try_lock_exclusive() {
|
||||
Ok(()) => Ok(SingleInstanceLock(lock_file)),
|
||||
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
|
||||
Err("duplicate instance".to_string())
|
||||
}
|
||||
Err(err) => Err(format!("加锁失败: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_server_ip_configured() -> bool {
|
||||
match config_get_all() {
|
||||
Ok(map) => map
|
||||
.get("server_ip")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let tray_menu = SystemTrayMenu::new()
|
||||
.add_item(CustomMenuItem::new(TRAY_MENU_SHOW_ID, "显示/隐藏主窗口"))
|
||||
.add_item(CustomMenuItem::new(TRAY_MENU_QUIT_ID, "退出"));
|
||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||
|
||||
tauri::Builder::default()
|
||||
.system_tray(system_tray)
|
||||
.on_system_tray_event(|app, event| match event {
|
||||
SystemTrayEvent::DoubleClick { .. } | SystemTrayEvent::LeftClick { .. } => {
|
||||
toggle_main_window_from_tray(app);
|
||||
}
|
||||
SystemTrayEvent::MenuItemClick { id, .. } if id.as_str() == TRAY_MENU_SHOW_ID => {
|
||||
toggle_main_window_from_tray(app);
|
||||
}
|
||||
SystemTrayEvent::MenuItemClick { id, .. } if id.as_str() == TRAY_MENU_QUIT_ID => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.setup(|app| {
|
||||
match acquire_single_instance_lock(app) {
|
||||
Ok(lock) => {
|
||||
app.manage(lock);
|
||||
}
|
||||
Err(reason) if reason == "duplicate instance" => {
|
||||
tauri::api::dialog::blocking::message(
|
||||
None::<&tauri::Window>,
|
||||
"提示",
|
||||
"请勿重复打开",
|
||||
);
|
||||
app.handle().exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
Err(reason) => {
|
||||
return Err(reason.into());
|
||||
}
|
||||
}
|
||||
|
||||
ensure_setup_window(app.handle())?;
|
||||
ensure_main_window(app.handle())?;
|
||||
|
||||
if has_server_ip_configured() {
|
||||
let _ = open_main_window(app.handle());
|
||||
}
|
||||
|
||||
spawn_background_pending_notice_client_update_sync(app.handle());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
config_get_all,
|
||||
config_merge,
|
||||
app_log,
|
||||
get_log_paths,
|
||||
emit_to_window,
|
||||
list_windows,
|
||||
check_apt_update,
|
||||
setup_zyyun_apt_source,
|
||||
upgrade_notice_client_via_apt,
|
||||
open_main_window,
|
||||
open_setup_window,
|
||||
quit_app
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
notice_client_lib::run()
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"package": {
|
||||
"productName": "notice-client",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devPath": "http://localhost:1422",
|
||||
"distDir": "../dist"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": true,
|
||||
"http": {
|
||||
"all": true,
|
||||
"request": true,
|
||||
"scope": ["http://*/*", "https://*/*"]
|
||||
}
|
||||
},
|
||||
"windows": [],
|
||||
"systemTray": {
|
||||
"iconPath": "icons/icon.ico"
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb"],
|
||||
"identifier": "com.ziyun.noticeclient",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@ -0,0 +1,126 @@
|
||||
import { http } from "../utils/service";
|
||||
|
||||
const VIDEO_LIST_PATH = "/notice/videolist";
|
||||
const TICKET_LIST_PATH = "/notice/ticketlist";
|
||||
|
||||
let cachedVideoUrls: string[] = [];
|
||||
let videoListPromise: Promise<string[]> | null = null;
|
||||
|
||||
export interface NoticeCallingItem {
|
||||
ticketId: string;
|
||||
winName: string;
|
||||
isTransfer?: boolean;
|
||||
}
|
||||
|
||||
export interface NoticeTicketListData {
|
||||
total: number;
|
||||
waitingNum: number;
|
||||
completedNum: number;
|
||||
dealingNum: number;
|
||||
callinglist: NoticeCallingItem[];
|
||||
abandonedlist: string[];
|
||||
waitinglist: string[];
|
||||
}
|
||||
|
||||
function normalizeCallingItem(raw: unknown): NoticeCallingItem | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const item = raw as Record<string, unknown>;
|
||||
const ticketId = item.ticketId ?? item.ticket_id;
|
||||
const winName = item.winName ?? item.win_name;
|
||||
if (ticketId == null || winName == null) {
|
||||
return null;
|
||||
}
|
||||
const isTransfer = item.isTransfer ?? item.is_transfer;
|
||||
return {
|
||||
ticketId: String(ticketId),
|
||||
winName: String(winName),
|
||||
isTransfer: Boolean(isTransfer),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTicketIdFromListItem(raw: unknown): string | null {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw === "string" || typeof raw === "number") {
|
||||
const value = String(raw).trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
if (typeof raw === "object") {
|
||||
const item = raw as Record<string, unknown>;
|
||||
const ticketId = item.ticketId ?? item.ticket_id;
|
||||
if (ticketId == null) {
|
||||
return null;
|
||||
}
|
||||
const value = String(ticketId).trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTicketIdList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.map(normalizeTicketIdFromListItem)
|
||||
.filter((ticketId): ticketId is string => ticketId !== null);
|
||||
}
|
||||
|
||||
function normalizeTicketListData(raw: unknown): NoticeTicketListData {
|
||||
const data = (raw ?? {}) as Record<string, unknown>;
|
||||
const callingRaw = Array.isArray(data.callinglist) ? data.callinglist : [];
|
||||
const callinglist = callingRaw
|
||||
.map(normalizeCallingItem)
|
||||
.filter((item): item is NoticeCallingItem => item !== null);
|
||||
|
||||
return {
|
||||
total: Number(data.total ?? 0),
|
||||
waitingNum: Number(data.waitingNum ?? data.waiting_num ?? 0),
|
||||
completedNum: Number(data.completedNum ?? data.completed_num ?? 0),
|
||||
dealingNum: Number(data.dealingNum ?? data.dealing_num ?? 0),
|
||||
callinglist,
|
||||
abandonedlist: normalizeTicketIdList(data.abandonedlist),
|
||||
waitinglist: normalizeTicketIdList(data.waitinglist),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchNoticeVideoList(): Promise<string[]> {
|
||||
const data = await http.get<unknown>(VIDEO_LIST_PATH);
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
return data.map((url) => String(url)).filter((url) => url.trim().length > 0);
|
||||
}
|
||||
|
||||
export async function fetchNoticeTicketList(): Promise<NoticeTicketListData> {
|
||||
const data = await http.get<unknown>(TICKET_LIST_PATH);
|
||||
return normalizeTicketListData(data);
|
||||
}
|
||||
|
||||
export function getCachedNoticeVideoUrls(): string[] {
|
||||
return [...cachedVideoUrls];
|
||||
}
|
||||
|
||||
/** 客户端打开时拉取宣传视频列表(结果缓存,重复调用复用同一请求)。 */
|
||||
export async function loadNoticeVideoListOnOpen(force = false): Promise<string[]> {
|
||||
if (!force && cachedVideoUrls.length > 0) {
|
||||
return [...cachedVideoUrls];
|
||||
}
|
||||
if (!force && videoListPromise) {
|
||||
return videoListPromise;
|
||||
}
|
||||
|
||||
videoListPromise = fetchNoticeVideoList()
|
||||
.then((urls) => {
|
||||
cachedVideoUrls = urls;
|
||||
return [...urls];
|
||||
})
|
||||
.finally(() => {
|
||||
videoListPromise = null;
|
||||
});
|
||||
|
||||
return videoListPromise;
|
||||
}
|
||||
|
After Width: | Height: | Size: 733 KiB |
@ -0,0 +1,35 @@
|
||||
:root {
|
||||
--ev-c-white: #ffffff;
|
||||
--ev-c-black: #1b1b1f;
|
||||
--ev-c-gray-1: #515c67;
|
||||
--ev-c-gray-2: #414853;
|
||||
--ev-c-gray-3: #32363f;
|
||||
--ev-c-text-1: rgba(255, 255, 245, 0.86);
|
||||
--color-background: #f3f4f6;
|
||||
--color-text: #111827;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
@import "./base.css";
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import type { AppConfig } from "./types";
|
||||
|
||||
export async function getAllConfig(): Promise<AppConfig> {
|
||||
try {
|
||||
return await invoke<AppConfig>("config_get_all");
|
||||
} catch (error) {
|
||||
throw new Error(`读取配置失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeConfig(partial: AppConfig): Promise<AppConfig> {
|
||||
try {
|
||||
return await invoke<AppConfig>("config_merge", { partial });
|
||||
} catch (error) {
|
||||
throw new Error(`写入配置失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import type { LogLevel } from "./types";
|
||||
|
||||
function extractCallerFromStack(): string | null {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = stack.split("\n").map((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
if (line.includes("logger.ts")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match =
|
||||
line.match(/(src\/[^)\s]+:\d+:\d+)/) ??
|
||||
line.match(/([A-Za-z]:\\[^)\s]+:\d+:\d+)/);
|
||||
if (match?.[1]) {
|
||||
return match[1].replace(/\\/g, "/");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function log(level: LogLevel, message: string): Promise<void> {
|
||||
try {
|
||||
const caller = extractCallerFromStack();
|
||||
const enrichedMessage = caller ? `${message} [source=${caller}]` : message;
|
||||
await invoke("app_log", { level, message: enrichedMessage });
|
||||
} catch (error) {
|
||||
throw new Error(`写入日志失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export type AppConfig = Record<string, JsonValue>;
|
||||
|
||||
export interface NativeConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
export interface AppLogPaths {
|
||||
logDir: string;
|
||||
logFile: string;
|
||||
}
|
||||
|
||||
export type ShowErrorLogActions = "none" | "file";
|
||||
|
||||
export interface ShowErrorNativeOptions {
|
||||
logActions?: ShowErrorLogActions;
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface NoticeDisplayConfig {
|
||||
pageBgColor: string;
|
||||
headerBgColor: string;
|
||||
headerTextColor: string;
|
||||
headerTitle: string;
|
||||
showVideoArea: boolean;
|
||||
infoAreaBgColor: string;
|
||||
infoAreaTextColor: string;
|
||||
}
|
||||
|
||||
export interface PendingNoticeClientUpdateStored {
|
||||
candidateVersion: string;
|
||||
}
|
||||
|
||||
export interface AptUpdateCheckResult {
|
||||
installedVersion: string;
|
||||
candidateVersion: string;
|
||||
sourceAvailable: boolean;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
/** 叫号行票号标签 */
|
||||
export type CallTicketTag = "转移" | "VIP" | "回签";
|
||||
|
||||
export interface CallMessageItem {
|
||||
text: string;
|
||||
tags?: CallTicketTag[];
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { appWindow } from "@tauri-apps/api/window";
|
||||
|
||||
export async function minimizeWindow(): Promise<void> {
|
||||
try {
|
||||
await appWindow.minimize();
|
||||
} catch (error) {
|
||||
throw new Error(`最小化窗口失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("quit_app");
|
||||
} catch (error) {
|
||||
throw new Error(`关闭窗口失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openMainWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_main_window");
|
||||
} catch (error) {
|
||||
throw new Error(`打开主窗口失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openSetupWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_setup_window");
|
||||
} catch (error) {
|
||||
throw new Error(`打开配置窗口失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function quitApplication(): Promise<void> {
|
||||
try {
|
||||
await invoke("quit_app");
|
||||
} catch (error) {
|
||||
throw new Error(`退出应用失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="shell">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.shell {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,48 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./assets/main.css";
|
||||
import { getAllConfig } from "./host/config";
|
||||
import { getServerIpFromConfig } from "./utils/noticeConfig";
|
||||
import { applyServerIpToHttp } from "./utils/service";
|
||||
import { loadNoticeVideoListOnOpen } from "./api/notice";
|
||||
import { router } from "./router";
|
||||
import { log } from "./host/logger";
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
try {
|
||||
const config = await withTimeout(getAllConfig(), 1500);
|
||||
const serverIp = getServerIpFromConfig(config);
|
||||
if (serverIp) {
|
||||
applyServerIpToHttp(serverIp);
|
||||
void loadNoticeVideoListOnOpen().catch(async (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await log("warn", `启动时拉取 videolist 失败: ${message}`);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 配置缺失时由路由守卫引导到设置页
|
||||
}
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router).mount("#app");
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@ -0,0 +1,61 @@
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
|
||||
import { getAllConfig } from "../host/config";
|
||||
import { getServerIpFromConfig } from "../utils/noticeConfig";
|
||||
import { applyServerIpToHttp } from "../utils/service";
|
||||
import MainShell from "../layouts/MainShell.vue";
|
||||
|
||||
const ServerSetupView = () => import("../views/ServerSetupView.vue");
|
||||
const IndexView = () => import("../views/IndexView.vue");
|
||||
const ConfigView = () => import("../views/ConfigView.vue");
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: "/setup", name: "setup", component: ServerSetupView },
|
||||
{
|
||||
path: "/",
|
||||
component: MainShell,
|
||||
children: [
|
||||
{ path: "", redirect: { name: "index" } },
|
||||
{ path: "index", name: "index", component: IndexView },
|
||||
{ path: "config", name: "config", component: ConfigView },
|
||||
{ path: "main-setup", name: "main-setup", component: ServerSetupView },
|
||||
],
|
||||
},
|
||||
{ path: "/:pathMatch(.*)*", redirect: { name: "index" } },
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
if (to.path === "/setup") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
let ip = "";
|
||||
try {
|
||||
const config = await getAllConfig();
|
||||
ip = getServerIpFromConfig(config);
|
||||
if (ip) {
|
||||
applyServerIpToHttp(ip);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[router] getAllConfig failed:", error);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ip && to.name !== "main-setup") {
|
||||
next({ name: "main-setup", replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (ip && to.name === "main-setup" && from.name !== "config") {
|
||||
next({ name: "index", replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
@ -0,0 +1,93 @@
|
||||
import type { AppConfig, NoticeDisplayConfig } from "../host/types";
|
||||
|
||||
export const NOTICE_CONFIG_KEYS = {
|
||||
pageBgColor: "page_bg_color",
|
||||
headerBgColor: "header_bg_color",
|
||||
headerTextColor: "header_text_color",
|
||||
headerTitle: "header_title",
|
||||
showVideoArea: "show_video_area",
|
||||
infoAreaBgColor: "info_area_bg_color",
|
||||
infoAreaTextColor: "info_area_text_color",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_NOTICE_DISPLAY_CONFIG: NoticeDisplayConfig = {
|
||||
pageBgColor: "#0f172a",
|
||||
headerBgColor: "#2563eb",
|
||||
headerTextColor: "#ffffff",
|
||||
headerTitle: "办事服务大厅",
|
||||
showVideoArea: true,
|
||||
infoAreaBgColor: "rgba(255,255,255,0.96)",
|
||||
infoAreaTextColor: "#1f2937",
|
||||
};
|
||||
|
||||
function readString(config: AppConfig, key: string, fallback: string): string {
|
||||
const value = config[key];
|
||||
return typeof value === "string" && value.trim() ? value : fallback;
|
||||
}
|
||||
|
||||
function readBoolean(config: AppConfig, key: string, fallback: boolean): boolean {
|
||||
const value = config[key];
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function noticeDisplayConfigFromAppConfig(config: AppConfig): NoticeDisplayConfig {
|
||||
return {
|
||||
pageBgColor: readString(
|
||||
config,
|
||||
NOTICE_CONFIG_KEYS.pageBgColor,
|
||||
DEFAULT_NOTICE_DISPLAY_CONFIG.pageBgColor,
|
||||
),
|
||||
headerBgColor: readString(
|
||||
config,
|
||||
NOTICE_CONFIG_KEYS.headerBgColor,
|
||||
DEFAULT_NOTICE_DISPLAY_CONFIG.headerBgColor,
|
||||
),
|
||||
headerTextColor: readString(
|
||||
config,
|
||||
NOTICE_CONFIG_KEYS.headerTextColor,
|
||||
DEFAULT_NOTICE_DISPLAY_CONFIG.headerTextColor,
|
||||
),
|
||||
headerTitle: readString(
|
||||
config,
|
||||
NOTICE_CONFIG_KEYS.headerTitle,
|
||||
DEFAULT_NOTICE_DISPLAY_CONFIG.headerTitle,
|
||||
),
|
||||
showVideoArea: readBoolean(
|
||||
config,
|
||||
NOTICE_CONFIG_KEYS.showVideoArea,
|
||||
DEFAULT_NOTICE_DISPLAY_CONFIG.showVideoArea,
|
||||
),
|
||||
infoAreaBgColor: readString(
|
||||
config,
|
||||
NOTICE_CONFIG_KEYS.infoAreaBgColor,
|
||||
DEFAULT_NOTICE_DISPLAY_CONFIG.infoAreaBgColor,
|
||||
),
|
||||
infoAreaTextColor: readString(
|
||||
config,
|
||||
NOTICE_CONFIG_KEYS.infoAreaTextColor,
|
||||
DEFAULT_NOTICE_DISPLAY_CONFIG.infoAreaTextColor,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function noticeDisplayConfigToPartial(
|
||||
display: NoticeDisplayConfig,
|
||||
): AppConfig {
|
||||
return {
|
||||
[NOTICE_CONFIG_KEYS.pageBgColor]: display.pageBgColor,
|
||||
[NOTICE_CONFIG_KEYS.headerBgColor]: display.headerBgColor,
|
||||
[NOTICE_CONFIG_KEYS.headerTextColor]: display.headerTextColor,
|
||||
[NOTICE_CONFIG_KEYS.headerTitle]: display.headerTitle,
|
||||
[NOTICE_CONFIG_KEYS.showVideoArea]: display.showVideoArea,
|
||||
[NOTICE_CONFIG_KEYS.infoAreaBgColor]: display.infoAreaBgColor,
|
||||
[NOTICE_CONFIG_KEYS.infoAreaTextColor]: display.infoAreaTextColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function getServerIpFromConfig(config: AppConfig): string {
|
||||
const value = config.server_ip;
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
|
||||
|
||||
export const API_QUEUE_PATH = "/api/queue";
|
||||
const DEFAULT_API_PORT = 8845;
|
||||
|
||||
let cachedBaseUrl = "";
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
code: number;
|
||||
msg?: string;
|
||||
message?: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
function shouldUseTauriHttpTransport(): boolean {
|
||||
return !import.meta.env.DEV;
|
||||
}
|
||||
|
||||
export function buildBaseUrlFromServerIp(serverIp: string): string {
|
||||
const raw = serverIp.trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
||||
return `${raw.replace(/\/$/, "")}${API_QUEUE_PATH}`;
|
||||
}
|
||||
|
||||
const hostPort = raw.replace(/^\/+/, "");
|
||||
const hasPort = /:\d+$/.test(hostPort) || /^\[.+\]:\d+$/.test(hostPort);
|
||||
if (hasPort) {
|
||||
return `http://${hostPort}${API_QUEUE_PATH}`;
|
||||
}
|
||||
|
||||
return `http://${hostPort}:${DEFAULT_API_PORT}${API_QUEUE_PATH}`;
|
||||
}
|
||||
|
||||
export function applyServerIpToHttp(serverIp: string): void {
|
||||
if (import.meta.env.DEV) {
|
||||
cachedBaseUrl = API_QUEUE_PATH;
|
||||
return;
|
||||
}
|
||||
|
||||
cachedBaseUrl = buildBaseUrlFromServerIp(serverIp);
|
||||
}
|
||||
|
||||
export function getHttpBaseUrl(): string {
|
||||
return cachedBaseUrl;
|
||||
}
|
||||
|
||||
function buildRequestUrl(relativePath: string): string {
|
||||
const base = getHttpBaseUrl().replace(/\/$/, "");
|
||||
const path = relativePath.replace(/^\//, "");
|
||||
if (!base) {
|
||||
throw new Error("未配置服务器地址,请先完成服务地址设置");
|
||||
}
|
||||
return `${base}/${path}`;
|
||||
}
|
||||
|
||||
function isApiSuccessCode(code: unknown): boolean {
|
||||
return code === 200 || code === 0;
|
||||
}
|
||||
|
||||
function getApiMessage(payload: unknown): string {
|
||||
const data = (payload ?? {}) as { message?: unknown; msg?: unknown };
|
||||
if (typeof data.message === "string" && data.message.trim()) {
|
||||
return data.message;
|
||||
}
|
||||
if (typeof data.msg === "string" && data.msg.trim()) {
|
||||
return data.msg;
|
||||
}
|
||||
return "请求失败";
|
||||
}
|
||||
|
||||
function unwrapEnvelope<T>(payload: ApiEnvelope<T>): T {
|
||||
if (!isApiSuccessCode(payload?.code)) {
|
||||
throw new Error(getApiMessage(payload));
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
async function requestGet<T>(relativePath: string): Promise<T> {
|
||||
const url = buildRequestUrl(relativePath);
|
||||
|
||||
if (shouldUseTauriHttpTransport()) {
|
||||
const response = await tauriFetch<ApiEnvelope<T>>(url, {
|
||||
method: "GET",
|
||||
responseType: ResponseType.JSON,
|
||||
timeout: 10000,
|
||||
});
|
||||
return unwrapEnvelope(response.data);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const payload = (await response.json()) as ApiEnvelope<T>;
|
||||
if (!response.ok) {
|
||||
throw new Error(getApiMessage(payload));
|
||||
}
|
||||
return unwrapEnvelope(payload);
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get<T>(relativePath: string): Promise<T> {
|
||||
return requestGet<T>(relativePath);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,639 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
fetchNoticeTicketList,
|
||||
getCachedNoticeVideoUrls,
|
||||
loadNoticeVideoListOnOpen,
|
||||
type NoticeCallingItem,
|
||||
} from "../api/notice";
|
||||
import { getAllConfig } from "../host/config";
|
||||
import { log } from "../host/logger";
|
||||
import {
|
||||
DEFAULT_NOTICE_DISPLAY_CONFIG,
|
||||
noticeDisplayConfigFromAppConfig,
|
||||
} from "../utils/noticeConfig";
|
||||
import type { CallTicketTag, NoticeDisplayConfig } from "../host/types";
|
||||
import badgeIcon from "../assets/badge_transparent.png";
|
||||
|
||||
const MAX_CALL_LINES = 8;
|
||||
const TICKET_POLL_MS = 2000;
|
||||
|
||||
const router = useRouter();
|
||||
const displayConfig = ref<NoticeDisplayConfig>({ ...DEFAULT_NOTICE_DISPLAY_CONFIG });
|
||||
|
||||
interface CallDisplayRow {
|
||||
ticket: string;
|
||||
window: string;
|
||||
tags: CallTicketTag[];
|
||||
}
|
||||
|
||||
const callRows = ref<CallDisplayRow[]>([]);
|
||||
const queueStats = ref({
|
||||
waiting: 0,
|
||||
totalTickets: 0,
|
||||
processed: 0,
|
||||
});
|
||||
const upcomingTickets = ref<string[]>([]);
|
||||
const abandonedTickets = ref<string[]>([]);
|
||||
|
||||
const videoUrls = ref<string[]>([]);
|
||||
const currentVideoIndex = ref(0);
|
||||
/** 每次切换视频递增,避免 URL 相同时 Vue 复用已结束的 video 元素 */
|
||||
const videoPlaySeq = ref(0);
|
||||
const promoVideoRef = ref<HTMLVideoElement | null>(null);
|
||||
|
||||
const displayedCallRows = computed(() => callRows.value.slice(0, MAX_CALL_LINES));
|
||||
|
||||
const currentVideoUrl = computed(() => {
|
||||
if (videoUrls.value.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const index = currentVideoIndex.value % videoUrls.value.length;
|
||||
return videoUrls.value[index] ?? "";
|
||||
});
|
||||
|
||||
const pageStyle = computed(() => ({
|
||||
backgroundColor: displayConfig.value.pageBgColor,
|
||||
}));
|
||||
|
||||
const headerStyle = computed(() => ({
|
||||
backgroundColor: displayConfig.value.headerBgColor,
|
||||
color: displayConfig.value.headerTextColor,
|
||||
}));
|
||||
|
||||
const infoPanelStyle = computed(() => ({
|
||||
backgroundColor: displayConfig.value.infoAreaBgColor,
|
||||
color: displayConfig.value.infoAreaTextColor,
|
||||
}));
|
||||
|
||||
function toCallDisplayRow(item: NoticeCallingItem): CallDisplayRow {
|
||||
const tags: CallTicketTag[] = [];
|
||||
if (item.isTransfer) {
|
||||
tags.push("转移");
|
||||
}
|
||||
return {
|
||||
ticket: item.ticketId,
|
||||
window: item.winName,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
function callTagClass(tag: CallTicketTag): string {
|
||||
if (tag === "转移") {
|
||||
return "call-tag--transfer";
|
||||
}
|
||||
if (tag === "VIP") {
|
||||
return "call-tag--vip";
|
||||
}
|
||||
return "call-tag--requeue";
|
||||
}
|
||||
|
||||
async function refreshTicketList(): Promise<void> {
|
||||
try {
|
||||
const data = await fetchNoticeTicketList();
|
||||
callRows.value = data.callinglist.map(toCallDisplayRow);
|
||||
queueStats.value = {
|
||||
waiting: data.waitingNum,
|
||||
totalTickets: data.total,
|
||||
processed: data.completedNum,
|
||||
};
|
||||
upcomingTickets.value = data.waitinglist;
|
||||
abandonedTickets.value = data.abandonedlist;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await log("warn", `ticketlist 轮询失败: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureVideoList(): Promise<void> {
|
||||
const cached = getCachedNoticeVideoUrls();
|
||||
if (cached.length > 0) {
|
||||
videoUrls.value = cached;
|
||||
currentVideoIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
videoUrls.value = await loadNoticeVideoListOnOpen();
|
||||
currentVideoIndex.value = 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await log("warn", `videolist 加载失败: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let isRefreshingVideoList = false;
|
||||
|
||||
async function playCurrentVideo(): Promise<void> {
|
||||
await nextTick();
|
||||
const el = promoVideoRef.value;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
el.currentTime = 0;
|
||||
await el.play();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await log("warn", `视频自动播放失败: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function advanceToVideo(index: number): void {
|
||||
currentVideoIndex.value = index;
|
||||
videoPlaySeq.value += 1;
|
||||
}
|
||||
|
||||
async function handleVideoEnded(): Promise<void> {
|
||||
const count = videoUrls.value.length;
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLastInCycle = currentVideoIndex.value >= count - 1;
|
||||
if (!isLastInCycle) {
|
||||
advanceToVideo(currentVideoIndex.value + 1);
|
||||
await playCurrentVideo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRefreshingVideoList) {
|
||||
advanceToVideo(0);
|
||||
await playCurrentVideo();
|
||||
return;
|
||||
}
|
||||
|
||||
isRefreshingVideoList = true;
|
||||
try {
|
||||
const urls = await loadNoticeVideoListOnOpen(true);
|
||||
videoUrls.value = urls;
|
||||
if (urls.length === 0) {
|
||||
currentVideoIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
advanceToVideo(0);
|
||||
await playCurrentVideo();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await log("warn", `videolist 轮播结束后刷新失败: ${message}`);
|
||||
advanceToVideo(0);
|
||||
await playCurrentVideo();
|
||||
} finally {
|
||||
isRefreshingVideoList = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadDisplayConfig(): Promise<void> {
|
||||
try {
|
||||
const config = await getAllConfig();
|
||||
displayConfig.value = noticeDisplayConfigFromAppConfig(config);
|
||||
} catch {
|
||||
displayConfig.value = { ...DEFAULT_NOTICE_DISPLAY_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function handleHeaderDoubleClick(): void {
|
||||
void router.push({ name: "config" });
|
||||
}
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
void reloadDisplayConfig();
|
||||
void ensureVideoList();
|
||||
void refreshTicketList();
|
||||
pollTimer = setInterval(() => {
|
||||
void refreshTicketList();
|
||||
}, TICKET_POLL_MS);
|
||||
window.addEventListener("notice-config-updated", reloadDisplayConfig);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
window.removeEventListener("notice-config-updated", reloadDisplayConfig);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="index-page" :style="pageStyle">
|
||||
<header
|
||||
class="hall-header"
|
||||
:style="headerStyle"
|
||||
title="双击进入配置"
|
||||
@dblclick="handleHeaderDoubleClick"
|
||||
>
|
||||
<img :src="badgeIcon" alt="" class="hall-badge" />
|
||||
<h1 class="hall-title">{{ displayConfig.headerTitle }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="index-body" :class="{ 'no-video': !displayConfig.showVideoArea }">
|
||||
<section class="call-panel" :style="infoPanelStyle">
|
||||
<ul class="call-items">
|
||||
<li
|
||||
v-for="(row, index) in displayedCallRows"
|
||||
:key="`${row.ticket}-${row.window}-${index}`"
|
||||
class="call-item"
|
||||
>
|
||||
<div class="call-item-content">
|
||||
<div class="call-tag-list">
|
||||
<span
|
||||
v-for="tag in row.tags"
|
||||
:key="tag"
|
||||
class="call-tag"
|
||||
:class="callTagClass(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="call-message-text">
|
||||
请<span class="call-half-space" aria-hidden="true" /><span class="call-highlight">{{
|
||||
row.ticket
|
||||
}}</span><span class="call-half-space" aria-hidden="true" />号到<span
|
||||
class="call-half-space"
|
||||
aria-hidden="true"
|
||||
/><span class="call-highlight">{{ row.window }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="right-panel">
|
||||
<aside v-if="displayConfig.showVideoArea" class="video-panel">
|
||||
<video
|
||||
v-if="currentVideoUrl"
|
||||
ref="promoVideoRef"
|
||||
:key="`${currentVideoIndex}-${videoPlaySeq}`"
|
||||
class="promo-video"
|
||||
:src="currentVideoUrl"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
@ended="handleVideoEnded"
|
||||
/>
|
||||
<div v-else class="video-placeholder">
|
||||
<p class="video-title">宣传视频</p>
|
||||
<p class="video-hint">暂无视频或加载中</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="info-panel" :style="infoPanelStyle">
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">等候人数</span>
|
||||
<strong class="stat-value">{{ queueStats.waiting }}<span class="stat-unit">人</span></strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">总人数</span>
|
||||
<strong class="stat-value">{{ queueStats.totalTickets }}<span class="stat-unit">人</span></strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">已办理</span>
|
||||
<strong class="stat-value">{{ queueStats.processed }}<span class="stat-unit">人</span></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-preview">
|
||||
<div class="queue-block">
|
||||
<h3 class="queue-title">请准备</h3>
|
||||
<div class="queue-tags">
|
||||
<span
|
||||
v-for="ticket in upcomingTickets"
|
||||
:key="ticket"
|
||||
class="queue-tag upcoming"
|
||||
>
|
||||
{{ ticket }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue-block">
|
||||
<h3 class="queue-title">已弃号</h3>
|
||||
<div class="queue-tags">
|
||||
<span
|
||||
v-for="ticket in abandonedTickets"
|
||||
:key="ticket"
|
||||
class="queue-tag abandoned"
|
||||
>
|
||||
{{ ticket }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.index-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hall-header {
|
||||
flex-shrink: 0;
|
||||
padding: clamp(10px, 1.6vh, 18px) clamp(16px, 2.5vw, 32px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(10px, 1.5vw, 16px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hall-badge {
|
||||
width: clamp(44px, 6.2vh, 68px);
|
||||
height: clamp(44px, 6.2vh, 68px);
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hall-title {
|
||||
font-size: clamp(30px, 4.8vh, 60px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.index-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: clamp(10px, 1.2vh, 16px);
|
||||
padding: clamp(10px, 1.2vh, 16px);
|
||||
}
|
||||
|
||||
.call-panel,
|
||||
.info-panel {
|
||||
border-radius: clamp(8px, 1vh, 12px);
|
||||
padding: clamp(12px, 1.6vh, 20px) clamp(14px, 1.8vw, 24px);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.call-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(10px, 1.2vh, 16px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.index-body.no-video .right-panel .info-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.call-items {
|
||||
list-style: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(8, minmax(0, 1fr));
|
||||
gap: clamp(4px, 0.7vh, 10px);
|
||||
}
|
||||
|
||||
.call-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(8px, 1.3vh, 16px) clamp(10px, 1.4vw, 18px);
|
||||
border-radius: clamp(6px, 0.8vh, 8px);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.call-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: clamp(8px, 1vw, 14px);
|
||||
}
|
||||
|
||||
.call-tag-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: clamp(3px, 0.5vh, 6px);
|
||||
flex-shrink: 0;
|
||||
width: clamp(52px, 5.8vw, 72px);
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.call-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(2px, 0.4vh, 4px) clamp(6px, 0.8vw, 10px);
|
||||
border-radius: clamp(4px, 0.5vh, 6px);
|
||||
font-size: clamp(12px, 1.6vh, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.call-tag--transfer {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.call-tag--vip {
|
||||
background: #fce7f3;
|
||||
color: #be185d;
|
||||
}
|
||||
|
||||
.call-tag--requeue {
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.call-message-text {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: clamp(26px, 4.2vh, 54px);
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.call-half-space {
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
}
|
||||
|
||||
.call-highlight {
|
||||
color: #dc2626;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
flex: 2;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(10px, 1.2vh, 16px);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: clamp(10px, 1.4vh, 16px);
|
||||
flex-shrink: 0;
|
||||
min-height: clamp(100px, 15vh, 168px);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: clamp(10px, 1.2vh, 14px);
|
||||
padding: clamp(16px, 2.4vh, 28px) clamp(14px, 1.8vw, 22px);
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: clamp(8px, 1.2vh, 14px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: clamp(18px, 2.6vh, 30px);
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: clamp(36px, 5.5vh, 72px);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 0.55em;
|
||||
font-weight: 600;
|
||||
margin-left: 0.08em;
|
||||
}
|
||||
|
||||
.queue-preview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: clamp(10px, 1.2vw, 16px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.queue-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-title {
|
||||
font-size: clamp(16px, 2vh, 22px);
|
||||
margin-bottom: clamp(6px, 0.8vh, 10px);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-tags {
|
||||
--queue-tag-height: clamp(32px, 4.6vh, 46px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: clamp(5px, 0.7vh, 8px);
|
||||
max-height: calc(var(--queue-tag-height) * 2 + clamp(5px, 0.7vh, 8px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--queue-tag-height);
|
||||
padding: 0 clamp(8px, 1vw, 14px);
|
||||
border-radius: 999px;
|
||||
font-size: clamp(15px, 2vh, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.queue-tag.upcoming {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.queue-tag.abandoned {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.video-panel {
|
||||
flex: 3;
|
||||
min-height: 0;
|
||||
border-radius: clamp(8px, 1vh, 12px);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.promo-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
gap: clamp(6px, 0.8vh, 8px);
|
||||
background: linear-gradient(145deg, #111827, #1f2937);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: clamp(18px, 2.4vh, 26px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-hint {
|
||||
font-size: clamp(12px, 1.5vh, 16px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-aspect-ratio: 4/3) {
|
||||
.index-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import AutoImport from "unplugin-auto-import/vite";
|
||||
import Components from "unplugin-vue-components/vite";
|
||||
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
const APP_NAME = "com.ziyun.noticeclient";
|
||||
const CONFIG_FILE_NAME = "config.json";
|
||||
const API_QUEUE_PATH = "/api/queue";
|
||||
const DEFAULT_API_PORT = 8845;
|
||||
|
||||
function getHomeDirectory(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || os.homedir() || ".";
|
||||
}
|
||||
|
||||
function getConfigFilePath(): string {
|
||||
if (process.platform === "linux") {
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||
const baseDirectory =
|
||||
xdgConfigHome && path.isAbsolute(xdgConfigHome)
|
||||
? xdgConfigHome
|
||||
: path.join(getHomeDirectory(), ".config");
|
||||
|
||||
return path.join(baseDirectory, APP_NAME, CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
if (process.env.APPDATA) {
|
||||
return path.join(process.env.APPDATA, APP_NAME, CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
return path.join(getHomeDirectory(), `.${APP_NAME}`, CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
function buildProxyTarget(serverIp: string): string {
|
||||
const raw = serverIp.trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
||||
return raw.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
const hostPort = raw.replace(/^\/+/, "");
|
||||
const hasPort = /:\d+$/.test(hostPort) || /^\[.+\]:\d+$/.test(hostPort);
|
||||
if (hasPort) {
|
||||
return `http://${hostPort}`;
|
||||
}
|
||||
|
||||
return `http://${hostPort}:${DEFAULT_API_PORT}`;
|
||||
}
|
||||
|
||||
function resolveProxyTarget(): string {
|
||||
const configFilePath = getConfigFilePath();
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configFilePath, "utf8");
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
const serverIp = typeof parsed.server_ip === "string" ? parsed.server_ip : "";
|
||||
return buildProxyTarget(serverIp);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const proxyTarget = resolveProxyTarget();
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ["vue", "vue-router"],
|
||||
dts: false,
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
dts: false,
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1422,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
proxy: proxyTarget
|
||||
? {
|
||||
[API_QUEUE_PATH]: {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1423,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||