新增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/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||