新增notci-client项目

master
cysamurai 4 weeks ago
parent a11b222bff
commit ddad2936a7

@ -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>

File diff suppressed because it is too large Load Diff

@ -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"
}
}

File diff suppressed because it is too large Load Diff

@ -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()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@ -0,0 +1,133 @@
use std::{
collections::BTreeMap,
env,
fs,
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
};
use serde_json::{Map, Value};
const APP_NAME: &str = "com.ziyun.noticeclient";
const CONFIG_FILE_NAME: &str = "config.json";
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_config_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 get_config_file_path() -> PathBuf {
get_config_dir().join(CONFIG_FILE_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 read_config() -> Result<Value, String> {
let path = get_config_file_path();
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let content = fs::read_to_string(&path).map_err(|error| format!("读取配置文件失败: {error}"))?;
if content.trim().is_empty() {
return Ok(Value::Object(Map::new()));
}
serde_json::from_str::<Value>(&content).map_err(|error| format!("解析配置文件失败: {error}"))
}
fn write_config(value: &Value) -> Result<(), String> {
let path = get_config_file_path();
ensure_parent_dir(&path)?;
let content = serde_json::to_string_pretty(value).map_err(|error| format!("序列化配置失败: {error}"))?;
fs::write(path, content).map_err(|error| format!("写入配置文件失败: {error}"))
}
static CONFIG_IO_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
fn lock_config_io() -> Result<std::sync::MutexGuard<'static, ()>, String> {
let mutex = CONFIG_IO_MUTEX.get_or_init(|| Mutex::new(()));
mutex
.lock()
.map_err(|_| "配置文件锁异常poisoned".to_string())
}
fn merge_value(target: &mut Value, patch: Value) {
match (target, patch) {
(Value::Object(target_map), Value::Object(patch_map)) => {
for (key, value) in patch_map {
if let Some(existing) = target_map.get_mut(&key) {
merge_value(existing, value);
} else {
target_map.insert(key, value);
}
}
}
(target_value, patch_value) => {
*target_value = patch_value;
}
}
}
#[tauri::command]
pub fn config_get_all() -> Result<BTreeMap<String, Value>, String> {
let _guard = lock_config_io()?;
let value = read_config()?;
let Value::Object(map) = value else {
return Ok(BTreeMap::new());
};
Ok(map.into_iter().collect())
}
pub fn merge_config_disk(partial: Value) -> Result<(), String> {
let _guard = lock_config_io()?;
let mut current = read_config()?;
merge_value(&mut current, partial);
write_config(&current)?;
Ok(())
}
#[tauri::command]
pub fn config_merge(partial: Value) -> Result<BTreeMap<String, Value>, String> {
let _guard = lock_config_io()?;
let mut current = read_config()?;
merge_value(&mut current, partial);
write_config(&current)?;
let Value::Object(map) = current else {
return Ok(BTreeMap::new());
};
Ok(map.into_iter().collect())
}

@ -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,830 @@
use serde::Serialize;
#[cfg(target_os = "linux")]
use std::process::Command;
use tauri::{AppHandle, Manager};
use serde_json::json;
use super::logger::app_log;
/// 与前端 `listen` 事件名一致,用于 apt 源自动配置进度(仅 Linux 构建使用)。
#[cfg(target_os = "linux")]
pub const ZYYUN_APT_SETUP_PROGRESS_EVENT: &str = "zyyun-apt-setup-progress";
#[cfg(target_os = "linux")]
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AptSetupProgressPayload {
pub percent: u8,
pub message: String,
}
#[cfg(target_os = "linux")]
fn emit_apt_setup_progress(app: &AppHandle, percent: u8, message: impl Into<String>) {
let msg = message.into();
let payload = AptSetupProgressPayload {
percent,
message: msg.clone(),
};
let _ = app.emit_all(ZYYUN_APT_SETUP_PROGRESS_EVENT, payload);
let _ = app_log(
"info".to_string(),
format!("[apt-setup] {percent}% {msg}"),
);
}
#[cfg(target_os = "linux")]
fn bash_single_quoted(content: &str) -> String {
format!("'{}'", content.replace('\'', "'\\''"))
}
/// 执行 `pkexec bash -c '<inner>'` 并记录 stdout/stderr`log_topic` 用于日志前缀(如 `[apt-setup]`)。
#[cfg(target_os = "linux")]
fn run_pkexec_bash_c(inner: &str, log_topic: &str, error_prefix_zh: &str) -> Result<(), String> {
let _ = app_log(
"info".to_string(),
format!(
"{log_topic} pkexec 内联 bash -c 脚本长度={} 字符",
inner.chars().count()
),
);
let output = Command::new("pkexec")
.args(["bash", "-c", inner])
.output()
.map_err(|e| {
let _ = app_log(
"error".to_string(),
format!("{log_topic} 无法启动 pkexec: {e}(请确认已安装 policykit-1 且当前为图形会话)"),
);
format!("无法启动 pkexec请确认已安装 policykit-1{e}")
})?;
let code = output.status.code();
let stdout_len = output.stdout.len();
let stderr_len = output.stderr.len();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.success() {
let _ = app_log(
"info".to_string(),
format!(
"{log_topic} pkexec/bash 已结束: success=true exitCode={code:?} stdout字节={stdout_len} stderr字节={stderr_len}"
),
);
if stdout.is_empty() && stderr.is_empty() {
let _ = app_log(
"info".to_string(),
format!("{log_topic} pkexec 未产生 stdout/stderr常见于图形授权器吞掉子进程输出"),
);
} else {
if !stdout.is_empty() {
log_preview(&format!("{log_topic} pkexec stdout"), &stdout, 8000);
}
if !stderr.is_empty() {
log_preview(&format!("{log_topic} pkexec stderr"), &stderr, 8000);
}
}
return Ok(());
}
let _ = app_log(
"error".to_string(),
format!(
"{log_topic} pkexec/bash 已结束: success=false exitCode={code:?} stdout字节={stdout_len} stderr字节={stderr_len}"
),
);
if !stdout.is_empty() {
log_preview(&format!("{log_topic} 失败时 pkexec stdout"), &stdout, 12000);
} else {
let _ = app_log(
"error".to_string(),
format!("{log_topic} 失败时 pkexec stdout 为空"),
);
}
if !stderr.is_empty() {
log_preview(&format!("{log_topic} 失败时 pkexec stderr"), &stderr, 12000);
} else {
let _ = app_log(
"error".to_string(),
format!("{log_topic} 失败时 pkexec stderr 为空"),
);
}
Err(format!(
"{error_prefix_zh}失败(退出码 {code:?}。stderr={} stdout={}",
if stderr.is_empty() { "(空)" } else { &stderr },
if stdout.is_empty() { "(空)" } else { &stdout }
))
}
#[cfg(target_os = "linux")]
fn log_preview(label: &str, text: &str, max_chars: usize) {
let body: String = text.chars().take(max_chars).collect();
let collapsed = body.replace('\r', "").replace('\n', " | ");
let suffix = if text.chars().count() > max_chars {
format!(" …(共 {} 字符,已截断)", text.chars().count())
} else {
String::new()
};
let _ = app_log(
"info".to_string(),
format!("{label}{suffix}: {collapsed}"),
);
}
#[cfg(target_os = "linux")]
fn run_privileged_apt_setup(deb_line: &str) -> Result<(), String> {
let trimmed = deb_line.trim();
let quoted = bash_single_quoted(trimmed);
let inner = format!(
"set -e; install -d /etc/apt/sources.list.d; printf '%s\\n' {quoted} > /etc/apt/sources.list.d/zyyun.list; apt-get update -y"
);
let _ = app_log(
"info".to_string(),
format!(
"[apt-setup] 特权脚本摘要: 使用 pkexec 启动 bash -c步骤为 install -d /etc/apt/sources.list.d → printf deb 行到 /etc/apt/sources.list.d/zyyun.list → apt-get update -ydeb 行长度={} 字符",
trimmed.chars().count()
),
);
log_preview("[apt-setup] deb 行预览(前 220 字符)", trimmed, 220);
let _ = app_log(
"info".to_string(),
"[apt-setup] deb 行已按单引号规则嵌入 bash -c 脚本(长度见下一条 pkexec 日志)".to_string(),
);
run_pkexec_bash_c(&inner, "[apt-setup]", "配置更新源")
}
/// 受控升级:仅允许固定包名 `notice-client`,不接受前端拼接 shell。
#[cfg(target_os = "linux")]
fn run_privileged_upgrade_notice_client() -> Result<(), String> {
const INNER: &str = "set -e; apt-get update -y && apt-get install -y --only-upgrade notice-client";
let _ = app_log(
"info".to_string(),
"[apt-upgrade] 受控特权脚本摘要: pkexec bash -c → apt-get update -y → apt-get install -y --only-upgrade notice-client包名写死在 Rust无用户输入".to_string(),
);
run_pkexec_bash_c(INNER, "[apt-upgrade]", "应用内升级apt")
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AptUpdateCheckResult {
package_name: String,
current_version: String,
installed_version: String,
candidate_version: String,
has_update: bool,
source_available: bool,
update_command: String,
}
#[cfg(target_os = "linux")]
const ZYYUN_APT_LIST_FILE: &str = "/etc/apt/sources.list.d/zyyun.list";
#[cfg(target_os = "linux")]
const DEFAULT_ZYYUN_REPO_BASE: &str = "http://80.12.140.29:80/apt";
#[cfg(target_os = "linux")]
const DEFAULT_ZYYUN_SUITE: &str = "v10";
#[cfg(target_os = "linux")]
const DEFAULT_ZYYUN_COMPONENT: &str = "main";
/// 读取紫云 apt 源配置;缺失时使用与 deb postinst 一致的默认值。
#[cfg(target_os = "linux")]
fn read_zyyun_apt_repo_config() -> (String, String, String) {
let fallback = || {
(
DEFAULT_ZYYUN_REPO_BASE.to_string(),
DEFAULT_ZYYUN_SUITE.to_string(),
DEFAULT_ZYYUN_COMPONENT.to_string(),
)
};
let Ok(raw) = std::fs::read_to_string(ZYYUN_APT_LIST_FILE) else {
return fallback();
};
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((url, suite, component)) = parse_deb_source_line(line) {
return (url, suite, component);
}
}
fallback()
}
#[cfg(target_os = "linux")]
fn parse_deb_source_line(line: &str) -> Option<(String, String, String)> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.first().copied() != Some("deb") {
return None;
}
let mut idx = 1usize;
if idx < parts.len() && parts[idx].starts_with('[') {
idx += 1;
}
if idx + 2 >= parts.len() {
return None;
}
Some((
parts[idx].to_string(),
parts[idx + 1].to_string(),
parts[idx + 2].to_string(),
))
}
#[cfg(target_os = "linux")]
fn detect_dpkg_architecture() -> String {
std::process::Command::new("dpkg")
.args(["--print-architecture"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "amd64".to_string())
}
#[cfg(target_os = "linux")]
fn dpkg_version_gt(a: &str, b: &str) -> bool {
std::process::Command::new("dpkg")
.args(["--compare-versions", a, "gt", b])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn max_dpkg_version(versions: Vec<String>) -> Option<String> {
versions.into_iter().reduce(|acc, v| {
if dpkg_version_gt(&v, &acc) {
v
} else {
acc
}
})
}
#[cfg(target_os = "linux")]
fn parse_package_versions_from_packages(text: &str, package_name: &str) -> Vec<String> {
let mut versions = Vec::new();
for block in text.split("\n\n") {
let mut name: Option<&str> = None;
let mut ver: Option<&str> = None;
for line in block.lines() {
if let Some(rest) = line.strip_prefix("Package: ") {
name = Some(rest.trim());
} else if let Some(rest) = line.strip_prefix("Version: ") {
ver = Some(rest.trim());
}
}
if name == Some(package_name) {
if let Some(v) = ver {
if !v.is_empty() {
versions.push(v.to_string());
}
}
}
}
versions
}
/// 直接从仓库 HTTP 拉取 Packages.gz避免本机未 `apt update` 时候选版本滞后。
#[cfg(target_os = "linux")]
fn fetch_remote_highest_package_version(package_name: &str) -> Result<Option<String>, String> {
use flate2::read::GzDecoder;
use std::io::Read;
use std::time::Duration;
let (base_url, suite, component) = read_zyyun_apt_repo_config();
let arch = detect_dpkg_architecture();
let base = base_url.trim_end_matches('/');
let url = format!(
"{base}/dists/{suite}/{component}/binary-{arch}/Packages.gz"
);
let _ = app_log(
"info".to_string(),
format!(
"检查更新: 在线拉取 Packages.gz package={} arch={} url={}",
package_name, arch, url
),
);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
let response = client
.get(&url)
.send()
.map_err(|e| format!("请求 Packages.gz 失败: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Packages.gz HTTP {}: {}",
response.status().as_u16(),
url
));
}
let bytes = response
.bytes()
.map_err(|e| format!("读取 Packages.gz 响应失败: {e}"))?;
let mut decoder = GzDecoder::new(bytes.as_ref());
let mut text = String::new();
decoder
.read_to_string(&mut text)
.map_err(|e| format!("解压 Packages.gz 失败: {e}"))?;
let versions = parse_package_versions_from_packages(&text, package_name);
if versions.is_empty() {
let _ = app_log(
"warn".to_string(),
format!(
"检查更新: Packages.gz 中未找到 package={}arch={}",
package_name, arch
),
);
return Ok(None);
}
let highest = max_dpkg_version(versions);
let _ = app_log(
"info".to_string(),
format!(
"检查更新: 在线解析最高版本 package={} remoteCandidate={}",
package_name,
highest.as_deref().unwrap_or("(none)")
),
);
Ok(highest)
}
#[cfg(target_os = "linux")]
fn merge_candidate_with_remote(
package_name: &str,
policy_candidate: &str,
) -> (String, bool) {
let policy_ok =
policy_candidate != "(none)" && policy_candidate != "(unknown)" && !policy_candidate.is_empty();
match fetch_remote_highest_package_version(package_name) {
Ok(Some(remote)) => {
if !policy_ok {
let _ = app_log(
"info".to_string(),
format!(
"检查更新: apt 本地候选不可用,使用在线候选 {remote}(提示:可执行 apt update 同步本地索引)"
),
);
return (remote, true);
}
if dpkg_version_gt(&remote, policy_candidate) {
let _ = app_log(
"info".to_string(),
format!(
"检查更新: 在线候选 {remote} 高于 apt-cache 本地候选 {policy_candidate}(仓库已发布新版本但本机可能未 apt update"
),
);
return (remote, true);
}
(policy_candidate.to_string(), true)
}
Ok(None) => (
if policy_ok {
policy_candidate.to_string()
} else {
"(unknown)".to_string()
},
policy_ok,
),
Err(err) => {
let _ = app_log(
"warn".to_string(),
format!("检查更新: 在线 Packages.gz 拉取失败,仍使用 apt-cache 本地结果: {err}"),
);
(
if policy_ok {
policy_candidate.to_string()
} else {
"(unknown)".to_string()
},
policy_ok,
)
}
}
}
#[cfg(target_os = "linux")]
fn extract_policy_value(output: &str, key: &str) -> String {
let label = key.trim_end_matches(':').trim();
if label.is_empty() {
return "(unknown)".to_string();
}
let needle = format!("{label}:");
for raw in output.lines() {
let line = raw
.trim()
.trim_start_matches('\u{feff}');
if let Some(rest) = line.strip_prefix(&needle) {
let value = rest.trim();
if !value.is_empty() {
return value.to_string();
}
}
// 行内任意位置出现「Installed:」「Candidate:」(兼容非常规格式/BOM/缩进)
if let Some(idx) = line.find(&needle) {
let rest = line[idx + needle.len()..].trim();
if !rest.is_empty() {
return rest.to_string();
}
}
}
"(unknown)".to_string()
}
#[tauri::command]
pub fn check_apt_update(
_app: AppHandle,
package_name: String,
current_version: String,
) -> Result<AptUpdateCheckResult, String> {
check_apt_update_internal(package_name, current_version)
}
/// 供 `check_apt_update` 命令与启动后台线程复用。
pub(crate) fn check_apt_update_internal(
package_name: String,
current_version: String,
) -> Result<AptUpdateCheckResult, String> {
let _ = app_log(
"info".to_string(),
format!(
"检查更新开始: package={}, currentVersion={}",
package_name, current_version
),
);
#[cfg(not(target_os = "linux"))]
{
let _ = (&package_name, &current_version);
let _ = app_log(
"warn".to_string(),
format!(
"检查更新失败: package={}, reason=当前系统不支持 apt 更新检测,仅支持 Linux",
package_name
),
);
return Err("当前系统不支持 apt 更新检测,仅支持 Linux。".to_string());
}
#[cfg(target_os = "linux")]
{
let output = Command::new("apt-cache")
.env("LC_ALL", "C")
.env("LANGUAGE", "C")
.arg("policy")
.arg(&package_name)
.output()
.map_err(|error| {
let message = format!("执行 apt-cache 失败: {error}");
let _ = app_log(
"error".to_string(),
format!(
"检查更新失败: package={}, 无法启动子进程 apt-cache policy, err={}",
package_name, message
),
);
message
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if stderr.is_empty() {
"apt-cache policy 执行失败".to_string()
} else {
format!("apt-cache policy 执行失败: {stderr}")
};
let _ = app_log(
"error".to_string(),
format!(
"检查更新失败: package={}, exitCode={:?} stderr字节={} stdout字节={}",
package_name,
output.status.code(),
output.stderr.len(),
output.stdout.len()
),
);
if !stdout.is_empty() {
log_preview("检查更新: apt-cache 失败 stdout", &stdout, 4000);
}
if !stderr.is_empty() {
log_preview("检查更新: apt-cache 失败 stderr", &stderr, 4000);
}
let _ = app_log(
"error".to_string(),
format!("检查更新失败: package={}, {}", package_name, message),
);
return Err(message);
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_log(
"info".to_string(),
format!(
"检查更新: apt-cache policy 子进程结束 success=true package={} exitCode={:?} stdout字节={} stderr字节={} (LC_ALL=C 以解析英文 Installed/Candidate 标签)",
package_name,
output.status.code(),
stdout.len(),
stderr.len()
),
);
if !stderr.trim().is_empty() {
log_preview("检查更新: apt-cache policy stderr(非致命,仍可能解析 stdout)", &stderr, 2000);
}
let mut installed_version = extract_policy_value(&stdout, "Installed:");
let mut candidate_version = extract_policy_value(&stdout, "Candidate:");
if installed_version == "(unknown)" {
let dpkg_cmd = format!("dpkg-query -W -f=${{Version}} {}", package_name);
let _ = app_log(
"info".to_string(),
format!("检查更新: 尝试补全已安装版本: {dpkg_cmd}"),
);
if let Ok(dpkg_out) = Command::new("dpkg-query")
.args(["-W", "-f=${Version}", &package_name])
.output()
{
let dpkg_ok = dpkg_out.status.success();
let dpkg_raw = String::from_utf8_lossy(&dpkg_out.stdout).trim().to_string();
let dpkg_err = String::from_utf8_lossy(&dpkg_out.stderr).trim().to_string();
let _ = app_log(
"info".to_string(),
format!(
"检查更新: dpkg-query 结束 success={} stdout={} stderr_len={}",
dpkg_ok,
if dpkg_raw.is_empty() { "(空)".to_string() } else { dpkg_raw.clone() },
dpkg_err.len()
),
);
if !dpkg_err.is_empty() {
log_preview("检查更新: dpkg-query stderr", &dpkg_err, 1500);
}
if dpkg_ok {
let v = dpkg_raw;
if !v.is_empty() {
installed_version = v;
let _ = app_log(
"info".to_string(),
format!(
"检查更新: 已从 dpkg-query 补全 installedVersion={} (apt-cache 未解析出 Installed 行)",
installed_version
),
);
}
}
} else {
let _ = app_log(
"warn".to_string(),
"检查更新: 无法启动 dpkg-query 子进程(忽略,继续使用 apt 解析结果)".to_string(),
);
}
}
log_preview(
"检查更新: apt-cache policy stdout 全文预览(截断)",
&stdout,
4000,
);
if installed_version == "(unknown)" || candidate_version == "(unknown)" {
let preview: String = stdout.chars().take(1200).collect();
let sanitized = preview.replace('\r', "").replace('\n', " | ");
let _ = app_log(
"warn".to_string(),
format!(
"检查更新: 版本或候选源解析异常 package={}apt-cache policy 输出预览(截断): {}",
package_name, sanitized
),
);
}
let policy_candidate = candidate_version.clone();
let (merged_candidate, remote_source_ok) =
merge_candidate_with_remote(&package_name, &policy_candidate);
candidate_version = merged_candidate;
let baseline_version = if installed_version == "(none)" || installed_version == "(unknown)" {
current_version.clone()
} else {
installed_version.clone()
};
let policy_source_ok =
policy_candidate != "(none)" && policy_candidate != "(unknown)" && !policy_candidate.is_empty();
let source_available = policy_source_ok
|| remote_source_ok
|| (candidate_version != "(none)"
&& candidate_version != "(unknown)"
&& !candidate_version.trim().is_empty());
let has_update = source_available
&& candidate_version != baseline_version
&& !candidate_version.trim().is_empty();
let update_command = format!(
"sudo apt update && sudo apt install --only-upgrade {}",
package_name
);
let result = AptUpdateCheckResult {
package_name,
current_version,
installed_version,
candidate_version,
has_update,
source_available,
update_command,
};
let _ = app_log(
"info".to_string(),
format!(
"检查更新完成: package={}, currentVersion={}, installedVersion={}, candidateVersion={}, baselineVersion={}, hasUpdate={}, sourceAvailable={}, updateCommand={}",
result.package_name,
result.current_version,
result.installed_version,
result.candidate_version,
baseline_version,
result.has_update,
result.source_available,
result.update_command
),
);
Ok(result)
}
}
/// 与前端 `listen('pending-notice-client-update-synced')` 一致:后台 apt 检测已落盘 config 后广播。
pub const PENDING_NOTICE_CLIENT_UPDATE_SYNCED_EVENT: &str = "pending-notice-client-update-synced";
/// 将 `check_apt_update` 结果同步到 `pending_notice_client_update`(与前端约定一致)。
pub fn apply_pending_notice_client_update_config(r: &AptUpdateCheckResult) -> Result<(), String> {
if r.has_update && r.source_available {
super::config::merge_config_disk(json!({
"pending_notice_client_update": {
"candidateVersion": r.candidate_version
}
}))
} else if r.source_available {
super::config::merge_config_disk(json!({
"pending_notice_client_update": null
}))
} else {
Ok(())
}
}
/// 启动后后台执行 apt 检测并写 config不阻塞 UI完成后 `emit_all` 便于登录页刷新角标。
pub fn spawn_background_pending_notice_client_update_sync(app: AppHandle) {
std::thread::spawn(move || {
let version = app.package_info().version.to_string();
match check_apt_update_internal("notice-client".to_string(), version) {
Ok(r) => match apply_pending_notice_client_update_config(&r) {
Ok(()) => {
let _ = app_log(
"info".to_string(),
"[update-badge] 后台 apt 检查已完成并写入 config如需".to_string(),
);
let payload = json!({
"hasUpdate": r.has_update,
"sourceAvailable": r.source_available,
"candidateVersion": r.candidate_version,
});
let _ = app.emit_all(PENDING_NOTICE_CLIENT_UPDATE_SYNCED_EVENT, payload);
}
Err(e) => {
let _ = app_log(
"error".to_string(),
format!("[update-badge] 后台同步 pending 配置失败: {e}"),
);
}
},
Err(msg) => {
let _ = app_log(
"debug".to_string(),
format!("[update-badge] 后台 apt 检查未执行: {msg}"),
);
}
}
});
}
/// 首次配置:写入紫云 apt 源并执行 `apt-get update`(需图形环境 `pkexec` 授权)。
#[tauri::command]
pub fn setup_zyyun_apt_source(app: AppHandle, deb_line: String) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
let _ = (&app, deb_line);
return Err("当前系统不支持自动配置 apt 源。".to_string());
}
#[cfg(target_os = "linux")]
{
let line = deb_line.trim();
if line.is_empty() {
let _ = app_log(
"error".to_string(),
"[apt-setup] setup_zyyun_apt_source 中止: deb 源行为空".to_string(),
);
return Err("deb 源行不能为空".to_string());
}
let _ = app_log(
"info".to_string(),
format!(
"[apt-setup] setup_zyyun_apt_source 入口: deb 行长度={} 字符",
line.chars().count()
),
);
log_preview("[apt-setup] setup_zyyun_apt_source 入口 deb 行预览", line, 260);
let _ = app_log(
"info".to_string(),
"[apt-setup] 即将调用 run_privileged_apt_setuppkexec bash -c …)".to_string(),
);
emit_apt_setup_progress(&app, 5, "准备配置紫云 apt 软件源…");
emit_apt_setup_progress(
&app,
15,
"即将弹出权限确认,请在对话框中输入管理员密码…",
);
let result = run_privileged_apt_setup(line);
let _ = app_log(
"info".to_string(),
format!(
"[apt-setup] run_privileged_apt_setup 返回: success={}",
result.is_ok()
),
);
match &result {
Ok(()) => {
emit_apt_setup_progress(&app, 92, "正在完成索引刷新…");
emit_apt_setup_progress(&app, 100, "配置完成,可继续检查更新。");
let _ = app_log(
"info".to_string(),
"[apt-setup] 紫云 apt 源配置成功".to_string(),
);
}
Err(err) => {
emit_apt_setup_progress(&app, 100, format!("配置失败:{err}"));
let _ = app_log(
"error".to_string(),
format!("[apt-setup] 紫云 apt 源配置失败: {err}"),
);
}
}
result
}
}
/// 受控应用内升级:通过 `pkexec` 执行固定的 `apt-get update` + `apt-get install --only-upgrade notice-client`。
/// 包名写死在 Rust 中,不接受前端传入 shell以降低注入风险。
#[tauri::command]
pub fn upgrade_notice_client_via_apt(_app: AppHandle) -> Result<(), String> {
let _ = app_log(
"info".to_string(),
"[apt-upgrade] 命令入口 upgrade_notice_client_via_apt仅固定包 notice-client".to_string(),
);
#[cfg(not(target_os = "linux"))]
{
let _ = app_log(
"warn".to_string(),
"[apt-upgrade] 中止: 当前目标平台非 Linux".to_string(),
);
return Err("当前系统不支持通过 apt 在应用内升级。".to_string());
}
#[cfg(target_os = "linux")]
{
let result = run_privileged_upgrade_notice_client();
match &result {
Ok(()) => {
let _ = app_log(
"info".to_string(),
"[apt-upgrade] pkexec/apt 升级子流程已成功结束(若界面版本未刷新,请重启应用)".to_string(),
);
}
Err(err) => {
let _ = app_log(
"error".to_string(),
format!("[apt-upgrade] 升级失败: {err}"),
);
}
}
result
}
}

@ -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;
}

Binary file not shown.

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,184 @@
import { ask, message } from "@tauri-apps/api/dialog";
import { invoke } from "@tauri-apps/api/tauri";
import { appWindow, WebviewWindow } from "@tauri-apps/api/window";
import { open } from "@tauri-apps/api/shell";
import type {
AppLogPaths,
NativeConfirmOptions,
ShowErrorNativeOptions,
} from "./types";
async function suspendAlwaysOnTopForNativeDialog(): Promise<() => Promise<void>> {
const labelsToRestore: string[] = [];
async function trySuspendWindow(win: WebviewWindow): Promise<void> {
try {
const visible = await win.isVisible();
if (!visible) {
return;
}
await win.setAlwaysOnTop(false);
labelsToRestore.push(win.label);
} catch {
// ignore
}
}
try {
const labels = await invoke<string[]>("list_windows");
await Promise.all(
labels.map(async (label) => {
const win = WebviewWindow.getByLabel(label);
if (win) {
await trySuspendWindow(win);
}
}),
);
} catch {
await trySuspendWindow(appWindow);
}
if (labelsToRestore.length === 0) {
await trySuspendWindow(appWindow);
}
return async () => {
for (const label of labelsToRestore) {
try {
const win = WebviewWindow.getByLabel(label);
if (win) {
await win.setAlwaysOnTop(true);
}
} catch {
// ignore
}
}
};
}
async function runWithNativeDialogAccessibility<T>(
fn: () => Promise<T>,
): Promise<T> {
const restore = await suspendAlwaysOnTopForNativeDialog();
try {
return await fn();
} finally {
await restore();
}
}
export async function confirmNative(options: NativeConfirmOptions): Promise<boolean> {
try {
return await runWithNativeDialogAccessibility(() =>
ask(options.message, {
title: options.title,
okLabel: options.okLabel,
cancelLabel: options.cancelLabel,
}),
);
} catch (error) {
throw new Error(`打开确认框失败: ${String(error)}`);
}
}
async function fetchLogPaths(): Promise<AppLogPaths> {
return await invoke<AppLogPaths>("get_log_paths");
}
const ERROR_DIALOG_MAX_LINES = 6;
function truncateErrorTextForDialog(text: string): string {
const normalized = text.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (lines.length <= ERROR_DIALOG_MAX_LINES) {
return normalized;
}
return `${lines.slice(0, ERROR_DIALOG_MAX_LINES - 1).join("\n")}\n...`;
}
export async function showErrorNative(
content: string,
title = "错误",
options?: ShowErrorNativeOptions,
): Promise<void> {
const body = truncateErrorTextForDialog(content);
const logActions = options?.logActions ?? "none";
if (logActions === "none") {
try {
await runWithNativeDialogAccessibility(() =>
message(body, { title, type: "error" }),
);
} catch (error) {
throw new Error(`打开错误提示框失败: ${String(error)}`);
}
return;
}
let paths: AppLogPaths;
try {
paths = await fetchLogPaths();
} catch {
try {
await runWithNativeDialogAccessibility(() =>
message(body, { title, type: "error" }),
);
} catch (error) {
throw new Error(`打开错误提示框失败: ${String(error)}`);
}
return;
}
try {
const openFile = await runWithNativeDialogAccessibility(() =>
ask(body, {
title,
type: "error",
okLabel: "打开日志文件",
cancelLabel: "确定",
}),
);
if (openFile) {
await open(paths.logFile);
}
} catch (error) {
try {
const fallback = truncateErrorTextForDialog(
`${content}\n无法打开日志文件${String(error)}`,
);
await runWithNativeDialogAccessibility(() =>
message(fallback, {
title,
type: "error",
}),
);
} catch (inner) {
throw new Error(`打开错误提示框失败: ${String(inner)}`);
}
}
}
export async function showInfoNative(
content: string,
title = "提示",
): Promise<void> {
try {
await runWithNativeDialogAccessibility(() =>
message(content, { title, type: "info" }),
);
} catch (error) {
throw new Error(`打开提示框失败: ${String(error)}`);
}
}
export async function showWarningNative(
content: string,
title = "提示",
): Promise<void> {
try {
await runWithNativeDialogAccessibility(() =>
message(content, { title, type: "warning" }),
);
} 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,351 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { invoke } from "@tauri-apps/api/tauri";
import { getVersion } from "@tauri-apps/api/app";
import { getAllConfig, mergeConfig } from "../host/config";
import {
confirmNative,
showErrorNative,
showInfoNative,
showWarningNative,
} from "../host/dialog";
import { log } from "../host/logger";
import { quitApplication } from "../host/window";
import type { AptUpdateCheckResult, NoticeDisplayConfig } from "../host/types";
import {
DEFAULT_NOTICE_DISPLAY_CONFIG,
noticeDisplayConfigFromAppConfig,
noticeDisplayConfigToPartial,
} from "../utils/noticeConfig";
const router = useRouter();
const saving = ref(false);
const checkingUpdate = ref(false);
const appVersion = ref("0.1.0");
const hasPendingUpdate = ref(false);
const pendingVersion = ref("");
const form = ref<NoticeDisplayConfig>({ ...DEFAULT_NOTICE_DISPLAY_CONFIG });
async function loadConfig(): Promise<void> {
const config = await getAllConfig();
form.value = noticeDisplayConfigFromAppConfig(config);
const pending = config.pending_notice_client_update;
if (
pending &&
typeof pending === "object" &&
!Array.isArray(pending) &&
typeof (pending as { candidateVersion?: unknown }).candidateVersion === "string"
) {
hasPendingUpdate.value = true;
pendingVersion.value = (pending as { candidateVersion: string }).candidateVersion;
} else {
hasPendingUpdate.value = false;
pendingVersion.value = "";
}
}
async function handleSave(): Promise<void> {
saving.value = true;
try {
await mergeConfig(noticeDisplayConfigToPartial(form.value));
window.dispatchEvent(new CustomEvent("notice-config-updated"));
ElMessage.success("配置已保存");
await log("info", "notice-client 展示配置已保存");
await router.push({ name: "index" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message || "保存失败", "保存配置");
} finally {
saving.value = false;
}
}
async function handleResetDefaults(): Promise<void> {
const ok = await confirmNative({
title: "恢复默认",
message: "确定将展示样式恢复为默认值吗?",
okLabel: "确定",
cancelLabel: "取消",
});
if (!ok) {
return;
}
form.value = { ...DEFAULT_NOTICE_DISPLAY_CONFIG };
}
async function handleOpenServerSetup(): Promise<void> {
await router.push({ name: "main-setup" });
}
async function handleBackToIndex(): Promise<void> {
await router.push({ name: "index" });
}
async function handleQuitApplication(): Promise<void> {
const ok = await confirmNative({
title: "退出程序",
message: "确定要退出信息发布屏程序吗?",
okLabel: "退出",
cancelLabel: "取消",
});
if (!ok) {
return;
}
await log("info", "用户从配置页退出程序");
await quitApplication();
}
async function syncPendingFromResult(result: AptUpdateCheckResult): Promise<void> {
if (result.hasUpdate && result.candidateVersion) {
await mergeConfig({
pending_notice_client_update: { candidateVersion: result.candidateVersion },
});
hasPendingUpdate.value = true;
pendingVersion.value = result.candidateVersion;
return;
}
await mergeConfig({ pending_notice_client_update: null });
hasPendingUpdate.value = false;
pendingVersion.value = "";
}
async function handleCheckUpdate(): Promise<void> {
if (checkingUpdate.value) {
return;
}
checkingUpdate.value = true;
try {
const result = await invoke<AptUpdateCheckResult>("check_apt_update", {
packageName: "notice-client",
currentVersion: appVersion.value,
});
await syncPendingFromResult(result);
if (!result.sourceAvailable) {
const cfg = await getAllConfig();
if (cfg.zyyun_apt_source_configured !== true) {
const doConfigure = await confirmNative({
title: "配置更新源",
message:
"未检测到可用更新源。是否使用管理员权限由本程序自动写入紫云软件源?",
okLabel: "开始配置",
cancelLabel: "取消",
});
if (doConfigure) {
await invoke("setup_zyyun_apt_source", { debLine: null });
await mergeConfig({ zyyun_apt_source_configured: true });
const retry = await invoke<AptUpdateCheckResult>("check_apt_update", {
packageName: "notice-client",
currentVersion: appVersion.value,
});
await syncPendingFromResult(retry);
if (retry.hasUpdate) {
await showInfoNative(`发现新版本 ${retry.candidateVersion}`, "检查更新");
return;
}
}
} else {
await showWarningNative("无法解析候选版本,请联系管理员。", "检查更新");
}
return;
}
if (result.hasUpdate) {
await showInfoNative(`发现新版本 ${result.candidateVersion}`, "检查更新");
return;
}
await showInfoNative("当前已是最新版本", "检查更新");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message, "检查更新");
} finally {
checkingUpdate.value = false;
}
}
async function handleUpgrade(): Promise<void> {
const ok = await confirmNative({
title: "应用内升级",
message: `确定升级到 ${pendingVersion.value || "新版本"} 吗?将弹出管理员授权。`,
okLabel: "升级",
cancelLabel: "取消",
});
if (!ok) {
return;
}
checkingUpdate.value = true;
try {
await invoke("upgrade_notice_client_via_apt");
await mergeConfig({ pending_notice_client_update: null });
hasPendingUpdate.value = false;
pendingVersion.value = "";
await showInfoNative("升级命令已执行,若版本未变化请重启应用。", "升级");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message, "升级失败", { logActions: "file" });
} finally {
checkingUpdate.value = false;
}
}
onMounted(async () => {
try {
appVersion.value = await getVersion();
} catch {
appVersion.value = "0.1.0";
}
await loadConfig();
});
</script>
<template>
<div class="config-page">
<header class="config-header">
<h1>展示配置</h1>
<p class="config-subtitle">修改后将立即作用于展示页</p>
</header>
<el-form label-width="140px" class="config-form">
<el-form-item label="主页面背景色">
<el-color-picker v-model="form.pageBgColor" show-alpha />
<el-input v-model="form.pageBgColor" class="color-input" />
</el-form-item>
<el-form-item label="头部背景色">
<el-color-picker v-model="form.headerBgColor" show-alpha />
<el-input v-model="form.headerBgColor" class="color-input" />
</el-form-item>
<el-form-item label="头部文字颜色">
<el-color-picker v-model="form.headerTextColor" show-alpha />
<el-input v-model="form.headerTextColor" class="color-input" />
</el-form-item>
<el-form-item label="头部标题">
<el-input v-model="form.headerTitle" maxlength="40" show-word-limit />
</el-form-item>
<el-form-item label="显示视频区域">
<el-switch v-model="form.showVideoArea" />
</el-form-item>
<el-form-item label="信息区背景色">
<el-color-picker v-model="form.infoAreaBgColor" show-alpha />
<el-input v-model="form.infoAreaBgColor" class="color-input" />
</el-form-item>
<el-form-item label="信息区文字颜色">
<el-color-picker v-model="form.infoAreaTextColor" show-alpha />
<el-input v-model="form.infoAreaTextColor" class="color-input" />
</el-form-item>
</el-form>
<section class="update-section">
<h2>应用更新</h2>
<p class="version-line">当前版本{{ appVersion }}</p>
<p v-if="hasPendingUpdate" class="pending-line">{{ pendingVersion }}</p>
<div class="action-row">
<el-button :loading="checkingUpdate" @click="handleCheckUpdate"></el-button>
<el-button
v-if="hasPendingUpdate"
type="primary"
:loading="checkingUpdate"
@click="handleUpgrade"
>
应用内升级
</el-button>
</div>
</section>
<div class="footer-actions">
<el-button @click="handleBackToIndex"></el-button>
<el-button @click="handleOpenServerSetup"></el-button>
<el-button @click="handleResetDefaults"></el-button>
<el-button type="primary" :loading="saving" @click="handleSave"></el-button>
<el-button type="danger" plain @click="handleQuitApplication">退</el-button>
</div>
</div>
</template>
<style scoped lang="scss">
.config-page {
height: 100%;
overflow: auto;
padding: 20px 24px 24px;
background: #f8fafc;
}
.config-header {
margin-bottom: 20px;
h1 {
font-size: 22px;
margin-bottom: 4px;
}
}
.config-subtitle {
color: #64748b;
font-size: 14px;
}
.config-form {
max-width: 640px;
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.color-input {
width: 180px;
margin-left: 12px;
}
.update-section {
margin-top: 20px;
max-width: 640px;
background: #fff;
padding: 16px 20px;
border-radius: 12px;
h2 {
font-size: 16px;
margin-bottom: 8px;
}
}
.version-line,
.pending-line {
font-size: 14px;
color: #475569;
margin-bottom: 6px;
}
.pending-line {
color: #2563eb;
font-weight: 600;
}
.action-row {
margin-top: 10px;
display: flex;
gap: 10px;
}
.footer-actions {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
</style>

@ -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,313 @@
<script setup lang="ts">
import { Close, Minus } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { appWindow } from "@tauri-apps/api/window";
import { loadNoticeVideoListOnOpen } from "../api/notice";
import { mergeConfig } from "../host/config";
import { showErrorNative } from "../host/dialog";
import { log } from "../host/logger";
import { closeWindow, minimizeWindow, openMainWindow } from "../host/window";
import { applyServerIpToHttp } from "../utils/service";
const router = useRouter();
const route = useRoute();
const serverIp = ref("");
const saving = ref(false);
const isPopupWindow = ref(false);
const isValid = computed(() => serverIp.value.trim().length > 0);
const isEmbeddedInMain = computed(() => route.name === "main-setup");
function showMessage(
type: "success" | "warning" | "error",
message: string,
): void {
ElMessage({
type,
message,
offset: 52,
grouping: true,
customClass: "narrow-window-message",
});
}
function validateHostInput(raw: string): boolean {
const value = raw.trim();
if (!value) {
return false;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
try {
const url = new URL(value);
return Boolean(url);
} catch {
return false;
}
}
return /^[\w.\-]+(?::\d+)?$/.test(value) || /^\d{1,3}(\.\d{1,3}){3}(?::\d+)?$/.test(value);
}
async function handleSave(): Promise<void> {
const value = serverIp.value.trim();
if (!value) {
showMessage("warning", "请输入服务器 IP 或地址");
return;
}
if (!validateHostInput(value)) {
showMessage("warning", "地址格式不正确例如192.168.1.10 或 192.168.1.10:8845");
return;
}
saving.value = true;
try {
await mergeConfig({ server_ip: value });
applyServerIpToHttp(value);
void loadNoticeVideoListOnOpen(true);
await log("info", `已保存服务器地址: ${value}`);
showMessage("success", "保存成功");
if (isPopupWindow.value) {
await openMainWindow();
return;
}
await router.replace({ name: "index" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message || "保存失败", "保存服务器地址");
await log("error", `保存服务器地址失败: ${message}`);
} finally {
saving.value = false;
}
}
function handleMinimizeClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
void minimizeWindow();
}
function handleCloseClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
void closeWindow();
}
onMounted(async () => {
try {
isPopupWindow.value = (await appWindow.label) === "setup";
} catch {
isPopupWindow.value = route.path === "/setup";
}
});
</script>
<template>
<div class="setup-container" :class="{ embedded: isEmbeddedInMain }">
<div class="background-elements">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
</div>
<div v-if="isPopupWindow" class="setup-header drag-region">
<button class="control-button" type="button" @click="handleMinimizeClick">
<el-icon class="control-icon">
<component :is="Minus" />
</el-icon>
</button>
<button class="control-button" type="button" @click="handleCloseClick">
<el-icon class="control-icon">
<component :is="Close" />
</el-icon>
</button>
</div>
<div class="setup-main">
<div class="header-section" :class="{ 'drag-region': isPopupWindow }">
<div class="app-info">
<h1 class="app-title">服务地址</h1>
<h2 class="app-subtitle">请先配置服务器 IP 或地址</h2>
</div>
</div>
<div class="form-section">
<div class="form-wrapper">
<div class="form-header">
<p class="form-subtitle">默认端口 8845支持 host:port 或完整 http(s) 地址</p>
</div>
<el-input
v-model="serverIp"
size="large"
clearable
placeholder="例如 192.168.1.10"
@keyup.enter="handleSave"
/>
<div class="form-actions">
<el-button
type="primary"
size="large"
class="save-button"
:loading="saving"
:disabled="!isValid"
@click="handleSave"
>
保存并进入主界面
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.setup-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%);
overflow: hidden;
position: relative;
padding: 0 16px 20px;
&.embedded {
width: 100%;
height: 100%;
padding: 24px 16px;
}
}
.drag-region {
-webkit-app-region: drag;
}
.background-elements {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.circle-1 {
width: 300px;
height: 300px;
top: -100px;
left: -100px;
}
&.circle-2 {
width: 200px;
height: 200px;
right: -80px;
bottom: -80px;
}
}
}
.setup-header {
width: 100%;
height: 32px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
}
.control-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: white;
border-radius: 4px;
-webkit-app-region: no-drag;
border: none;
background: transparent;
}
.control-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.control-icon {
font-size: 20px;
}
.setup-main {
width: 100%;
max-width: 420px;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 16px;
}
.header-section {
text-align: center;
margin-bottom: 30px;
}
.app-title {
font-size: 28px;
font-weight: 700;
color: white;
margin-bottom: 8px;
}
.app-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.form-wrapper {
background: rgba(255, 255, 255, 0.95);
border-radius: 5px;
padding: 28px 30px 26px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.form-header {
text-align: center;
margin-bottom: 25px;
}
.form-subtitle {
color: #666;
font-size: 13px;
}
.form-actions {
margin-top: 24px;
}
.save-button {
width: 100%;
}
:global(.narrow-window-message) {
min-width: 0 !important;
width: calc(100vw - 32px) !important;
left: 16px !important;
right: 16px !important;
}
</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/**"],
},
},
}));

@ -205,6 +205,7 @@ ensure_tauri_icons() {
case "$project_dir" in
call-client) custom_icon="$icons_dir/call_icon.png" ;;
broadcast-client) custom_icon="$icons_dir/bc_icon.png" ;;
notice-client) custom_icon="$icons_dir/zh_icon.png" ;;
esac
if [[ -n "$custom_icon" && -f "$custom_icon" ]]; then

@ -46,3 +46,56 @@
{ "uid": 1, "name": "综合办税", "wins": [{ "uid": 1, "name": "综合办税窗口1" }, { "uid": 2, "name": "综合办税窗口2" }] },
{ "uid": 2, "name": "发票办理", "wins": [{ "uid": 3, "name": "发票办理窗口3" }] }
]界面上需要在业务名称后面展示对应可办理的窗口名称即wins中的数据综合办税(综合办税窗口1,综合办税窗口2)
### 5.新增一个项目notice-client客户端基本的保存配置、日志、更新以及打包方式都与call-client和broadcast-client一致先搭建一个空白页面的应用如果有必要可使用第三方框架。
- notice-client除首次启动时的服务器IP配置窗口外包含一个主窗口主窗口包含index以及config两个页面进行切换。
- notice-client的index页面包含显示大厅名称的头部。剩下的区域要显示以下内容排队取号系统的叫号信息如"请A0001号到1号窗口"的信息列表展示区域,显示排队取号系统等候人数、总票号量、已办理量以及即将被呼叫和被弃号的简易队列信息,播放宣传视频区域
- notice-client的config页面包含以下配置项主页面整体背景颜色头部背景颜色头部文字颜色头部标题文字、是否显示视频播放区域、信息展示区域背景颜色、信息展示区域文字颜色。
### 6.修改notice-client
- 在没有配置服务器地址得情况下启动会先弹出一个配置服务器地址得窗口配置完成后跳转到主窗口但是主窗口仍就停留在配置服务器地址页面保留在主窗口中填写配置后跳转到index页面。
- 主窗口不使用tabbar切换页面使用双击头部跳转到config页面。
- 叫号信息单独使用左边的区域,剩下的信息区域使用右边的区域,视频在上面信息在下面。
- 顶部大厅名称靠左显示并且加入图标显示badge_transparent.png
### 7.修改notice-client
- 去除叫号信息显示区域的叫号信息四个字最大可以显示8行的叫号信息
- UI自适应
- 全屏无边框
- config页面增加一个退出程序按钮
- 右侧视频区域增大和下方信息展示区域高的占比为32即将呼叫和已弃号区域的队列固定显示两行增大字体
### 8.修改notice-client
- 增大等候人数、总票号量、已办理量的区域大小和文字大小
- 增大叫号信息区域的文字大小
### 9.修改notice-client
- 叫号信息显示区域加个票号标签显示在每一行的开头标签包含转移、VIP、回签
- "请"、"号到"几个字和票号、窗口之前添加半个字的间距
### 10.notice-client网络请求需求
- baseurl与call-client一致配置的ip:8845/api/queue/
- GET /api/queue/notice/videolist 每次客户端打开时调用
→ { "code": 200, "data": ["http://.../a.mp4", "http://.../b.mp4"] }
- GET /api/queue/notice/ticketlist 客户端在index页面时每2秒调用一次
→ { "code": 200, "data": {
"total": 总票号量,
"waitingNum": 等候票号,
"completedNum": 已办理票号,
"dealingNum": 正在办理票号,
"callinglist": 查询ticket表状态为3的记录数返回数据格式为数组[{ticketId叫号信息显示区域的票号, winName:叫号信息显示区域的窗口名称, isTransfer:是否显示转移标签}],
"abandonedlist": 已弃号数组,
"waitinglist": 即将呼叫数组,
} }
### 11.notice-client修改
- 增大顶部大厅吗名称的文本字体
- 当视频列表轮播完之后先调用一次获取视频列表接口,更新一下视频列表,然后继续轮播

Loading…
Cancel
Save