Files
stash/src/db/settings.rs
T
CNWei aa6f341505 feat(search): 按参考稿重做搜索框并支持后台配置
- 顶部悬浮引擎标签 + 下拉选项,使用 stash-ui 新增的 EngineIcon
- 站内搜索保持现有本地过滤与回车打开首个匹配项
- 外部引擎使用配置的 {query} 模板新标签页搜索
- settings 新增 search.default / search.engines,后台新增『搜索框』配置默认项、名称、地址与启停
- 默认默认搜索项为站内搜索,地址校验必须含 {query}
2026-11-04 18:11:22 +08:00

569 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。
//!
//! 存 `setting` 表:`key = 'site'`,`value` 是一段 JSON,`version` 做乐观锁。
//! 读的时候任何字段缺失都回落默认值,坏 JSON 也回落默认值——
//! 设置坏掉不应该让首页白屏。
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::{db::map_error, error::StashError};
pub const SETTINGS_KEY: &str = "site";
pub const MAX_HEADER_ITEMS: usize = 8;
pub const MIN_SPEED: u32 = 8;
pub const MAX_SPEED: u32 = 120;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Tone {
#[default]
Plain,
Primary,
Success,
Neutral,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct HeaderItem {
/// 文本,支持占位符 `{count}` / `{project}` / `{status}`
pub text: String,
/// 前置小标签,如「本地」「状态」
pub tag: Option<String>,
pub tone: Tone,
}
impl Default for HeaderItem {
fn default() -> Self {
Self {
text: String::new(),
tag: None,
tone: Tone::Plain,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct HeaderSettings {
pub enabled: bool,
/// 滚动一轮的秒数
pub speed: u32,
/// 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽
pub gap: u32,
pub items: Vec<HeaderItem>,
}
impl Default for HeaderSettings {
fn default() -> Self {
Self {
enabled: true,
speed: 24,
gap: 26,
items: vec![
HeaderItem {
text: "共 {count} 个本地应用".into(),
tag: None,
tone: Tone::Plain,
},
HeaderItem {
text: "{project}".into(),
tag: Some("本地".into()),
tone: Tone::Primary,
},
HeaderItem {
text: "{status}".into(),
tag: Some("状态".into()),
tone: Tone::Success,
},
],
}
}
}
fn default_true() -> bool {
true
}
impl Default for SidebarSettings {
fn default() -> Self {
Self {
show_profile: true,
show_service: true,
show_nav: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SidebarSettings {
/// 是否显示「本地管理员」资料卡
#[serde(default = "default_true")]
pub show_profile: bool,
/// 是否显示「本地服务」面板
#[serde(default = "default_true")]
pub show_service: bool,
/// 是否显示「分组导航」卡片(首页 / 分组树)
#[serde(default = "default_true")]
pub show_nav: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct BackgroundSettings {
/// 亮色背景图地址;空表示用内置图
pub light: String,
/// 暗色背景图地址;空表示用内置图
pub dark: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SearchEngineSettings {
pub id: String,
pub name: String,
/// 搜索地址模板,用 `{query}` 表示关键词
pub url: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SearchSettings {
/// `internal` 或某个引擎 id
pub default: String,
pub engines: Vec<SearchEngineSettings>,
}
impl Default for SearchSettings {
fn default() -> Self {
Self {
default: "internal".into(),
engines: vec![
SearchEngineSettings {
id: "google".into(),
name: "Google".into(),
url: "https://www.google.com/search?q={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "bing".into(),
name: "Bing".into(),
url: "https://www.bing.com/search?q={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "baidu".into(),
name: "百度".into(),
url: "https://www.baidu.com/s?wd={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "sogou".into(),
name: "搜狗".into(),
url: "https://www.sogou.com/web?query={query}".into(),
enabled: true,
},
SearchEngineSettings {
id: "360".into(),
name: "360".into(),
url: "https://www.so.com/s?q={query}".into(),
enabled: true,
},
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ProfileSettings {
pub name: String,
pub role: String,
pub description: String,
pub location: String,
/// 头像地址;空字符串表示用首字占位
pub avatar: String,
}
impl Default for ProfileSettings {
fn default() -> Self {
Self {
name: "本地管理员".into(),
role: "管理员".into(),
description: "本地静态站点托管与预览,所有项目都运行在这台机器上。".into(),
location: "IP属地:局域网".into(),
avatar: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BrandSettings {
/// 品牌文字(页头左侧,可留空只显示 logo)
pub name: String,
/// 没有 logo 图片时,圆形标记里显示的首字
pub mark: String,
/// logo 图片地址;空字符串表示用首字
pub logo: String,
}
impl Default for BrandSettings {
fn default() -> Self {
Self {
name: "STASH".into(),
mark: "S".into(),
logo: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ActionSettings {
/// 明暗切换图标
#[serde(default = "default_true")]
pub theme: bool,
/// 刷新图标(重扫本地项目并重拉内容库)
pub refresh: bool,
/// 站点设置图标(仅登录后出现)
pub settings: bool,
/// 登录 / 退出登录
pub session: bool,
}
impl Default for ActionSettings {
fn default() -> Self {
Self {
theme: true,
refresh: true,
settings: true,
session: true,
}
}
}
/// 配色:浅色 / 深色 / 跟随系统。
/// 落到 `<html data-scheme>` 上,深色由组件库的令牌块接管,页面不用改组件。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThemeSetting {
Light,
Dark,
#[default]
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SiteMetaSettings {
/// 浏览器标签标题
pub title: String,
/// favicon 地址;空字符串表示用内置图标
pub favicon: String,
/// 配色
pub theme: ThemeSetting,
pub brand: BrandSettings,
pub actions: ActionSettings,
}
impl Default for SiteMetaSettings {
fn default() -> Self {
Self {
title: "Stash · 本地应用工作区".into(),
favicon: String::new(),
theme: ThemeSetting::Auto,
brand: BrandSettings::default(),
actions: ActionSettings::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct Settings {
pub header: HeaderSettings,
pub sidebar: SidebarSettings,
pub background: BackgroundSettings,
pub search: SearchSettings,
pub profile: ProfileSettings,
pub site: SiteMetaSettings,
}
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SettingsDocument {
pub version: i64,
pub settings: Settings,
}
/// 校验:条数、长度、速度范围。
pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.header.items.is_empty() {
return Err(StashError::InvalidDirectory("页头至少保留一条信息".into()));
}
if settings.header.items.len() > MAX_HEADER_ITEMS {
return Err(StashError::InvalidDirectory(format!(
"页头最多 {MAX_HEADER_ITEMS} 条信息"
)));
}
if !(MIN_SPEED..=MAX_SPEED).contains(&settings.header.speed) {
return Err(StashError::InvalidDirectory(format!(
"滚动速度需在 {MIN_SPEED}–{MAX_SPEED} 秒之间"
)));
}
if settings.header.gap > 120 {
return Err(StashError::InvalidDirectory("信息间距最多 120".into()));
}
for item in &settings.header.items {
let text = item.text.trim();
if text.is_empty() {
return Err(StashError::InvalidDirectory("信息文本不能为空".into()));
}
if text.chars().count() > 60 {
return Err(StashError::InvalidDirectory("单条信息最多 60 个字".into()));
}
if let Some(tag) = &item.tag {
if tag.chars().count() > 8 {
return Err(StashError::InvalidDirectory("标签最多 8 个字".into()));
}
}
}
if settings.profile.name.chars().count() > 40 {
return Err(StashError::InvalidDirectory("名称最多 40 个字".into()));
}
if settings.profile.role.chars().count() > 20 {
return Err(StashError::InvalidDirectory("角色最多 20 个字".into()));
}
if settings.profile.description.chars().count() > 200 {
return Err(StashError::InvalidDirectory("描述最多 200 个字".into()));
}
if settings.profile.location.chars().count() > 40 {
return Err(StashError::InvalidDirectory("位置最多 40 个字".into()));
}
if settings.background.light.chars().count() > 500 {
return Err(StashError::InvalidDirectory(
"亮色背景地址最多 500 个字符".into(),
));
}
if settings.background.dark.chars().count() > 500 {
return Err(StashError::InvalidDirectory(
"暗色背景地址最多 500 个字符".into(),
));
}
if settings.search.engines.len() > 10 {
return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into()));
}
let mut ids = std::collections::HashSet::new();
for engine in &settings.search.engines {
if !matches!(
engine.id.as_str(),
"google" | "bing" | "baidu" | "sogou" | "360"
) {
return Err(StashError::InvalidDirectory("搜索引擎标识不合法".into()));
}
if !ids.insert(engine.id.as_str()) {
return Err(StashError::InvalidDirectory("搜索引擎标识不能重复".into()));
}
let name = engine.name.trim();
if name.is_empty() || name.chars().count() > 20 {
return Err(StashError::InvalidDirectory("搜索引擎名称需为 1–20 个字".into()));
}
let url = engine.url.trim();
if url.chars().count() > 500
|| !(url.starts_with("http://") || url.starts_with("https://"))
|| !url.contains("{query}")
{
return Err(StashError::InvalidDirectory(
"搜索地址需以 http(s):// 开头并包含 {query}".into(),
));
}
}
let default = settings.search.default.trim();
if default != "internal" {
let engine = settings
.search
.engines
.iter()
.find(|engine| engine.id == default)
.ok_or_else(|| StashError::InvalidDirectory("默认搜索引擎不存在".into()))?;
if !engine.enabled {
return Err(StashError::InvalidDirectory("默认搜索引擎未启用".into()));
}
}
if settings.site.title.chars().count() > 120 {
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
}
if settings.site.favicon.chars().count() > 500 {
return Err(StashError::InvalidDirectory("favicon 地址最多 500 个字符".into()));
}
if settings.site.brand.name.chars().count() > 20 {
return Err(StashError::InvalidDirectory("品牌文字最多 20 个字".into()));
}
if settings.site.brand.mark.chars().count() > 4 {
return Err(StashError::InvalidDirectory("logo 标记最多 4 个字".into()));
}
if settings.site.brand.logo.chars().count() > 500 {
return Err(StashError::InvalidDirectory("logo 地址最多 500 个字符".into()));
}
Ok(())
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
/// 读设置;没有记录时返回默认值与 version = 0。
pub fn load(conn: &Connection) -> Result<SettingsDocument, StashError> {
let row: Option<(String, i64)> = conn
.query_row(
"SELECT value, version FROM setting WHERE key = ?1",
[SETTINGS_KEY],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(map_error)?;
let Some((raw, version)) = row else {
return Ok(SettingsDocument {
version: 0,
settings: Settings::default(),
});
};
let settings = serde_json::from_str::<Settings>(&raw).unwrap_or_else(|error| {
tracing::warn!("设置 JSON 解析失败,使用默认值: {error}");
Settings::default()
});
Ok(SettingsDocument { version, settings })
}
/// 保存设置;`expected_version` 与库里不一致时返回冲突。
pub fn save(
conn: &mut Connection,
settings: Settings,
expected_version: i64,
) -> Result<SettingsDocument, StashError> {
validate(&settings)?;
let value = serde_json::to_string(&settings)
.map_err(|error| StashError::Database(format!("设置序列化失败: {error}")))?;
let tx = conn.transaction().map_err(map_error)?;
let updated = if expected_version == 0 {
tx.execute(
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, ?2, 1, ?3)
ON CONFLICT(key) DO NOTHING",
params![SETTINGS_KEY, value, now()],
)
.map_err(map_error)?
} else {
tx.execute(
"UPDATE setting SET value = ?1, version = version + 1, updated_at = ?2
WHERE key = ?3 AND version = ?4",
params![value, now(), SETTINGS_KEY, expected_version],
)
.map_err(map_error)?
};
if updated == 0 {
return Err(StashError::Conflict(
"设置已被其它页面修改,请刷新后重试".into(),
));
}
let (version,): (i64,) = tx
.query_row(
"SELECT version FROM setting WHERE key = ?1",
[SETTINGS_KEY],
|row| Ok((row.get(0)?,)),
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
Ok(SettingsDocument { version, settings })
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"CREATE TABLE setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);",
)
.expect("应能建表");
conn
}
#[test]
fn defaults_are_stable_and_valid() {
let settings = Settings::default();
assert!(settings.header.enabled);
assert_eq!(settings.header.items.len(), 3);
assert!(validate(&settings).is_ok());
}
#[test]
fn roundtrip_and_version_bump() {
let mut conn = db();
let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功");
assert_eq!(saved.version, 1);
let loaded = load(&conn).expect("应能读");
assert_eq!(loaded, saved);
let mut next = saved.settings.clone();
next.header.speed = 40;
let saved2 = save(&mut conn, next, saved.version).expect("更新应成功");
assert_eq!(saved2.version, 2);
assert_eq!(saved2.settings.header.speed, 40);
}
#[test]
fn stale_version_conflicts() {
let mut conn = db();
let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功");
let error = save(&mut conn, Settings::default(), saved.version - 1).expect_err("应冲突");
assert!(matches!(error, StashError::Conflict(_)));
}
#[test]
fn invalid_settings_are_rejected() {
let mut conn = db();
let mut bad = Settings::default();
bad.header.speed = 1;
assert!(save(&mut conn, bad, 0).is_err());
let mut empty = Settings::default();
empty.header.items.clear();
assert!(save(&mut conn, empty, 0).is_err());
}
#[test]
fn broken_json_falls_back_to_defaults() {
let conn = db();
conn.execute(
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, '{bad json', 3, 0)",
[SETTINGS_KEY],
)
.expect("应能写入");
let loaded = load(&conn).expect("坏 JSON 不应报错");
assert_eq!(loaded.version, 3);
assert_eq!(loaded.settings, Settings::default());
}
}