feat(edit): P2 条目就地编辑 + P3 备份/审计/改密
- 迁移 v17:entry.updated_at(乐观锁)+ audit 表
- GET/PATCH /api/items/{key}:字段编辑、标签替换、409 冲突、400 校验
- 详情页 [编辑] → Drawer 表单(标题/简介/标签/评价/正文/封面/跳转),保存后局部刷新
- GET /api/backup:VACUUM INTO 一致性快照,附件下载
- /admin 系统 Tab:修改密码(清空会话)、下载备份
- 审计:item.update / settings.update
- 测试 46 passed(entries 4 例)
- 文档:ADMIN-PLAN P2/P3 记录、README
This commit is contained in:
@@ -35,6 +35,8 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
|
||||
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
||||
- 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐;
|
||||
- 详情页的 `[编辑]` 打开右侧 Drawer,可改标题 / 简介 / 标签 / 正文等(保存后局部刷新,`updated_at` 乐观锁);
|
||||
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
||||
- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
|
||||
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
|
||||
|
||||
@@ -167,4 +167,22 @@ DELETE /api/items/:id
|
||||
- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染;
|
||||
- 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。
|
||||
|
||||
### P2(已完成,2026-10-29)
|
||||
|
||||
- 库新增 `Textarea`(stash-ui v0.2.0):`rows` / `resize` / `invalid`;
|
||||
- 条目编辑:`GET /api/items/{key}`(公开读)、`PATCH /api/items/{key}`(需登录);
|
||||
- 乐观锁:`entry.updated_at`(迁移 v17),冲突返回 409;
|
||||
- 可编辑字段:标题、简介、标签、一句话评价、正文 Markdown、封面、跳转地址;
|
||||
- 前端:详情页 `[编辑]` → `Drawer` 表单;保存后 `catalog.reload()` 局部刷新,不整页刷新;
|
||||
- 审计:每次条目更新写入 `audit` 表;
|
||||
- 测试:46 passed(新增 entries 4 例:更新/标签替换、冲突、空标题、不存在)。
|
||||
|
||||
### P3(部分完成,2026-10-29)
|
||||
|
||||
- 审计表 `audit`:记录 `item.update`、`settings.update`;
|
||||
- 备份:`GET /api/backup`(需登录)用 `VACUUM INTO` 生成一致性副本并以附件下载;
|
||||
- 系统设置 Tab:修改密码(成功后所有会话失效)、下载备份;
|
||||
- **未做**:恢复/导入(需要停机替换文件或在线校验导入流程)、回收站/软删除、图片上传、拖拽排序(当前用上移/下移按钮替代)。
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! 条目的读取与编辑(P2)。
|
||||
//!
|
||||
//! 编辑用 `updated_at` 做乐观锁:带上打开表单时的时间戳,
|
||||
//! 别人已经改过就返回冲突,而不是静默覆盖。
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{db::map_error, error::StashError};
|
||||
|
||||
const MAX_NAME: usize = 120;
|
||||
const MAX_BODY: usize = 100_000;
|
||||
const MAX_TAGS: usize = 30;
|
||||
const MAX_TAG_LEN: usize = 40;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct EntryRecord {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
pub href: String,
|
||||
pub cover: String,
|
||||
pub banner: String,
|
||||
pub badge: String,
|
||||
pub comment: String,
|
||||
pub summary: String,
|
||||
pub body: String,
|
||||
pub tags: Vec<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct EntryPatch {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub icon: String,
|
||||
#[serde(default)]
|
||||
pub href: String,
|
||||
#[serde(default)]
|
||||
pub cover: String,
|
||||
#[serde(default)]
|
||||
pub banner: String,
|
||||
#[serde(default)]
|
||||
pub badge: String,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
#[serde(default)]
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
|
||||
if patch.name.trim().is_empty() {
|
||||
return Err(StashError::InvalidDirectory("标题不能为空".into()));
|
||||
}
|
||||
if patch.name.chars().count() > MAX_NAME {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"标题最多 {MAX_NAME} 个字"
|
||||
)));
|
||||
}
|
||||
if patch.body.chars().count() > MAX_BODY {
|
||||
return Err(StashError::InvalidDirectory("正文过长".into()));
|
||||
}
|
||||
if patch.tags.len() > MAX_TAGS {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"标签最多 {MAX_TAGS} 个"
|
||||
)));
|
||||
}
|
||||
for tag in &patch.tags {
|
||||
if tag.chars().count() > MAX_TAG_LEN {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"单个标签最多 {MAX_TAG_LEN} 个字"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读一条条目(编辑表单用),不存在返回 None。
|
||||
pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashError> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT key, name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
|
||||
COALESCE(cover, ''), COALESCE(banner, ''), COALESCE(badge, ''),
|
||||
COALESCE(comment, ''), COALESCE(summary, ''), COALESCE(body, ''), updated_at
|
||||
FROM entry WHERE key = ?1",
|
||||
[key],
|
||||
|row| {
|
||||
Ok(EntryRecord {
|
||||
key: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
icon: row.get(3)?,
|
||||
href: row.get(4)?,
|
||||
cover: row.get(5)?,
|
||||
banner: row.get(6)?,
|
||||
badge: row.get(7)?,
|
||||
comment: row.get(8)?,
|
||||
summary: row.get(9)?,
|
||||
body: row.get(10)?,
|
||||
tags: Vec::new(),
|
||||
updated_at: row.get(11)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some(mut entry) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut statement = conn
|
||||
.prepare("SELECT label FROM entry_tag WHERE entry_key = ?1 ORDER BY sort")
|
||||
.map_err(map_error)?;
|
||||
entry.tags = statement
|
||||
.query_map([key], |row| row.get::<_, String>(0))
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
|
||||
pub fn update(
|
||||
conn: &mut Connection,
|
||||
key: &str,
|
||||
patch: EntryPatch,
|
||||
expected_updated_at: i64,
|
||||
) -> Result<EntryRecord, StashError> {
|
||||
validate(&patch)?;
|
||||
|
||||
let tags: Vec<String> = patch
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.trim().to_string())
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.collect();
|
||||
|
||||
let timestamp = now();
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
|
||||
let updated = tx
|
||||
.execute(
|
||||
"UPDATE entry SET
|
||||
name = ?1, description = ?2, icon = ?3, href = ?4,
|
||||
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
|
||||
summary = ?9, body = ?10, updated_at = ?11
|
||||
WHERE key = ?12 AND updated_at = ?13",
|
||||
params![
|
||||
patch.name.trim(),
|
||||
patch.description,
|
||||
patch.icon,
|
||||
patch.href,
|
||||
patch.cover,
|
||||
patch.banner,
|
||||
patch.badge,
|
||||
patch.comment,
|
||||
patch.summary,
|
||||
patch.body,
|
||||
timestamp,
|
||||
key,
|
||||
expected_updated_at
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
if updated == 0 {
|
||||
return Err(StashError::Conflict(
|
||||
"条目已被修改,请刷新后重试".into(),
|
||||
));
|
||||
}
|
||||
|
||||
tx.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
|
||||
.map_err(map_error)?;
|
||||
for (index, tag) in tags.iter().enumerate() {
|
||||
tx.execute(
|
||||
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
|
||||
params![key, index as i64, tag],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.update', ?2, ?3)",
|
||||
params![timestamp, key, patch.name.trim()],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
tx.commit().map_err(map_error)?;
|
||||
|
||||
load(conn, key)?
|
||||
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
|
||||
}
|
||||
|
||||
/// 记录一条审计(设置保存等)。
|
||||
pub fn audit(conn: &Connection, action: &str, target: &str, detail: &str) -> Result<(), StashError> {
|
||||
conn.execute(
|
||||
"INSERT INTO audit (at, action, target, detail) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![now(), action, target, detail],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE entry (
|
||||
key TEXT PRIMARY KEY,
|
||||
node_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT, icon TEXT, href TEXT, cover TEXT, banner TEXT,
|
||||
badge TEXT, comment TEXT, summary TEXT, body TEXT,
|
||||
hot INTEGER NOT NULL DEFAULT 0, views INTEGER NOT NULL DEFAULT 0,
|
||||
likes INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE entry_tag (
|
||||
entry_key TEXT NOT NULL,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT NOT NULL,
|
||||
PRIMARY KEY (entry_key, sort)
|
||||
);
|
||||
CREATE TABLE audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);",
|
||||
)
|
||||
.expect("应能建表");
|
||||
conn
|
||||
}
|
||||
|
||||
fn patch(name: &str) -> EntryPatch {
|
||||
EntryPatch {
|
||||
name: name.into(),
|
||||
description: "简介".into(),
|
||||
icon: String::new(),
|
||||
href: "https://example.com".into(),
|
||||
cover: String::new(),
|
||||
banner: String::new(),
|
||||
badge: "推荐".into(),
|
||||
comment: "一句话".into(),
|
||||
summary: "正文".into(),
|
||||
body: "## 标题".into(),
|
||||
tags: vec!["推荐电影".into(), "动画".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_replaces_fields_and_tags() {
|
||||
let mut conn = db();
|
||||
let saved = update(&mut conn, "movie-1", patch("千与千寻 2"), 0).expect("应能更新");
|
||||
assert_eq!(saved.name, "千与千寻 2");
|
||||
assert_eq!(saved.tags, vec!["推荐电影".to_string(), "动画".to_string()]);
|
||||
assert!(saved.updated_at > 0);
|
||||
|
||||
let audit: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM audit", [], |row| row.get(0))
|
||||
.expect("应能查审计");
|
||||
assert_eq!(audit, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_timestamp_conflicts() {
|
||||
let mut conn = db();
|
||||
let saved = update(&mut conn, "movie-1", patch("A"), 0).expect("应能更新");
|
||||
let error = update(&mut conn, "movie-1", patch("B"), saved.updated_at - 1)
|
||||
.expect_err("应冲突");
|
||||
assert!(matches!(error, StashError::Conflict(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_name() {
|
||||
let mut conn = db();
|
||||
assert!(update(&mut conn, "movie-1", patch(" "), 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_entry_is_not_found() {
|
||||
let mut conn = db();
|
||||
let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在");
|
||||
assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_)));
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod auth;
|
||||
mod catalog;
|
||||
pub mod entries;
|
||||
mod schema;
|
||||
pub mod settings;
|
||||
|
||||
@@ -27,6 +28,8 @@ pub use schema::SeedReport;
|
||||
pub use auth::SessionState;
|
||||
/// 站点设置:页头滚动信息 + 侧栏顺序/显隐。
|
||||
pub use settings::{Settings, SettingsDocument};
|
||||
/// 条目编辑(P2)。
|
||||
pub use entries::{EntryPatch, EntryRecord};
|
||||
|
||||
/// 一次查询得到的完整内容目录。
|
||||
///
|
||||
@@ -169,7 +172,26 @@ impl Db {
|
||||
expected_version: i64,
|
||||
) -> Result<SettingsDocument, StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
settings::save(&mut conn, next, expected_version)
|
||||
let document = settings::save(&mut conn, next, expected_version)?;
|
||||
entries::audit(&conn, "settings.update", "site", "站点设置")?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
// ---------- 条目编辑 ----------
|
||||
|
||||
pub fn load_entry(&self, key: &str) -> Result<Option<EntryRecord>, StashError> {
|
||||
let conn = self.lock()?;
|
||||
entries::load(&conn, key)
|
||||
}
|
||||
|
||||
pub fn update_entry(
|
||||
&self,
|
||||
key: &str,
|
||||
patch: EntryPatch,
|
||||
expected_updated_at: i64,
|
||||
) -> Result<EntryRecord, StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
entries::update(&mut conn, key, patch, expected_updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +540,7 @@ mod tests {
|
||||
fresh
|
||||
.execute_batch(
|
||||
"CREATE TABLE node (key TEXT PRIMARY KEY);
|
||||
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
|
||||
PRAGMA user_version = 15;",
|
||||
)
|
||||
.expect("应能造出 v15 老库");
|
||||
|
||||
+32
-6
@@ -75,7 +75,8 @@ CREATE TABLE IF NOT EXISTS entry (
|
||||
hot INTEGER NOT NULL DEFAULT 0 CHECK (hot IN (0, 1)),-- 是否热门:列表里显示 HOT 徽标
|
||||
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
|
||||
likes INTEGER NOT NULL DEFAULT 0, -- 点赞数,详情页操作条「赞」后的数字
|
||||
sort INTEGER NOT NULL DEFAULT 0 -- 条目顺序(组内或分区内),越小越靠前
|
||||
sort INTEGER NOT NULL DEFAULT 0, -- 条目顺序(组内或分区内),越小越靠前
|
||||
updated_at INTEGER NOT NULL DEFAULT 0 -- 最近一次编辑时间(unix 秒),0 = 从未编辑
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
|
||||
@@ -128,6 +129,29 @@ CREATE TABLE IF NOT EXISTS setting (
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- ========== v17:审计 ==========
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
"#;
|
||||
|
||||
/// v16 → v17:条目加 `updated_at`(乐观锁),新增审计表。
|
||||
const V17_SQL: &str = r#"
|
||||
ALTER TABLE entry ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
"#;
|
||||
|
||||
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
|
||||
@@ -171,7 +195,7 @@ pub struct SeedReport {
|
||||
///
|
||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||
pub const SCHEMA_VERSION: i64 = 16;
|
||||
pub const SCHEMA_VERSION: i64 = 17;
|
||||
|
||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
@@ -180,10 +204,7 @@ fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
|
||||
/// 把库升到 `SCHEMA_VERSION`,返回升级后的版本。
|
||||
///
|
||||
/// - 0:全新库,建全量结构;
|
||||
/// - 15:补 v16 的登录/设置表;
|
||||
/// - 16:已是最新;
|
||||
/// - 其它:拒绝启动(不猜结构)。
|
||||
/// 逐级迁移:0 建全量;15 → 16 → 17;已最新直接返回;更高版本拒绝。
|
||||
pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||||
let mut version = current_version(conn)?;
|
||||
|
||||
@@ -213,6 +234,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||||
version = 16;
|
||||
}
|
||||
|
||||
if version == 16 {
|
||||
conn.execute_batch(V17_SQL).map_err(map_error)?;
|
||||
version = 17;
|
||||
}
|
||||
|
||||
conn.pragma_update(None, "user_version", version)
|
||||
.map_err(map_error)?;
|
||||
|
||||
|
||||
+108
@@ -581,6 +581,112 @@ async fn patch_settings(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 条目编辑 / 备份 ----------------
|
||||
|
||||
/// GET /api/items/{key} —— 编辑表单的初始值(公开,便于未登录时预览)
|
||||
async fn get_item(State(state): State<AppState>, Path(key): Path<String>) -> Response {
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.load_entry(&key)).await {
|
||||
Ok(Ok(Some(entry))) => Json(entry).into_response(),
|
||||
Ok(Ok(None)) => api_error(StatusCode::NOT_FOUND, "条目不存在"),
|
||||
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 ItemPatchBody {
|
||||
#[serde(rename = "updatedAt")]
|
||||
updated_at: i64,
|
||||
item: crate::db::EntryPatch,
|
||||
}
|
||||
|
||||
/// PATCH /api/items/{key} —— 需要登录,`updatedAt` 乐观锁
|
||||
async fn patch_item(
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<ItemPatchBody>,
|
||||
) -> 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 (patch, updated_at) = (body.item, body.updated_at);
|
||||
match tokio::task::spawn_blocking(move || db.update_entry(&key, patch, updated_at)).await {
|
||||
Ok(Ok(entry)) => Json(entry).into_response(),
|
||||
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/backup —— 下载整库快照(需要登录)。
|
||||
///
|
||||
/// 用 SQLite 的 `VACUUM INTO` 生成一致性副本,避免直接复制正在写的文件。
|
||||
async fn backup_db(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
match read_session(&state, &headers).await {
|
||||
Ok(session) if session.authenticated => {}
|
||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
||||
Err(response) => return response,
|
||||
}
|
||||
|
||||
let db_path = state.config.db_path.clone();
|
||||
let created = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let target = db_path.with_file_name(format!(
|
||||
"stash-backup-{}.db",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
|
||||
let conn = rusqlite::Connection::open(&db_path).map_err(|error| error.to_string())?;
|
||||
conn.execute("VACUUM INTO ?1", [target.to_string_lossy().as_ref()])
|
||||
.map_err(|error| error.to_string())?;
|
||||
let bytes = std::fs::read(&target).map_err(|error| error.to_string())?;
|
||||
let _ = std::fs::remove_file(&target);
|
||||
Ok(bytes)
|
||||
})
|
||||
.await;
|
||||
|
||||
match created {
|
||||
Ok(Ok(bytes)) => {
|
||||
let filename = format!(
|
||||
"stash-backup-{}.db",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/octet-stream".to_string()),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装路由:API + 内嵌前端 + 托管目录。
|
||||
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
||||
/// 起监听任务,没法在单元测试里用。
|
||||
@@ -607,6 +713,8 @@ fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/logout", post(auth_logout))
|
||||
.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/backup", get(backup_db))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
.route("/favicon.svg", get(web_root_file))
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type SettingsDocument,
|
||||
} from './settings'
|
||||
import { LoginDialog } from './components/auth/LoginDialog'
|
||||
import { ItemEditor } from './components/content/ItemEditor'
|
||||
import { parseViewPath, viewPath, type View } from './data/view'
|
||||
import './App.css'
|
||||
|
||||
@@ -52,6 +53,7 @@ export default function App() {
|
||||
settings: defaultSettings(),
|
||||
})
|
||||
const [loginOpen, setLoginOpen] = useState(false)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -253,6 +255,7 @@ export default function App() {
|
||||
categoryId={itemCategory.id}
|
||||
categoryTitle={itemCategory.title}
|
||||
onNavigate={navigateHref}
|
||||
onEdit={() => setEditingKey(itemView.itemId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -272,6 +275,7 @@ export default function App() {
|
||||
related={relatedItems}
|
||||
onNavigate={navigateHref}
|
||||
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
||||
onEdit={() => setEditingKey(itemView.itemId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -295,6 +299,13 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ItemEditor
|
||||
open={editingKey !== null}
|
||||
itemKey={editingKey}
|
||||
onClose={() => setEditingKey(null)}
|
||||
onSaved={() => catalog.reload()}
|
||||
/>
|
||||
|
||||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||||
<div className="site-background" aria-hidden="true" />
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Title,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { fetchSession, logout, type SessionState } from '../../auth'
|
||||
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
|
||||
import { LoginDialog } from '../auth/LoginDialog'
|
||||
import {
|
||||
fetchSettings,
|
||||
@@ -138,6 +138,7 @@ export function AdminPage() {
|
||||
items={[
|
||||
{ key: 'header', label: '页头滚动信息' },
|
||||
{ key: 'sidebar', label: '侧栏导航' },
|
||||
{ key: 'system', label: '系统' },
|
||||
]}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
@@ -154,12 +155,14 @@ export function AdminPage() {
|
||||
value={draft.header}
|
||||
onChange={(header) => setDraft({ ...draft, header })}
|
||||
/>
|
||||
) : (
|
||||
) : tab === 'sidebar' ? (
|
||||
<SidebarForm
|
||||
categories={categories}
|
||||
value={draft.sidebar}
|
||||
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
|
||||
/>
|
||||
) : (
|
||||
<SystemPanel />
|
||||
)}
|
||||
|
||||
<Space gap="sm" justify="end">
|
||||
@@ -362,8 +365,75 @@ function SidebarForm({
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 行内按钮组 ----------
|
||||
// ---------- 系统 ----------
|
||||
|
||||
function SystemPanel() {
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
|
||||
|
||||
const change = async () => {
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
const result = await changePassword(current, next)
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
setMessage({ tone: 'error', text: result.error })
|
||||
return
|
||||
}
|
||||
setMessage({ tone: 'info', text: '密码已修改,所有会话已失效,请重新登录' })
|
||||
setCurrent('')
|
||||
setNext('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||
<FormField label="当前密码" htmlFor="current-password">
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(event) => setCurrent(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="新密码" htmlFor="next-password" hint="至少 8 位">
|
||||
<Input
|
||||
id="next-password"
|
||||
type="password"
|
||||
value={next}
|
||||
onChange={(event) => setNext(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{message !== null && (
|
||||
<Notice tone={message.tone} title={message.tone === 'info' ? '已修改' : '修改失败'}>
|
||||
{message.text}
|
||||
</Notice>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={busy}
|
||||
icon={<Icon name="key_2_line" />}
|
||||
onClick={change}
|
||||
>
|
||||
修改密码
|
||||
</Button>
|
||||
|
||||
<Text size="sm" tone="muted">
|
||||
备份:下载一份 SQLite 快照(VACUUM INTO 生成,一致性副本)。恢复时停掉服务,用它替换
|
||||
data/stash.db 再启动。
|
||||
</Text>
|
||||
<Button
|
||||
icon={<Icon name="download_line" />}
|
||||
onClick={() => window.location.assign('/api/backup')}
|
||||
>
|
||||
下载备份
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 行内按钮组 ----------
|
||||
function IconButtonGroup({
|
||||
onUp,
|
||||
onDown,
|
||||
|
||||
@@ -15,6 +15,7 @@ type ArticleDetailViewProps = {
|
||||
categoryId: string
|
||||
categoryTitle: string
|
||||
onNavigate: (href: string) => void
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
export function ArticleDetailView({
|
||||
@@ -22,6 +23,7 @@ export function ArticleDetailView({
|
||||
categoryId,
|
||||
categoryTitle,
|
||||
onNavigate,
|
||||
onEdit,
|
||||
}: ArticleDetailViewProps) {
|
||||
const meta = article.meta ?? ['2 年前', categoryTitle]
|
||||
const crumbs = [
|
||||
@@ -39,7 +41,7 @@ export function ArticleDetailView({
|
||||
<header className="article-head">
|
||||
<Title level={1} as="h2" className="article-heading">
|
||||
{article.title}
|
||||
<button type="button" className="item-edit" disabled title="编辑功能待接入">
|
||||
<button type="button" className="item-edit" title="编辑文章" onClick={onEdit}>
|
||||
[编辑]
|
||||
</button>
|
||||
</Title>
|
||||
|
||||
@@ -27,6 +27,8 @@ type ItemDetailViewProps = {
|
||||
related: RelatedItem[]
|
||||
onNavigate: (href: string) => void
|
||||
onOpenItem: (itemId: string) => void
|
||||
/** 打开编辑 Drawer */
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
export function ItemDetailView({
|
||||
@@ -35,6 +37,7 @@ export function ItemDetailView({
|
||||
related,
|
||||
onNavigate,
|
||||
onOpenItem,
|
||||
onEdit,
|
||||
}: ItemDetailViewProps) {
|
||||
return (
|
||||
<div className="detail-view item-view">
|
||||
@@ -45,7 +48,7 @@ export function ItemDetailView({
|
||||
<Card padding="none" className="item-card">
|
||||
<Title level={1} as="h2" weight="semibold" className="item-title">
|
||||
{model.title}
|
||||
<button type="button" className="item-edit" disabled title="编辑功能待接入">
|
||||
<button type="button" className="item-edit" title="编辑条目" onClick={onEdit}>
|
||||
[编辑]
|
||||
</button>
|
||||
</Title>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Button, Drawer, FormField, Icon, Input, Notice, Space, Textarea } from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchItem, saveItem, type EntryRecord } from '../../items'
|
||||
|
||||
/**
|
||||
* 条目编辑器(P2):右侧 Drawer 表单,就地改标题 / 简介 / 标签 / 正文等。
|
||||
*
|
||||
* `updatedAt` 做乐观锁:别人先改过就提示刷新,避免静默覆盖。
|
||||
*/
|
||||
type ItemEditorProps = {
|
||||
open: boolean
|
||||
itemKey: string | null
|
||||
onClose: () => void
|
||||
onSaved: (entry: EntryRecord) => void
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
description: string
|
||||
tags: string
|
||||
badge: string
|
||||
comment: string
|
||||
summary: string
|
||||
body: string
|
||||
cover: string
|
||||
banner: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
badge: '',
|
||||
comment: '',
|
||||
summary: '',
|
||||
body: '',
|
||||
cover: '',
|
||||
banner: '',
|
||||
href: '',
|
||||
}
|
||||
|
||||
export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps) {
|
||||
const [entry, setEntry] = useState<EntryRecord | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || itemKey === null) return
|
||||
|
||||
let alive = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
fetchItem(itemKey).then((result) => {
|
||||
if (!alive) return
|
||||
setLoading(false)
|
||||
if (!result.ok) {
|
||||
setError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
const next = result.entry
|
||||
setEntry(next)
|
||||
setForm({
|
||||
name: next.name,
|
||||
description: next.description,
|
||||
tags: next.tags.join(','),
|
||||
badge: next.badge,
|
||||
comment: next.comment,
|
||||
summary: next.summary,
|
||||
body: next.body,
|
||||
cover: next.cover,
|
||||
banner: next.banner,
|
||||
href: next.href,
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [open, itemKey])
|
||||
|
||||
const update = (patch: Partial<FormState>) => setForm((previous) => ({ ...previous, ...patch }))
|
||||
|
||||
const save = async () => {
|
||||
if (entry === null) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
|
||||
const result = await saveItem(entry.key, entry.updated_at, {
|
||||
...form,
|
||||
tags: form.tags
|
||||
.split(/[,,]/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
|
||||
setSaving(false)
|
||||
if (!result.ok) {
|
||||
setError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
onSaved(result.entry)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={520}
|
||||
title="编辑条目"
|
||||
footer={
|
||||
<Space gap="sm" justify="end">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
loading={saving}
|
||||
disabled={loading}
|
||||
icon={<Icon name="check_line" />}
|
||||
onClick={save}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{loading && <p>正在读取…</p>}
|
||||
{error !== '' && (
|
||||
<Notice tone="error" title="保存失败">
|
||||
{error}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{!loading && entry !== null && (
|
||||
<Space direction="column" gap="md" align="stretch">
|
||||
<FormField label="标题" htmlFor="edit-name" required>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={form.name}
|
||||
onChange={(event) => update({ name: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="简介" htmlFor="edit-description" hint="列表卡片上的一行说明">
|
||||
<Input
|
||||
id="edit-description"
|
||||
value={form.description}
|
||||
onChange={(event) => update({ description: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="标签" htmlFor="edit-tags" hint="用逗号分隔,例如:推荐电影,动画">
|
||||
<Input
|
||||
id="edit-tags"
|
||||
value={form.tags}
|
||||
onChange={(event) => update({ tags: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="一句话评价" htmlFor="edit-comment">
|
||||
<Input
|
||||
id="edit-comment"
|
||||
value={form.comment}
|
||||
onChange={(event) => update({ comment: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="正文" htmlFor="edit-body" hint="支持 Markdown:##、段落、链接、图片">
|
||||
<Textarea
|
||||
id="edit-body"
|
||||
rows={8}
|
||||
value={form.body}
|
||||
onChange={(event) => update({ body: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="封面地址" htmlFor="edit-cover">
|
||||
<Input
|
||||
id="edit-cover"
|
||||
value={form.cover}
|
||||
placeholder="https://…"
|
||||
onChange={(event) => update({ cover: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="跳转地址" htmlFor="edit-href">
|
||||
<Input
|
||||
id="edit-href"
|
||||
value={form.href}
|
||||
placeholder="https://…"
|
||||
onChange={(event) => update({ href: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 条目编辑的前端接口。
|
||||
*
|
||||
* 读取是公开的(编辑表单要能预览),写入必须登录,并用 `updatedAt` 做乐观锁。
|
||||
*/
|
||||
export type EntryRecord = {
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
href: string
|
||||
cover: string
|
||||
banner: string
|
||||
badge: string
|
||||
comment: string
|
||||
summary: string
|
||||
body: string
|
||||
tags: string[]
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type EntryInput = {
|
||||
name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
badge: string
|
||||
comment: string
|
||||
summary: string
|
||||
body: string
|
||||
cover: string
|
||||
banner: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export type ItemResult =
|
||||
| { ok: true; entry: EntryRecord }
|
||||
| { ok: false; status: number; error: string }
|
||||
|
||||
export async function fetchItem(key: string): Promise<ItemResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (response.ok) {
|
||||
return { ok: true, entry: (await response.json()) as EntryRecord }
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return { ok: false, status: response.status, error: payload?.error ?? '读取失败' }
|
||||
} catch (error) {
|
||||
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveItem(
|
||||
key: string,
|
||||
updatedAt: number,
|
||||
item: EntryInput,
|
||||
): Promise<ItemResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ updatedAt, item }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, entry: (await response.json()) as EntryRecord }
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: payload?.error ?? `保存失败(${response.status})`,
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user