11
This commit is contained in:
74
src-tauri/Cargo.lock
generated
74
src-tauri/Cargo.lock
generated
@@ -8,6 +8,18 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -1078,6 +1090,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -1560,6 +1584,15 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -1575,6 +1608,15 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2134,6 +2176,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -3238,6 +3291,20 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -3931,6 +3998,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rquickjs",
|
"rquickjs",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
@@ -4714,6 +4782,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ log = "0.4"
|
|||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
||||||
|
# Local SQLite config
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
//! 全局应用配置:登录后从 `GET /api/v1/appConfig` 拉取 `desktop_configs` 键值表,
|
//! 应用配置:服务端 `desktop_configs` + SQLite 本地配置,同名键**本地优先**。
|
||||||
//! 原样缓存;启动 Node 时写入环境变量 `AICLIENT_CFG_<规范化键名>`。
|
//! 合并结果供 `get_app_config` 与 Node 环境变量 `AICLIENT_CFG_*`。
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::local_config::LocalConfigDb;
|
||||||
|
|
||||||
/// Node 子进程环境变量前缀,与 `scripts/nodejs/desktop_config.js` 一致。
|
/// Node 子进程环境变量前缀,与 `scripts/nodejs/desktop_config.js` 一致。
|
||||||
pub const NODE_ENV_PREFIX: &str = "AICLIENT_CFG_";
|
pub const NODE_ENV_PREFIX: &str = "AICLIENT_CFG_";
|
||||||
|
|
||||||
@@ -19,8 +22,10 @@ pub enum AppConfigError {
|
|||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct Inner {
|
struct Inner {
|
||||||
entries: HashMap<String, String>,
|
server_entries: HashMap<String, String>,
|
||||||
|
local_entries: HashMap<String, String>,
|
||||||
last_message: String,
|
last_message: String,
|
||||||
|
local_db: Option<Arc<LocalConfigDb>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -59,6 +64,14 @@ pub fn node_env_var_name(db_name: &str) -> String {
|
|||||||
format!("{}{}", NODE_ENV_PREFIX, normalize_config_key(db_name))
|
format!("{}{}", NODE_ENV_PREFIX, normalize_config_key(db_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merged_entries(server: &HashMap<String, String>, local: &HashMap<String, String>) -> HashMap<String, String> {
|
||||||
|
let mut map = server.clone();
|
||||||
|
for (k, v) in local {
|
||||||
|
map.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -66,28 +79,128 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 应用启动时初始化 SQLite 并加载本地配置。
|
||||||
|
pub async fn init_local_store(&self, db_path: PathBuf) -> Result<(), String> {
|
||||||
|
let entries = tokio::task::spawn_blocking(move || -> Result<(Arc<LocalConfigDb>, HashMap<String, String>), String> {
|
||||||
|
let db = LocalConfigDb::open(db_path).map_err(|e| e.to_string())?;
|
||||||
|
let map = db.list_all_map().map_err(|e| e.to_string())?;
|
||||||
|
Ok((Arc::new(db), map))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.map_err(|e| e)?;
|
||||||
|
|
||||||
|
let (db, map) = entries;
|
||||||
|
let mut g = self.inner.write().await;
|
||||||
|
g.local_db = Some(db);
|
||||||
|
g.local_entries = map;
|
||||||
|
log::info!(
|
||||||
|
target: "app_config",
|
||||||
|
"本地配置已加载,共 {} 项",
|
||||||
|
g.local_entries.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
|
||||||
|
where
|
||||||
|
F: FnOnce(Arc<LocalConfigDb>) -> Result<T, String> + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
let db = self
|
||||||
|
.inner
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.local_db
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "本地配置数据库未初始化".to_string())?;
|
||||||
|
tokio::task::spawn_blocking(move || f(db))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并后的有效配置(本地覆盖服务端同名键)。
|
||||||
pub async fn entries(&self) -> HashMap<String, String> {
|
pub async fn entries(&self) -> HashMap<String, String> {
|
||||||
self.inner.read().await.entries.clone()
|
let g = self.inner.read().await;
|
||||||
|
merged_entries(&g.server_entries, &g.local_entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_entries(&self) -> HashMap<String, String> {
|
||||||
|
self.inner.read().await.server_entries.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn local_entries(&self) -> HashMap<String, String> {
|
||||||
|
self.inner.read().await.local_entries.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get(&self, name: &str) -> Option<String> {
|
pub async fn get(&self, name: &str) -> Option<String> {
|
||||||
self.inner.read().await.entries.get(name).cloned()
|
let g = self.inner.read().await;
|
||||||
|
if let Some(v) = g.local_entries.get(name) {
|
||||||
|
return Some(v.clone());
|
||||||
|
}
|
||||||
|
g.server_entries.get(name).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn last_message(&self) -> String {
|
pub async fn last_message(&self) -> String {
|
||||||
self.inner.read().await.last_message.clone()
|
self.inner.read().await.last_message.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear(&self) {
|
pub async fn clear_server(&self) {
|
||||||
let mut g = self.inner.write().await;
|
let mut g = self.inner.write().await;
|
||||||
g.entries.clear();
|
g.server_entries.clear();
|
||||||
g.last_message.clear();
|
g.last_message.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 供 Node 子进程 `Command::env` 使用。
|
pub async fn reload_local_from_db(&self) -> Result<(), String> {
|
||||||
|
let map = self
|
||||||
|
.with_db(|db| db.list_all_map().map_err(|e| e.to_string()))
|
||||||
|
.await?;
|
||||||
|
self.inner.write().await.local_entries = map;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_local(&self, name: String, value: String, mark: Option<String>) -> Result<(), String> {
|
||||||
|
let name_trim = name.trim().to_string();
|
||||||
|
if name_trim.is_empty() {
|
||||||
|
return Err("配置键名不能为空".into());
|
||||||
|
}
|
||||||
|
let value_owned = value;
|
||||||
|
let mark_owned = mark.clone();
|
||||||
|
self.with_db({
|
||||||
|
let name = name_trim.clone();
|
||||||
|
let value = value_owned.clone();
|
||||||
|
move |db| db.upsert(&name, &value, mark_owned.as_deref()).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let mut g = self.inner.write().await;
|
||||||
|
g.local_entries.insert(name_trim, value_owned);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_local(&self, name: &str) -> Result<(), String> {
|
||||||
|
let key = name.trim().to_string();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("配置键名不能为空".into());
|
||||||
|
}
|
||||||
|
self.with_db({
|
||||||
|
let key = key.clone();
|
||||||
|
move |db| db.delete(&key).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
self.inner.write().await.local_entries.remove(name.trim());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_local_items(&self) -> Result<Vec<crate::local_config::LocalConfigItem>, String> {
|
||||||
|
self.with_db(|db| db.list_items().map_err(|e| e.to_string()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 供 Node 子进程 `Command::env` 使用(已合并,本地优先)。
|
||||||
pub async fn env_for_node(&self) -> HashMap<String, String> {
|
pub async fn env_for_node(&self) -> HashMap<String, String> {
|
||||||
let g = self.inner.read().await;
|
let g = self.inner.read().await;
|
||||||
g.entries
|
let merged = merged_entries(&g.server_entries, &g.local_entries);
|
||||||
|
merged
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, value)| (node_env_var_name(name), value.clone()))
|
.map(|(name, value)| (node_env_var_name(name), value.clone()))
|
||||||
.collect()
|
.collect()
|
||||||
@@ -95,18 +208,21 @@ impl AppConfig {
|
|||||||
|
|
||||||
pub async fn refresh_after_auth(&self, token: &str) -> Result<(), AppConfigError> {
|
pub async fn refresh_after_auth(&self, token: &str) -> Result<(), AppConfigError> {
|
||||||
if token.trim().is_empty() {
|
if token.trim().is_empty() {
|
||||||
self.clear().await;
|
self.clear_server().await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match fetch_from_server(token).await {
|
match fetch_from_server(token).await {
|
||||||
Ok(map) => {
|
Ok(map) => {
|
||||||
let mut g = self.inner.write().await;
|
let mut g = self.inner.write().await;
|
||||||
g.entries = map;
|
g.server_entries = map;
|
||||||
g.last_message.clear();
|
g.last_message.clear();
|
||||||
|
let merged_len =
|
||||||
|
merged_entries(&g.server_entries, &g.local_entries).len();
|
||||||
log::info!(
|
log::info!(
|
||||||
target: "app_config",
|
target: "app_config",
|
||||||
"应用配置已更新,共 {} 项",
|
"服务端配置已更新;合并后共 {} 项(本地 {} 项可覆盖同名键)",
|
||||||
g.entries.len()
|
merged_len,
|
||||||
|
g.local_entries.len()
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::app_config::AppConfig;
|
use crate::app_config::AppConfig;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConfigEntryDetail {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_config(
|
pub async fn get_app_config(
|
||||||
app_config: State<'_, AppConfig>,
|
app_config: State<'_, AppConfig>,
|
||||||
@@ -17,3 +26,69 @@ pub async fn get_app_config_last_message(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
Ok(app_config.last_message().await)
|
Ok(app_config.last_message().await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅服务端下发的配置(不含本地覆盖)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_server_app_config(
|
||||||
|
app_config: State<'_, AppConfig>,
|
||||||
|
) -> Result<HashMap<String, String>, String> {
|
||||||
|
Ok(app_config.server_entries().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并配置明细:标明每项来自 local 或 server(本地同名已覆盖的不重复列出 server 项)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_app_config_detail(
|
||||||
|
app_config: State<'_, AppConfig>,
|
||||||
|
) -> Result<Vec<ConfigEntryDetail>, String> {
|
||||||
|
let server = app_config.server_entries().await;
|
||||||
|
let local = app_config.local_entries().await;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut names: Vec<String> = server
|
||||||
|
.keys()
|
||||||
|
.chain(local.keys())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
for name in names {
|
||||||
|
if local.contains_key(&name) {
|
||||||
|
items.push(ConfigEntryDetail {
|
||||||
|
name: name.clone(),
|
||||||
|
value: local.get(&name).cloned().unwrap_or_default(),
|
||||||
|
source: "local".into(),
|
||||||
|
});
|
||||||
|
} else if let Some(v) = server.get(&name) {
|
||||||
|
items.push(ConfigEntryDetail {
|
||||||
|
name,
|
||||||
|
value: v.clone(),
|
||||||
|
source: "server".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_local_app_config(
|
||||||
|
app_config: State<'_, AppConfig>,
|
||||||
|
) -> Result<Vec<crate::local_config::LocalConfigItem>, String> {
|
||||||
|
app_config.list_local_items().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_local_app_config(
|
||||||
|
app_config: State<'_, AppConfig>,
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
mark: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app_config.set_local(name, value, mark).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_local_app_config(
|
||||||
|
app_config: State<'_, AppConfig>,
|
||||||
|
name: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app_config.delete_local(&name).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod app_config;
|
pub mod app_config;
|
||||||
|
pub mod local_config;
|
||||||
pub mod asr;
|
pub mod asr;
|
||||||
pub mod auth_session;
|
pub mod auth_session;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
@@ -18,6 +19,21 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(auth_session::AuthSession::default())
|
.manage(auth_session::AuthSession::default())
|
||||||
.manage(app_config::AppConfig::new())
|
.manage(app_config::AppConfig::new())
|
||||||
|
.setup(|app| {
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::block_on(async move {
|
||||||
|
let app_config = handle.state::<app_config::AppConfig>();
|
||||||
|
let dir = handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||||
|
let db_path = dir.join("local_config.db");
|
||||||
|
app_config.init_local_store(db_path).await?;
|
||||||
|
Ok::<(), String>(())
|
||||||
|
})
|
||||||
|
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
@@ -30,6 +46,11 @@ pub fn run() {
|
|||||||
commands::auth::sync_auth_session,
|
commands::auth::sync_auth_session,
|
||||||
commands::app_config::get_app_config,
|
commands::app_config::get_app_config,
|
||||||
commands::app_config::get_app_config_last_message,
|
commands::app_config::get_app_config_last_message,
|
||||||
|
commands::app_config::get_server_app_config,
|
||||||
|
commands::app_config::get_app_config_detail,
|
||||||
|
commands::app_config::list_local_app_config,
|
||||||
|
commands::app_config::set_local_app_config,
|
||||||
|
commands::app_config::delete_local_app_config,
|
||||||
commands::quickjs::run_quickjs_script,
|
commands::quickjs::run_quickjs_script,
|
||||||
commands::quickjs::run_quickjs_script_source,
|
commands::quickjs::run_quickjs_script_source,
|
||||||
commands::quickjs::list_quickjs_scripts,
|
commands::quickjs::list_quickjs_scripts,
|
||||||
|
|||||||
130
src-tauri/src/local_config.rs
Normal file
130
src-tauri/src/local_config.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
//! SQLite 本地键值配置(`app_data_dir/local_config.db`)。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum LocalConfigError {
|
||||||
|
#[error("{0}")]
|
||||||
|
Db(#[from] rusqlite::Error),
|
||||||
|
#[error("{0}")]
|
||||||
|
Message(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalConfigItem {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub mark: Option<String>,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LocalConfigDb {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalConfigDb {
|
||||||
|
pub fn open(path: PathBuf) -> Result<Self, LocalConfigError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| LocalConfigError::Message(format!("创建配置目录失败: {e}")))?;
|
||||||
|
}
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS local_configs (
|
||||||
|
name TEXT PRIMARY KEY NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
mark TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_all_map(&self) -> Result<HashMap<String, String>, LocalConfigError> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
|
LocalConfigError::Message("本地数据库锁异常".into())
|
||||||
|
})?;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT name, value FROM local_configs ORDER BY name ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})?;
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let (k, v) = row?;
|
||||||
|
map.insert(k, v);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_items(&self) -> Result<Vec<LocalConfigItem>, LocalConfigError> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
|
LocalConfigError::Message("本地数据库锁异常".into())
|
||||||
|
})?;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT name, value, mark, updated_at FROM local_configs ORDER BY name ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(LocalConfigItem {
|
||||||
|
name: row.get(0)?,
|
||||||
|
value: row.get(1)?,
|
||||||
|
mark: row.get(2)?,
|
||||||
|
updated_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
items.push(row?);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
value: &str,
|
||||||
|
mark: Option<&str>,
|
||||||
|
) -> Result<(), LocalConfigError> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(LocalConfigError::Message("配置键名不能为空".into()));
|
||||||
|
}
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
|
LocalConfigError::Message("本地数据库锁异常".into())
|
||||||
|
})?;
|
||||||
|
conn.execute(
|
||||||
|
r#"
|
||||||
|
INSERT INTO local_configs (name, value, mark, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
mark = excluded.mark,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
"#,
|
||||||
|
params![name, value, mark, now],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, name: &str) -> Result<(), LocalConfigError> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
|
LocalConfigError::Message("本地数据库锁异常".into())
|
||||||
|
})?;
|
||||||
|
conn.execute("DELETE FROM local_configs WHERE name = ?1", params![name])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ const baseMenuItems = [
|
|||||||
{ name: "extract", label: "提取", to: "/extract", icon: "extract" },
|
{ name: "extract", label: "提取", to: "/extract", icon: "extract" },
|
||||||
{ name: "design", label: "设计", to: "/design", icon: "design" },
|
{ name: "design", label: "设计", to: "/design", icon: "design" },
|
||||||
{ name: "model", label: "模型", to: "/model", icon: "model" },
|
{ name: "model", label: "模型", to: "/model", icon: "model" },
|
||||||
|
{ name: "local-config", label: "本地配置", to: "/local-config", icon: "settings" },
|
||||||
{ name: "help", label: "帮助", to: "/help", icon: "help" },
|
{ name: "help", label: "帮助", to: "/help", icon: "help" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -81,6 +82,13 @@ const activeName = computed(() => {
|
|||||||
<path d="M12 3l7 4v10l-7 4-7-4V7l7-4z" />
|
<path d="M12 3l7 4v10l-7 4-7-4V7l7-4z" />
|
||||||
<path d="M12 3v18M5 7l7 4 7-4" />
|
<path d="M12 3v18M5 7l7 4 7-4" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<svg v-else-if="item.icon === 'settings'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<svg v-else-if="item.icon === 'help'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg v-else-if="item.icon === 'help'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.7-1.2 1.2-1.2 2.2M12 17h.01" stroke-linecap="round" />
|
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.7-1.2 1.2-1.2 2.2M12 17h.01" stroke-linecap="round" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 流水线配置校验:优先使用 Rust 缓存的 desktop_configs(get_app_config)。
|
* 流水线配置校验:Rust 合并服务端 desktop_configs + 本地 SQLite(同名本地优先)。
|
||||||
* 实际密钥由 Node 子进程通过环境变量 AICLIENT_CFG_* 读取,无需在前端 params 传递。
|
* 实际密钥由 Node 子进程通过环境变量 AICLIENT_CFG_* 读取,无需在前端 params 传递。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,7 +17,8 @@ export async function ensureAppConfigReady() {
|
|||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "服务端配置缺少 LLM_API_KEY,请在 desktop_configs 表中维护",
|
message:
|
||||||
|
"缺少 LLM_API_KEY,请在管理后台 desktop_configs 或侧栏「本地配置」中设置",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -28,12 +29,12 @@ export async function ensureAppConfigReady() {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "未获取到应用配置,请先登录并确保 desktop_configs 表有数据",
|
message: "未获取到应用配置,请登录服务端或在「本地配置」中添加 LLM_API_KEY",
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "请在 Tauri 桌面端登录后使用(配置由服务端下发)",
|
message: "请在 Tauri 桌面端使用,并登录或在「本地配置」中设置密钥",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ const mainChildren = [
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "local-config",
|
||||||
|
|
||||||
|
name: "local-config",
|
||||||
|
|
||||||
|
component: () => import("../views/LocalConfigView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "本地配置" },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
path: "admin",
|
path: "admin",
|
||||||
|
|||||||
238
src/views/LocalConfigView.vue
Normal file
238
src/views/LocalConfigView.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const localItems = ref([]);
|
||||||
|
const mergedDetail = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const dialogMode = ref("create");
|
||||||
|
const editingName = ref("");
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
name: "",
|
||||||
|
value: "",
|
||||||
|
mark: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref(emptyForm());
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
dialogMode.value === "create" ? "添加本地配置" : "编辑本地配置",
|
||||||
|
);
|
||||||
|
|
||||||
|
const isTauri = computed(() => typeof window !== "undefined" && "__TAURI_INTERNALS__" in window);
|
||||||
|
|
||||||
|
function showMessage(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value, max = 40) {
|
||||||
|
if (!value) return "—";
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
return `${value.slice(0, max)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
if (!isTauri.value) {
|
||||||
|
showMessage("warn", "本地配置仅在 Tauri 桌面端可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
try {
|
||||||
|
const [local, detail] = await Promise.all([
|
||||||
|
invoke("list_local_app_config"),
|
||||||
|
invoke("get_app_config_detail"),
|
||||||
|
]);
|
||||||
|
localItems.value = Array.isArray(local) ? local : [];
|
||||||
|
mergedDetail.value = Array.isArray(detail) ? detail : [];
|
||||||
|
} catch (err) {
|
||||||
|
showMessage("error", String(err));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogMode.value = "create";
|
||||||
|
editingName.value = "";
|
||||||
|
form.value = emptyForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row) {
|
||||||
|
dialogMode.value = "edit";
|
||||||
|
editingName.value = row.name;
|
||||||
|
form.value = {
|
||||||
|
name: row.name,
|
||||||
|
value: row.value,
|
||||||
|
mark: row.mark || "",
|
||||||
|
};
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLocal() {
|
||||||
|
const name = form.value.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
showMessage("warn", "键名不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await invoke("set_local_app_config", {
|
||||||
|
name,
|
||||||
|
value: form.value.value,
|
||||||
|
mark: form.value.mark.trim() || null,
|
||||||
|
});
|
||||||
|
dialogVisible.value = false;
|
||||||
|
showMessage("success", "本地配置已保存(同名键将覆盖服务端)");
|
||||||
|
await loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
showMessage("error", String(err));
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLocal(row) {
|
||||||
|
const ok = window.confirm(`删除本地配置「${row.name}」?将恢复使用服务端同名配置。`);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await invoke("delete_local_app_config", { name: row.name });
|
||||||
|
showMessage("success", "已删除");
|
||||||
|
await loadAll();
|
||||||
|
} catch (err) {
|
||||||
|
showMessage("error", String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="local-config-page custom-scrollbar p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="gradient-text text-xl font-semibold">本地配置</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-muted">
|
||||||
|
保存在本机 SQLite,与登录后服务端下发的配置合并;<strong class="text-text-secondary">同名键本地优先</strong>,并注入 Node 环境变量
|
||||||
|
<code class="rounded bg-white/10 px-1 font-mono text-xs">AICLIENT_CFG_*</code>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="feedback.message"
|
||||||
|
:severity="feedback.severity"
|
||||||
|
:closable="true"
|
||||||
|
class="mb-3"
|
||||||
|
@close="feedback.message = ''"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<Button label="添加本地配置" :disabled="!isTauri" @click="openCreateDialog" />
|
||||||
|
<Button label="刷新" severity="secondary" outlined :loading="loading" @click="loadAll" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mb-2 text-sm font-medium text-text-muted">本地配置项</h2>
|
||||||
|
<DataTable
|
||||||
|
:value="localItems"
|
||||||
|
:loading="loading"
|
||||||
|
striped-rows
|
||||||
|
size="small"
|
||||||
|
class="admin-users__table mb-6"
|
||||||
|
data-key="name"
|
||||||
|
>
|
||||||
|
<Column field="name" header="键名">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<code class="text-xs">{{ data.name }}</code>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="value" header="值">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span :title="data.value">{{ truncate(data.value) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="mark" header="备注">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.mark || "—" }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="操作" style="width: 8rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="admin-users__actions">
|
||||||
|
<Button label="编辑" size="small" text @click="openEditDialog(data)" />
|
||||||
|
<Button label="删除" size="small" severity="danger" text @click="removeLocal(data)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<template #empty>
|
||||||
|
<p class="py-4 text-center text-sm text-text-muted">暂无本地配置</p>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<h2 class="mb-2 text-sm font-medium text-text-muted">当前生效(合并后)</h2>
|
||||||
|
<DataTable
|
||||||
|
:value="mergedDetail"
|
||||||
|
:loading="loading"
|
||||||
|
striped-rows
|
||||||
|
size="small"
|
||||||
|
class="admin-users__table"
|
||||||
|
data-key="name"
|
||||||
|
>
|
||||||
|
<Column field="name" header="键名">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<code class="text-xs">{{ data.name }}</code>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="value" header="值">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span :title="data.value">{{ truncate(data.value) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="source" header="来源" style="width: 6rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
:value="data.source === 'local' ? '本地' : '服务端'"
|
||||||
|
:severity="data.source === 'local' ? 'warn' : 'info'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<template #empty>
|
||||||
|
<p class="py-4 text-center text-sm text-text-muted">尚无配置,请登录或添加本地项</p>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="dialogVisible" :header="dialogTitle" modal :style="{ width: '28rem' }">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">键名</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.name"
|
||||||
|
class="w-full font-mono uppercase"
|
||||||
|
:disabled="dialogMode === 'edit'"
|
||||||
|
placeholder="如 LLM_API_KEY"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">值</label>
|
||||||
|
<Textarea v-model="form.value" rows="5" class="w-full font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">备注(仅本地记录)</label>
|
||||||
|
<InputText v-model="form.mark" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||||
|
<Button label="保存" :loading="saving" @click="saveLocal" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user