前台 - 搜索框从内容列移到 Layout 的 toolbar 插槽,去掉 fixed + TOP_OFFSET + top-blur 三处补偿 - 左右栏滚动容器不变,左栏吸顶改用 CSS sticky(修回顶时侧栏抖动) - 只让分组导航滚动,资料卡与本地服务固定 - 页头操作支持配置:搜索改为刷新(重扫项目 + 重拉内容库) - 站点级设置:页面标题 / favicon / 品牌 logo / 品牌文字 - 停用的分组在侧边栏、首页卡片、分组页、条目页统一不再出现 后台 - 「侧边栏」「首页卡片」合并为「首页」Tab - 首页卡片表格新增状态列与顺序列(上下箭头即时保存) - 分组显隐只由「分组管理 → 状态」决定,去掉侧栏独立勾选 - 页头 Tab 增加站点信息、首页 Logo(带实时预览)、操作工具开关 - 后台页头与前台同构并吸顶 后端 - settings 新增 site(标题 / favicon / 品牌 / 操作工具)与 sidebar.show_nav - 写接口成功后广播内容库变更,SSE 改为「广播 + data_version 兜底」 - catalog SSE 事件名与前端监听对齐(catalog)
489 lines
16 KiB
Rust
489 lines
16 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,
|
||
pub items: Vec<HeaderItem>,
|
||
}
|
||
|
||
impl Default for HeaderSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
speed: 24,
|
||
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,
|
||
hidden: Vec::new(),
|
||
order: Vec::new(),
|
||
hidden_groups: Vec::new(),
|
||
shown_groups: Vec::new(),
|
||
group_order: std::collections::HashMap::new(),
|
||
tree_hidden: Vec::new(),
|
||
tree_order: std::collections::HashMap::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
/// 隐藏的分类 id
|
||
pub hidden: Vec<String>,
|
||
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
|
||
pub order: Vec<String>,
|
||
/// 隐藏的分组,键为 `分区id/分组id`
|
||
pub hidden_groups: Vec<String>,
|
||
/// 强制显示的分组(覆盖数据库里的 show_in_sidebar=0)
|
||
pub shown_groups: Vec<String>,
|
||
/// 每个分区下分组的顺序:分区 id → 分组 id 列表
|
||
pub group_order: std::collections::HashMap<String, Vec<String>>,
|
||
/// 新模型:侧栏隐藏的分组 key(任意层级)
|
||
pub tree_hidden: Vec<String>,
|
||
/// 新模型:每级分组的顺序,键是父分组 key(根级用空串)
|
||
pub tree_order: std::collections::HashMap<String, Vec<String>>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||
#[serde(default)]
|
||
pub struct SectionsSettings {
|
||
/// 隐藏的首页卡片(分类 id)
|
||
pub hidden: Vec<String>,
|
||
/// 卡片顺序(分类 id)
|
||
pub order: Vec<String>,
|
||
/// 标题覆盖:id → 标题
|
||
pub titles: std::collections::HashMap<String, String>,
|
||
/// 预览条数覆盖:id → 条数
|
||
pub limits: std::collections::HashMap<String, u32>,
|
||
}
|
||
|
||
#[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 {
|
||
/// 刷新图标(重扫本地项目并重拉内容库)
|
||
pub refresh: bool,
|
||
/// 站点设置图标(仅登录后出现)
|
||
pub settings: bool,
|
||
/// 登录 / 退出登录
|
||
pub session: bool,
|
||
}
|
||
|
||
impl Default for ActionSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
refresh: true,
|
||
settings: true,
|
||
session: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(default)]
|
||
pub struct SiteMetaSettings {
|
||
/// 浏览器标签标题
|
||
pub title: String,
|
||
/// favicon 地址;空字符串表示用内置图标
|
||
pub favicon: String,
|
||
pub brand: BrandSettings,
|
||
pub actions: ActionSettings,
|
||
}
|
||
|
||
impl Default for SiteMetaSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
title: "Stash · 本地应用工作区".into(),
|
||
favicon: String::new(),
|
||
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 sections: SectionsSettings,
|
||
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} 秒之间"
|
||
)));
|
||
}
|
||
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.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 {
|
||
return Err(StashError::InvalidDirectory("侧栏配置过长".into()));
|
||
}
|
||
if settings.sidebar.hidden_groups.len() > 200
|
||
|| settings.sidebar.shown_groups.len() > 200
|
||
|| settings.sidebar.group_order.len() > 100
|
||
|| settings.sidebar.tree_hidden.len() > 500
|
||
|| settings.sidebar.tree_order.len() > 200
|
||
{
|
||
return Err(StashError::InvalidDirectory("侧栏分组配置过长".into()));
|
||
}
|
||
if settings.sections.hidden.len() > 100 || settings.sections.order.len() > 100 {
|
||
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
|
||
}
|
||
if settings.sections.titles.len() > 100 || settings.sections.limits.len() > 100 {
|
||
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
|
||
}
|
||
for title in settings.sections.titles.values() {
|
||
if title.chars().count() > 40 {
|
||
return Err(StashError::InvalidDirectory("卡片标题最多 40 个字".into()));
|
||
}
|
||
}
|
||
for limit in settings.sections.limits.values() {
|
||
if *limit > 60 {
|
||
return Err(StashError::InvalidDirectory("卡片条数最多 60".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.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!(settings.sections.hidden.is_empty());
|
||
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());
|
||
}
|
||
}
|