diff --git a/README.md b/README.md index 4c26132..dde5f0e 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,12 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 - Rust 工具链(2024 Edition) - Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本 -### 登录(v0.1.1 起) +### 登录与设置(v0.1.1 起) -- 首次打开 `http://127.0.0.1:8080/login` 是**设置密码**(至少 8 位),之后是登录; +- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位); +- 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐; - 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天); -- 读接口公开(本地工具可只读使用),写接口(`/api/auth/password` 及后续编辑接口)要求登录; +- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录; - 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」; - 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。 diff --git a/docs/ADMIN-PLAN.md b/docs/ADMIN-PLAN.md index 1f634b2..0d52f22 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -158,3 +158,13 @@ DELETE /api/items/:id **未做(P1/P2)**:`/admin` 设置页、侧栏/页头编辑、内容就编辑、`setting` 表的读写接口。 +### P1(已完成,2026-10-29) + +- 登录改为**页内弹窗**(`LoginDialog`):`/login` 旧地址重定向回首页,不再单开页面; +- 设置读写:`GET /api/settings`(公开)、`PATCH /api/settings`(需登录,`version` 乐观锁,冲突 409、校验失败 400); +- `setting` 表存 `key='site'` 的 JSON:页头(启用 / 速度 / 条目与标签/色调)、侧栏(隐藏 / 顺序); +- `/admin` 设置页:Tabs(页头滚动信息 / 侧栏导航),条目支持增删、上移下移、占位符 `{count}`/`{project}`/`{status}`; +- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染; +- 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。 + + diff --git a/src/db/mod.rs b/src/db/mod.rs index 03f6096..c136cec 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -11,6 +11,7 @@ pub mod auth; mod catalog; mod schema; +pub mod settings; use std::sync::Mutex; @@ -24,6 +25,8 @@ pub use schema::SeedReport; /// 登录/会话的对外状态(server 层直接用)。 pub use auth::SessionState; +/// 站点设置:页头滚动信息 + 侧栏顺序/显隐。 +pub use settings::{Settings, SettingsDocument}; /// 一次查询得到的完整内容目录。 /// @@ -152,6 +155,22 @@ impl Db { let mut conn = self.lock()?; auth::change_password(&mut conn, current, next) } + + // ---------- 站点设置 ---------- + + pub fn load_settings(&self) -> Result { + let conn = self.lock()?; + settings::load(&conn) + } + + pub fn save_settings( + &self, + next: Settings, + expected_version: i64, + ) -> Result { + let mut conn = self.lock()?; + settings::save(&mut conn, next, expected_version) + } } /// 把 rusqlite 的错误统一收敛成 `StashError::Database`。 diff --git a/src/db/settings.rs b/src/db/settings.rs new file mode 100644 index 0000000..0f7a50a --- /dev/null +++ b/src/db/settings.rs @@ -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, + 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, +} + +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, + /// 分类显示顺序(id);未列出的排在后面,保持原有顺序 + pub order: Vec, +} + +#[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 { + 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::(&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 { + 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()); + } +} diff --git a/src/error.rs b/src/error.rs index 9691753..d888a3b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -30,6 +30,10 @@ pub enum StashError { /// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。 #[error("数据库错误: {0}")] Database(String), + + /// 乐观锁冲突:资源在本次编辑期间被别人改过。 + #[error("冲突: {0}")] + Conflict(String), } impl IntoResponse for StashError { @@ -37,6 +41,7 @@ impl IntoResponse for StashError { let (status, err_msg) = match self { StashError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), StashError::InvalidDirectory(msg) => (StatusCode::BAD_REQUEST, msg), + StashError::Conflict(msg) => (StatusCode::CONFLICT, msg), _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), }; diff --git a/src/server.rs b/src/server.rs index 9501a66..318b3cb 100644 --- a/src/server.rs +++ b/src/server.rs @@ -534,6 +534,53 @@ async fn auth_password( } } +// ---------------- 站点设置 ---------------- + +#[derive(serde::Deserialize)] +struct SettingsPatch { + version: i64, + settings: crate::db::Settings, +} + +/// GET /api/settings —— 读接口公开,前端首屏直接取 +async fn get_settings(State(state): State) -> Response { + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || db.load_settings()).await { + Ok(Ok(document)) => Json(document).into_response(), + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +/// PATCH /api/settings —— 需要登录;`version` 做乐观锁 +async fn patch_settings( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !same_origin(&headers) { + return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝"); + } + + match read_session(&state, &headers).await { + Ok(session) if session.authenticated => {} + Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"), + Err(response) => return response, + } + + let db = Arc::clone(&state.db); + let (settings, version) = (body.settings, body.version); + match tokio::task::spawn_blocking(move || db.save_settings(settings, version)).await { + Ok(Ok(document)) => Json(document).into_response(), + Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message), + Ok(Err(StashError::InvalidDirectory(message))) => { + api_error(StatusCode::BAD_REQUEST, &message) + } + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + /// 组装路由:API + 内嵌前端 + 托管目录。 /// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、 /// 起监听任务,没法在单元测试里用。 @@ -559,6 +606,7 @@ fn build_router(state: AppState) -> Router { .route("/api/auth/login", post(auth_login)) .route("/api/auth/logout", post(auth_logout)) .route("/api/auth/password", patch(auth_password)) + .route("/api/settings", get(get_settings).patch(patch_settings)) .route("/", get(web_index)) .route("/index.html", get(web_index)) .route("/favicon.svg", get(web_root_file)) diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 184e53c..299e76b 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -773,36 +773,80 @@ white-space: nowrap; } -/* ---------- 登录页 ---------- */ +/* ---------- 设置页 ---------- */ -.login-page { - display: grid; - place-items: center; +.admin-page { min-height: 100vh; min-height: 100dvh; - padding: var(--su-space-6); background-color: var(--su-bg-page); } -.login-card { +.admin-topbar { display: flex; - flex-direction: column; - gap: var(--su-space-4); - width: min(380px, 100%); -} - -.login-brand { align-items: center; + justify-content: space-between; + gap: var(--su-space-4); + height: 64px; + padding: 0 var(--su-space-6); + border-bottom: 1px solid var(--su-border); + background-color: var(--su-bg-card); } -.login-back { - font-size: var(--su-font-size-sm); - color: var(--su-text-muted); - text-align: center; +.admin-main { + width: min(880px, calc(100% - 48px)); + margin: 0 auto; + padding: var(--su-space-8) 0 var(--su-space-10); } -.login-back:hover { - color: var(--su-color-primary); +.admin-locked { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--su-space-4); + margin-top: var(--su-space-5); + padding: var(--su-space-4); + border: 1px dashed var(--su-border-strong); + border-radius: var(--su-radius-md); +} + +.admin-form { + margin-top: var(--su-space-5); +} + +.admin-row { + display: flex; + align-items: center; + gap: var(--su-space-3); +} + +.admin-item-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 160px 120px auto; + align-items: center; + gap: var(--su-space-2); +} + +.admin-item-row > :first-child:not(.admin-item-title) { + grid-column: span 1; +} + +.admin-item-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-nav-row { + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.admin-item-actions { + display: flex; + justify-content: flex-end; +} + +.admin-item-row .su-switch { + justify-self: start; } /* ---------- 响应式 ---------- */ diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 61acdba..7b80ef7 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -29,6 +29,13 @@ import { useProjects } from './useProjects' import { useCatalog } from './useCatalog' import { useViewRoute } from './useViewRoute' import { fetchSession, logout as logoutRequest, type SessionState } from './auth' +import { + applySidebarSettings, + defaultSettings, + fetchSettings, + type SettingsDocument, +} from './settings' +import { LoginDialog } from './components/auth/LoginDialog' import { parseViewPath, viewPath, type View } from './data/view' import './App.css' @@ -40,6 +47,11 @@ export default function App() { // 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts) const { view, go } = useViewRoute() const [session, setSession] = useState(null) + const [settings, setSettings] = useState({ + version: 0, + settings: defaultSettings(), + }) + const [loginOpen, setLoginOpen] = useState(false) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) @@ -49,6 +61,9 @@ export default function App() { fetchSession().then((next) => { if (alive) setSession(next) }) + fetchSettings().then((next) => { + if (alive) setSettings(next) + }) return () => { alive = false } @@ -63,13 +78,17 @@ export default function App() { [categories, keyword], ) - // 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区 + // 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区; + // 再按 /admin 里的顺序与显隐设置重排一遍 const sidebarCategories = useMemo( () => - categories.filter( - (category) => category.id !== 'local' && category.showInSidebar !== false, + applySidebarSettings( + categories.filter( + (category) => category.id !== 'local' && category.showInSidebar !== false, + ), + settings.settings.sidebar, ), - [categories], + [categories, settings.settings.sidebar], ) const detailView = view.type === 'detail' ? view : undefined @@ -258,11 +277,24 @@ export default function App() { } const handleLogout = () => { - void logoutRequest().then(() => window.location.assign('/')) + void logoutRequest().then(() => { + setSession({ authenticated: false, has_admin: true }) + }) } return ( <> + {/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */} + setLoginOpen(false)} + onSuccess={(next) => { + setSession(next) + setLoginOpen(false) + }} + /> + {/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}