diff --git a/docs/ADMIN-PLAN.md b/docs/ADMIN-PLAN.md index b2c589b..8254f2f 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -147,6 +147,16 @@ DELETE /api/items/:id ## 11. 实施记录 +### P4(2026-10-29,收尾) + +- 分区增删:`POST /api/categories`、`DELETE /api/categories/{id}`(需登录,级联内容,`local` 不可删),首页与侧栏共用; +- 资料设置:头像 URL / 名称 / 角色 / 简介 / 位置,应用到侧栏资料卡; +- 操作条:进入详情页记一次浏览、点赞可切换(localStorage 记状态)、分享复制链接、跳转外链; +- 交互细节:`[编辑]` 与标题垂直居中并留 12px 间距;设置页操作按钮与内容拉开间距;保存改用 Toast(2.4s 自动消失);设置页 Tabs 不再出现滚动条; +- 登录密码框增加显示/隐藏切换(错误后仍可用); +- 测试:48 passed(新增 categories 2 例、settings 资料校验)。 + + ### P0(已完成,2026-10-29) - 迁移机制:`user_version 15 → 16`,`schema::migrate` 逐级升级;v15 老库启动时自动补 `admin` / `session` / `setting` 三表; diff --git a/src/db/categories.rs b/src/db/categories.rs new file mode 100644 index 0000000..42bff6b --- /dev/null +++ b/src/db/categories.rs @@ -0,0 +1,202 @@ +//! 分区(首页卡片 / 侧栏项)的新增与删除。 +//! +//! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。 +//! 新增时顺手建一条首页展示块,保证它立刻出现在首页。 + +use rusqlite::{Connection, params}; +use serde::Deserialize; + +use crate::{db::map_error, error::StashError, db::entries}; + +const KINDS: [&str; 3] = ["apps", "posters", "articles"]; + +#[derive(Debug, Clone, Deserialize)] +pub struct NewCategory { + /// 业务键,只允许小写字母、数字与连字符 + pub id: String, + pub title: String, + /// apps / posters / articles + pub kind: String, +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 32 + && id + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') +} + +pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> { + let id = new.id.trim(); + let title = new.title.trim(); + + if !valid_id(id) { + return Err(StashError::InvalidDirectory( + "标识只能用小写字母、数字与连字符(≤32)".into(), + )); + } + if title.is_empty() || title.chars().count() > 40 { + return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into())); + } + if !KINDS.contains(&new.kind.as_str()) { + return Err(StashError::InvalidDirectory("类型不合法".into())); + } + + let exists: i64 = conn + .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| row.get(0)) + .map_err(map_error)?; + if exists > 0 { + return Err(StashError::Conflict(format!("分区已存在: {id}"))); + } + + let tx = conn.transaction().map_err(map_error)?; + let sort: i64 = tx + .query_row( + "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE kind = 'section'", + [], + |row| row.get(0), + ) + .map_err(map_error)?; + + tx.execute( + "INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar) + VALUES (?1, NULL, ?2, 'section', ?3, ?4, 1)", + params![id, title, new.kind, sort], + ) + .map_err(map_error)?; + + let block_sort: i64 = tx + .query_row( + "SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", + [], + |row| row.get(0), + ) + .map_err(map_error)?; + tx.execute( + "INSERT INTO section_block (node_key, title, preview_limit, sort) VALUES (?1, NULL, NULL, ?2)", + params![id, block_sort], + ) + .map_err(map_error)?; + + entries::audit(&tx, "category.create", id, title)?; + tx.commit().map_err(map_error)?; + Ok(()) +} + +pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> { + if id == "local" { + return Err(StashError::InvalidDirectory("本地服务不能删除".into())); + } + + let removed = conn + .execute("DELETE FROM node WHERE key = ?1 AND kind = 'section'", [id]) + .map_err(map_error)?; + + if removed == 0 { + return Err(StashError::NotFound(format!("分区不存在: {id}"))); + } + + entries::audit(conn, "category.delete", id, "")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn db() -> Connection { + let conn = Connection::open_in_memory().expect("内存库应能打开"); + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE node ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE, + title TEXT NOT NULL, + kind TEXT NOT NULL, + view_mode TEXT, + sort INTEGER NOT NULL DEFAULT 0, + show_in_sidebar INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE section_block ( + id INTEGER PRIMARY KEY, + node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE, + title TEXT, + preview_limit INTEGER, + sort INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at INTEGER NOT NULL, + action TEXT NOT NULL, + target TEXT, + detail TEXT + );", + ) + .expect("应能建表"); + conn + } + + #[test] + fn create_then_delete() { + let mut conn = db(); + create( + &mut conn, + NewCategory { + id: "books".into(), + title: "书单".into(), + kind: "posters".into(), + }, + ) + .expect("应能创建"); + + let blocks: i64 = conn + .query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) + .expect("应能查"); + assert_eq!(blocks, 1); + + remove(&mut conn, "books").expect("应能删除"); + let nodes: i64 = conn + .query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0)) + .expect("应能查"); + assert_eq!(nodes, 0); + } + + #[test] + fn rejects_bad_id_and_duplicate() { + let mut conn = db(); + assert!( + create( + &mut conn, + NewCategory { + id: "Bad Id".into(), + title: "x".into(), + kind: "apps".into() + } + ) + .is_err() + ); + + create( + &mut conn, + NewCategory { + id: "books".into(), + title: "书单".into(), + kind: "apps".into(), + }, + ) + .expect("应能创建"); + + let error = create( + &mut conn, + NewCategory { + id: "books".into(), + title: "书单".into(), + kind: "apps".into(), + }, + ) + .expect_err("重复应失败"); + assert!(matches!(error, StashError::Conflict(_))); + } +} diff --git a/src/db/entries.rs b/src/db/entries.rs index 9ace88b..4f5c766 100644 --- a/src/db/entries.rs +++ b/src/db/entries.rs @@ -207,6 +207,33 @@ pub fn update( .ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}"))) } +/// 记录一次浏览:views +1,返回 (views, likes)。 +pub fn bump_view(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> { + conn.execute("UPDATE entry SET views = views + 1 WHERE key = ?1", [key]) + .map_err(map_error)?; + counts(conn, key) +} + +/// 点赞 / 取消点赞:likes 加一或减一(不低于 0),返回 (views, likes)。 +pub fn set_like(conn: &Connection, key: &str, liked: bool) -> Result<(i64, i64), StashError> { + let delta: i64 = if liked { 1 } else { -1 }; + conn.execute( + "UPDATE entry SET likes = MAX(0, likes + ?1) WHERE key = ?2", + params![delta, key], + ) + .map_err(map_error)?; + counts(conn, key) +} + +fn counts(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> { + conn.query_row("SELECT views, likes FROM entry WHERE key = ?1", [key], |row| { + Ok((row.get(0)?, row.get(1)?)) + }) + .optional() + .map_err(map_error)? + .ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}"))) +} + /// 记录一条审计(设置保存等)。 pub fn audit(conn: &Connection, action: &str, target: &str, detail: &str) -> Result<(), StashError> { conn.execute( diff --git a/src/db/mod.rs b/src/db/mod.rs index 4e5df82..821efa3 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -9,6 +9,7 @@ //! 启动时会重新建表并灌入内嵌种子。 pub mod auth; +pub mod categories; mod catalog; pub mod entries; mod schema; @@ -193,6 +194,28 @@ impl Db { let mut conn = self.lock()?; entries::update(&mut conn, key, patch, expected_updated_at) } + + pub fn bump_view(&self, key: &str) -> Result<(i64, i64), StashError> { + let conn = self.lock()?; + entries::bump_view(&conn, key) + } + + pub fn set_like(&self, key: &str, liked: bool) -> Result<(i64, i64), StashError> { + let conn = self.lock()?; + entries::set_like(&conn, key, liked) + } + + // ---------- 分区(首页卡片 / 侧栏项) ---------- + + pub fn create_category(&self, new: categories::NewCategory) -> Result<(), StashError> { + let mut conn = self.lock()?; + categories::create(&mut conn, new) + } + + pub fn remove_category(&self, id: &str) -> Result<(), StashError> { + let mut conn = self.lock()?; + categories::remove(&mut conn, id) + } } /// 把 rusqlite 的错误统一收敛成 `StashError::Database`。 diff --git a/src/db/settings.rs b/src/db/settings.rs index d407bf7..9bd0ff0 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -101,12 +101,36 @@ pub struct SectionsSettings { pub limits: std::collections::HashMap, } +#[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, Default)] #[serde(default)] pub struct Settings { pub header: HeaderSettings, pub sidebar: SidebarSettings, pub sections: SectionsSettings, + pub profile: ProfileSettings, } /// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。 @@ -164,6 +188,18 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { 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())); + } Ok(()) } diff --git a/src/server.rs b/src/server.rs index 24ef2b2..47fe824 100644 --- a/src/server.rs +++ b/src/server.rs @@ -687,6 +687,91 @@ async fn backup_db(State(state): State, headers: HeaderMap) -> Respons } } +/// POST /api/items/{key}/view —— 记一次浏览(公开) +async fn post_view(State(state): State, Path(key): Path) -> Response { + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || db.bump_view(&key)).await { + Ok(Ok((views, likes))) => Json(serde_json::json!({ "views": views, "likes": likes })).into_response(), + Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +#[derive(serde::Deserialize)] +struct LikeBody { + liked: bool, +} + +/// POST /api/items/{key}/like —— 点赞 / 取消(公开,前端用 localStorage 记是否已赞) +async fn post_like( + State(state): State, + Path(key): Path, + Json(body): Json, +) -> Response { + let db = Arc::clone(&state.db); + match tokio::task::spawn_blocking(move || db.set_like(&key, body.liked)).await { + Ok(Ok((views, likes))) => Json(serde_json::json!({ "views": views, "likes": likes })).into_response(), + Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message), + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +/// POST /api/categories —— 新建分区(需要登录) +async fn create_category( + 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); + match tokio::task::spawn_blocking(move || db.create_category(body)).await { + Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).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()), + } +} + +/// DELETE /api/categories/{id} —— 删除分区(需要登录,级联内容) +async fn delete_category( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> 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); + match tokio::task::spawn_blocking(move || db.remove_category(&id)).await { + Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(), + Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &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` 要绑定端口、 /// 起监听任务,没法在单元测试里用。 @@ -714,6 +799,10 @@ fn build_router(state: AppState) -> Router { .route("/api/auth/password", patch(auth_password)) .route("/api/settings", get(get_settings).patch(patch_settings)) .route("/api/items/{key}", get(get_item).patch(patch_item)) + .route("/api/items/{key}/view", post(post_view)) + .route("/api/items/{key}/like", post(post_like)) + .route("/api/categories", post(create_category)) + .route("/api/categories/{id}", axum::routing::delete(delete_category)) .route("/api/backup", get(backup_db)) .route("/", get(web_index)) .route("/index.html", get(web_index)) diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 71a2c7b..a912f5f 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -464,8 +464,10 @@ } .item-title { + display: flex; + align-items: center; justify-content: center; - gap: 6px; + gap: var(--su-space-3); font-size: var(--su-font-size-xl); font-weight: var(--su-font-weight-semibold); line-height: 1.5; @@ -904,3 +906,60 @@ transition: none; } } + +/* ---------- 登录密码框 / 条目操作条 / 设置页细节 ---------- */ + +.password-field { + display: flex; + align-items: center; + gap: var(--su-space-2); +} + +.password-field > :first-child { + flex: 1; + min-width: 0; +} + +.item-stat { + display: inline-flex; + align-items: center; + gap: var(--su-space-1); + padding: 0; + border: none; + background: none; + color: var(--su-text-sub); + font: inherit; + cursor: pointer; +} + +.item-stat.is-liked, +.item-stat.is-liked svg { + color: var(--su-color-primary); +} + +.item-title .item-edit { + margin-left: 0; + align-self: center; + line-height: 1; +} + +.admin-actions { + margin-top: var(--su-space-6); +} + +.admin-tabs { + overflow: visible; +} + +.admin-create { + margin-top: var(--su-space-4); + padding-top: var(--su-space-4); + border-top: 1px dashed var(--su-border-strong); +} + +.admin-create-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 120px auto; + gap: var(--su-space-2); + align-items: center; +} diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 5f3c8b9..535dff0 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -1,4 +1,4 @@ -import { Empty, Layout, Notice } from '@stash/ui' +import { Empty, Layout, Notice, Toaster } from '@stash/ui' import { useEffect, useMemo, useRef, useState } from 'react' import { BackToTop } from './components/common/BackToTop' import { ArticleDetailView } from './components/content/ArticleDetailView' @@ -267,6 +267,7 @@ export default function App() { return ( + + {/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */} ([]) const [tab, setTab] = useState('header') const [saving, setSaving] = useState(false) - const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null) const load = async () => { const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()]) @@ -75,25 +80,36 @@ export function AdminPage() { const save = async () => { if (document === null || draft === null) return setSaving(true) - setMessage(null) const result = await saveSettings(document.version, draft) setSaving(false) if (!result.ok) { - setMessage({ tone: 'error', text: result.error }) + toast.error(result.error) return } setDocument(result.document) setDraft(clone(result.document.settings)) - setMessage({ tone: 'info', text: '已保存,回到首页即可看到效果' }) + toast.success('已保存') + } + + const removeCategory = async (card: CategorySummary) => { + if (!window.confirm(`删除「${card.title}」会同时删除它下面的所有条目,确定继续?`)) return + const result = await deleteCategory(card.id) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success(`已删除「${card.title}」`) + await load() } const authenticated = session?.authenticated === true return (
+
) })} {ordered.length === 0 && 内容库还没有加载完成。} + + + 新增或删除分类会同时影响首页卡片与侧栏。 + + ) } @@ -384,10 +413,14 @@ function SectionForm({ categories, value, onChange, + onDelete, + onReload, }: { categories: CategorySummary[] value: SectionSettings onChange: (sections: SectionSettings) => void + onDelete: (card: CategorySummary) => void + onReload: () => void }) { const cards = useMemo(() => { const base = [ @@ -470,12 +503,136 @@ function SectionForm({ move(index, -1)} onDown={() => move(index, 1)} - onRemove={undefined} + onRemove={card.id === 'local' ? undefined : () => onDelete(card)} /> ))} {cards.length === 0 && 内容库还没有加载完成。} + + + + ) +} + +// ---------- 新增分类 ---------- + +function CategoryCreator({ onCreated }: { onCreated: () => void }) { + const [id, setId] = useState('') + const [title, setTitle] = useState('') + const [kind, setKind] = useState<'apps' | 'posters' | 'articles'>('posters') + const [busy, setBusy] = useState(false) + + const submit = async () => { + setBusy(true) + const result = await createCategory({ id: id.trim(), title: title.trim(), kind }) + setBusy(false) + + if (!result.ok) { + toast.error(result.error) + return + } + + toast.success('分类已创建') + setId('') + setTitle('') + onCreated() + } + + return ( + + + 新增分类(会同时出现在首页卡片与侧栏)。标识只允许小写字母、数字与连字符。 + +
+ setId(event.target.value)} + /> + setTitle(event.target.value)} + /> + update({ avatar: event.target.value })} + /> + + + + update({ name: event.target.value })} + /> + + + + update({ role: event.target.value })} + /> + + + +