feat(admin): 分区增删、资料设置、操作条与交互收尾
- POST/DELETE /api/categories(需登录,级联,local 保护) - profile 设置(头像/名称/角色/简介/位置)应用到资料卡 - 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链 - 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条 - 登录密码可见切换 - 测试 48 passed
This commit is contained in:
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -101,12 +101,36 @@ pub struct SectionsSettings {
|
||||
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, 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -687,6 +687,91 @@ async fn backup_db(State(state): State<AppState>, headers: HeaderMap) -> Respons
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/items/{key}/view —— 记一次浏览(公开)
|
||||
async fn post_view(State(state): State<AppState>, Path(key): Path<String>) -> 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<AppState>,
|
||||
Path(key): Path<String>,
|
||||
Json(body): Json<LikeBody>,
|
||||
) -> 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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::categories::NewCategory>,
|
||||
) -> 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user