feat(admin): 登录改页内弹窗 + P1 站点设置(页头/侧栏)

- 登录:LoginDialog(Modal)替代 /login 页面;/login 重定向回首页
- 设置接口:GET /api/settings 公开读、PATCH 需登录 + version 乐观锁(409/400)
- setting 表存 site JSON:页头(启用/速度/条目+标签+色调)、侧栏(hidden/order)
- /admin:Tabs 页头/侧栏,条目增删与上下移,保存后首页生效
- 首页应用设置:ticker 文本占位符与速度、侧栏顺序与显隐
- 测试 42 passed(settings 5 例)
This commit is contained in:
stash
2026-10-29 12:52:29 +08:00
parent dea7781b86
commit 9ae5a455c5
14 changed files with 1205 additions and 195 deletions
+293
View File
@@ -0,0 +1,293 @@
//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。
//!
//! 存 `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,
},
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SidebarSettings {
/// 隐藏的分类 id
pub hidden: Vec<String>,
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
pub order: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct Settings {
pub header: HeaderSettings,
pub sidebar: SidebarSettings,
}
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
#[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()));
}
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());
}
}