- 顶部悬浮引擎标签 + 下拉选项,使用 stash-ui 新增的 EngineIcon
- 站内搜索保持现有本地过滤与回车打开首个匹配项
- 外部引擎使用配置的 {query} 模板新标签页搜索
- settings 新增 search.default / search.engines,后台新增『搜索框』配置默认项、名称、地址与启停
- 默认默认搜索项为站内搜索,地址校验必须含 {query}
569 lines
18 KiB
Rust
569 lines
18 KiB
Rust
//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。
|
||
//!
|
||
//! 存 `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());
|
||
}
|
||
}
|