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 位);
|
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
||||||
- 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐;
|
- 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐;
|
||||||
|
- 详情页的 `[编辑]` 打开右侧 Drawer,可改标题 / 简介 / 标签 / 正文等(保存后局部刷新,`updated_at` 乐观锁);
|
||||||
|
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||||
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
||||||
- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
|
- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
|
||||||
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
|
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
|
||||||
|
|||||||
@@ -167,4 +167,22 @@ DELETE /api/items/:id
|
|||||||
- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染;
|
- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染;
|
||||||
- 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。
|
- 测试: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;
|
pub mod auth;
|
||||||
mod catalog;
|
mod catalog;
|
||||||
|
pub mod entries;
|
||||||
mod schema;
|
mod schema;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ pub use schema::SeedReport;
|
|||||||
pub use auth::SessionState;
|
pub use auth::SessionState;
|
||||||
/// 站点设置:页头滚动信息 + 侧栏顺序/显隐。
|
/// 站点设置:页头滚动信息 + 侧栏顺序/显隐。
|
||||||
pub use settings::{Settings, SettingsDocument};
|
pub use settings::{Settings, SettingsDocument};
|
||||||
|
/// 条目编辑(P2)。
|
||||||
|
pub use entries::{EntryPatch, EntryRecord};
|
||||||
|
|
||||||
/// 一次查询得到的完整内容目录。
|
/// 一次查询得到的完整内容目录。
|
||||||
///
|
///
|
||||||
@@ -169,7 +172,26 @@ impl Db {
|
|||||||
expected_version: i64,
|
expected_version: i64,
|
||||||
) -> Result<SettingsDocument, StashError> {
|
) -> Result<SettingsDocument, StashError> {
|
||||||
let mut conn = self.lock()?;
|
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
|
fresh
|
||||||
.execute_batch(
|
.execute_batch(
|
||||||
"CREATE TABLE node (key TEXT PRIMARY KEY);
|
"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;",
|
PRAGMA user_version = 15;",
|
||||||
)
|
)
|
||||||
.expect("应能造出 v15 老库");
|
.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 徽标
|
hot INTEGER NOT NULL DEFAULT 0 CHECK (hot IN (0, 1)),-- 是否热门:列表里显示 HOT 徽标
|
||||||
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
|
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
|
||||||
likes 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);
|
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,
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
updated_at INTEGER NOT NULL
|
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)。
|
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
|
||||||
@@ -171,7 +195,7 @@ pub struct SeedReport {
|
|||||||
///
|
///
|
||||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||||
pub const SCHEMA_VERSION: i64 = 16;
|
pub const SCHEMA_VERSION: i64 = 17;
|
||||||
|
|
||||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
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`,返回升级后的版本。
|
/// 把库升到 `SCHEMA_VERSION`,返回升级后的版本。
|
||||||
///
|
///
|
||||||
/// - 0:全新库,建全量结构;
|
/// 逐级迁移:0 建全量;15 → 16 → 17;已最新直接返回;更高版本拒绝。
|
||||||
/// - 15:补 v16 的登录/设置表;
|
|
||||||
/// - 16:已是最新;
|
|
||||||
/// - 其它:拒绝启动(不猜结构)。
|
|
||||||
pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||||||
let mut version = current_version(conn)?;
|
let mut version = current_version(conn)?;
|
||||||
|
|
||||||
@@ -213,6 +234,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
|||||||
version = 16;
|
version = 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if version == 16 {
|
||||||
|
conn.execute_batch(V17_SQL).map_err(map_error)?;
|
||||||
|
version = 17;
|
||||||
|
}
|
||||||
|
|
||||||
conn.pragma_update(None, "user_version", version)
|
conn.pragma_update(None, "user_version", version)
|
||||||
.map_err(map_error)?;
|
.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 + 内嵌前端 + 托管目录。
|
/// 组装路由:API + 内嵌前端 + 托管目录。
|
||||||
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
||||||
/// 起监听任务,没法在单元测试里用。
|
/// 起监听任务,没法在单元测试里用。
|
||||||
@@ -607,6 +713,8 @@ fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/auth/logout", post(auth_logout))
|
.route("/api/auth/logout", post(auth_logout))
|
||||||
.route("/api/auth/password", patch(auth_password))
|
.route("/api/auth/password", patch(auth_password))
|
||||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
.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("/", get(web_index))
|
||||||
.route("/index.html", get(web_index))
|
.route("/index.html", get(web_index))
|
||||||
.route("/favicon.svg", get(web_root_file))
|
.route("/favicon.svg", get(web_root_file))
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
type SettingsDocument,
|
type SettingsDocument,
|
||||||
} from './settings'
|
} from './settings'
|
||||||
import { LoginDialog } from './components/auth/LoginDialog'
|
import { LoginDialog } from './components/auth/LoginDialog'
|
||||||
|
import { ItemEditor } from './components/content/ItemEditor'
|
||||||
import { parseViewPath, viewPath, type View } from './data/view'
|
import { parseViewPath, viewPath, type View } from './data/view'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export default function App() {
|
|||||||
settings: defaultSettings(),
|
settings: defaultSettings(),
|
||||||
})
|
})
|
||||||
const [loginOpen, setLoginOpen] = useState(false)
|
const [loginOpen, setLoginOpen] = useState(false)
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -253,6 +255,7 @@ export default function App() {
|
|||||||
categoryId={itemCategory.id}
|
categoryId={itemCategory.id}
|
||||||
categoryTitle={itemCategory.title}
|
categoryTitle={itemCategory.title}
|
||||||
onNavigate={navigateHref}
|
onNavigate={navigateHref}
|
||||||
|
onEdit={() => setEditingKey(itemView.itemId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -272,6 +275,7 @@ export default function App() {
|
|||||||
related={relatedItems}
|
related={relatedItems}
|
||||||
onNavigate={navigateHref}
|
onNavigate={navigateHref}
|
||||||
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
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,底部渐隐到页面底色 */}
|
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||||||
<div className="site-background" aria-hidden="true" />
|
<div className="site-background" aria-hidden="true" />
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
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 { LoginDialog } from '../auth/LoginDialog'
|
||||||
import {
|
import {
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
@@ -138,6 +138,7 @@ export function AdminPage() {
|
|||||||
items={[
|
items={[
|
||||||
{ key: 'header', label: '页头滚动信息' },
|
{ key: 'header', label: '页头滚动信息' },
|
||||||
{ key: 'sidebar', label: '侧栏导航' },
|
{ key: 'sidebar', label: '侧栏导航' },
|
||||||
|
{ key: 'system', label: '系统' },
|
||||||
]}
|
]}
|
||||||
activeKey={tab}
|
activeKey={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
@@ -154,12 +155,14 @@ export function AdminPage() {
|
|||||||
value={draft.header}
|
value={draft.header}
|
||||||
onChange={(header) => setDraft({ ...draft, header })}
|
onChange={(header) => setDraft({ ...draft, header })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : tab === 'sidebar' ? (
|
||||||
<SidebarForm
|
<SidebarForm
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={draft.sidebar}
|
value={draft.sidebar}
|
||||||
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
|
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<SystemPanel />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Space gap="sm" justify="end">
|
<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({
|
function IconButtonGroup({
|
||||||
onUp,
|
onUp,
|
||||||
onDown,
|
onDown,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type ArticleDetailViewProps = {
|
|||||||
categoryId: string
|
categoryId: string
|
||||||
categoryTitle: string
|
categoryTitle: string
|
||||||
onNavigate: (href: string) => void
|
onNavigate: (href: string) => void
|
||||||
|
onEdit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleDetailView({
|
export function ArticleDetailView({
|
||||||
@@ -22,6 +23,7 @@ export function ArticleDetailView({
|
|||||||
categoryId,
|
categoryId,
|
||||||
categoryTitle,
|
categoryTitle,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
onEdit,
|
||||||
}: ArticleDetailViewProps) {
|
}: ArticleDetailViewProps) {
|
||||||
const meta = article.meta ?? ['2 年前', categoryTitle]
|
const meta = article.meta ?? ['2 年前', categoryTitle]
|
||||||
const crumbs = [
|
const crumbs = [
|
||||||
@@ -39,7 +41,7 @@ export function ArticleDetailView({
|
|||||||
<header className="article-head">
|
<header className="article-head">
|
||||||
<Title level={1} as="h2" className="article-heading">
|
<Title level={1} as="h2" className="article-heading">
|
||||||
{article.title}
|
{article.title}
|
||||||
<button type="button" className="item-edit" disabled title="编辑功能待接入">
|
<button type="button" className="item-edit" title="编辑文章" onClick={onEdit}>
|
||||||
[编辑]
|
[编辑]
|
||||||
</button>
|
</button>
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type ItemDetailViewProps = {
|
|||||||
related: RelatedItem[]
|
related: RelatedItem[]
|
||||||
onNavigate: (href: string) => void
|
onNavigate: (href: string) => void
|
||||||
onOpenItem: (itemId: string) => void
|
onOpenItem: (itemId: string) => void
|
||||||
|
/** 打开编辑 Drawer */
|
||||||
|
onEdit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemDetailView({
|
export function ItemDetailView({
|
||||||
@@ -35,6 +37,7 @@ export function ItemDetailView({
|
|||||||
related,
|
related,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onOpenItem,
|
onOpenItem,
|
||||||
|
onEdit,
|
||||||
}: ItemDetailViewProps) {
|
}: ItemDetailViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="detail-view item-view">
|
<div className="detail-view item-view">
|
||||||
@@ -45,7 +48,7 @@ export function ItemDetailView({
|
|||||||
<Card padding="none" className="item-card">
|
<Card padding="none" className="item-card">
|
||||||
<Title level={1} as="h2" weight="semibold" className="item-title">
|
<Title level={1} as="h2" weight="semibold" className="item-title">
|
||||||
{model.title}
|
{model.title}
|
||||||
<button type="button" className="item-edit" disabled title="编辑功能待接入">
|
<button type="button" className="item-edit" title="编辑条目" onClick={onEdit}>
|
||||||
[编辑]
|
[编辑]
|
||||||
</button>
|
</button>
|
||||||
</Title>
|
</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