feat(admin): 分区增删、资料设置、操作条与交互收尾
- POST/DELETE /api/categories(需登录,级联,local 保护) - profile 设置(头像/名称/角色/简介/位置)应用到资料卡 - 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链 - 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条 - 登录密码可见切换 - 测试 48 passed
This commit is contained in:
@@ -147,6 +147,16 @@ DELETE /api/items/:id
|
|||||||
|
|
||||||
## 11. 实施记录
|
## 11. 实施记录
|
||||||
|
|
||||||
|
### P4(2026-10-29,收尾)
|
||||||
|
|
||||||
|
- 分区增删:`POST /api/categories`、`DELETE /api/categories/{id}`(需登录,级联内容,`local` 不可删),首页与侧栏共用;
|
||||||
|
- 资料设置:头像 URL / 名称 / 角色 / 简介 / 位置,应用到侧栏资料卡;
|
||||||
|
- 操作条:进入详情页记一次浏览、点赞可切换(localStorage 记状态)、分享复制链接、跳转外链;
|
||||||
|
- 交互细节:`[编辑]` 与标题垂直居中并留 12px 间距;设置页操作按钮与内容拉开间距;保存改用 Toast(2.4s 自动消失);设置页 Tabs 不再出现滚动条;
|
||||||
|
- 登录密码框增加显示/隐藏切换(错误后仍可用);
|
||||||
|
- 测试:48 passed(新增 categories 2 例、settings 资料校验)。
|
||||||
|
|
||||||
|
|
||||||
### P0(已完成,2026-10-29)
|
### P0(已完成,2026-10-29)
|
||||||
|
|
||||||
- 迁移机制:`user_version 15 → 16`,`schema::migrate` 逐级升级;v15 老库启动时自动补 `admin` / `session` / `setting` 三表;
|
- 迁移机制:`user_version 15 → 16`,`schema::migrate` 逐级升级;v15 老库启动时自动补 `admin` / `session` / `setting` 三表;
|
||||||
|
|||||||
@@ -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}")))
|
.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> {
|
pub fn audit(conn: &Connection, action: &str, target: &str, detail: &str) -> Result<(), StashError> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! 启动时会重新建表并灌入内嵌种子。
|
//! 启动时会重新建表并灌入内嵌种子。
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod categories;
|
||||||
mod catalog;
|
mod catalog;
|
||||||
pub mod entries;
|
pub mod entries;
|
||||||
mod schema;
|
mod schema;
|
||||||
@@ -193,6 +194,28 @@ impl Db {
|
|||||||
let mut conn = self.lock()?;
|
let mut conn = self.lock()?;
|
||||||
entries::update(&mut conn, key, patch, expected_updated_at)
|
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`。
|
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
||||||
|
|||||||
@@ -101,12 +101,36 @@ pub struct SectionsSettings {
|
|||||||
pub limits: std::collections::HashMap<String, u32>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub header: HeaderSettings,
|
pub header: HeaderSettings,
|
||||||
pub sidebar: SidebarSettings,
|
pub sidebar: SidebarSettings,
|
||||||
pub sections: SectionsSettings,
|
pub sections: SectionsSettings,
|
||||||
|
pub profile: ProfileSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
|
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
|
||||||
@@ -164,6 +188,18 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
|||||||
return Err(StashError::InvalidDirectory("卡片条数最多 60".into()));
|
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(())
|
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 + 内嵌前端 + 托管目录。
|
/// 组装路由:API + 内嵌前端 + 托管目录。
|
||||||
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
|
||||||
/// 起监听任务,没法在单元测试里用。
|
/// 起监听任务,没法在单元测试里用。
|
||||||
@@ -714,6 +799,10 @@ fn build_router(state: AppState) -> Router {
|
|||||||
.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/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("/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))
|
||||||
|
|||||||
+60
-1
@@ -464,8 +464,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: var(--su-space-3);
|
||||||
font-size: var(--su-font-size-xl);
|
font-size: var(--su-font-size-xl);
|
||||||
font-weight: var(--su-font-weight-semibold);
|
font-weight: var(--su-font-weight-semibold);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -904,3 +906,60 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 登录密码框 / 条目操作条 / 设置页细节 ---------- */
|
||||||
|
|
||||||
|
.password-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--su-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-stat {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--su-space-1);
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--su-text-sub);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-stat.is-liked,
|
||||||
|
.item-stat.is-liked svg {
|
||||||
|
color: var(--su-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title .item-edit {
|
||||||
|
margin-left: 0;
|
||||||
|
align-self: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
margin-top: var(--su-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-create {
|
||||||
|
margin-top: var(--su-space-4);
|
||||||
|
padding-top: var(--su-space-4);
|
||||||
|
border-top: 1px dashed var(--su-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-create-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 120px auto;
|
||||||
|
gap: var(--su-space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Empty, Layout, Notice } from '@stash/ui'
|
import { Empty, Layout, Notice, Toaster } from '@stash/ui'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BackToTop } from './components/common/BackToTop'
|
import { BackToTop } from './components/common/BackToTop'
|
||||||
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
||||||
@@ -267,6 +267,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemDetailView
|
<ItemDetailView
|
||||||
|
itemKey={itemView.itemId}
|
||||||
crumbs={[
|
crumbs={[
|
||||||
{ label: '首页', href: '/' },
|
{ label: '首页', href: '/' },
|
||||||
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
|
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
|
||||||
@@ -290,6 +291,8 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
|
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
|
||||||
<LoginDialog
|
<LoginDialog
|
||||||
open={loginOpen}
|
open={loginOpen}
|
||||||
@@ -347,6 +350,7 @@ export default function App() {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
projects={projects}
|
projects={projects}
|
||||||
categories={sidebarCategories}
|
categories={sidebarCategories}
|
||||||
|
profile={settings.settings.profile}
|
||||||
live={live}
|
live={live}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
|||||||
@@ -10,16 +10,22 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
Title,
|
Title,
|
||||||
|
Toaster,
|
||||||
|
toast,
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { changePassword, 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 {
|
||||||
|
createCategory,
|
||||||
|
deleteCategory,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
TONE_OPTIONS,
|
TONE_OPTIONS,
|
||||||
type HeaderItem,
|
type HeaderItem,
|
||||||
|
type ProfileSettings,
|
||||||
type SectionSettings,
|
type SectionSettings,
|
||||||
type SettingsDocument,
|
type SettingsDocument,
|
||||||
type SettingsTone,
|
type SettingsTone,
|
||||||
@@ -49,7 +55,6 @@ export function AdminPage() {
|
|||||||
const [categories, setCategories] = useState<CategorySummary[]>([])
|
const [categories, setCategories] = useState<CategorySummary[]>([])
|
||||||
const [tab, setTab] = useState('header')
|
const [tab, setTab] = useState('header')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()])
|
const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()])
|
||||||
@@ -75,25 +80,36 @@ export function AdminPage() {
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (document === null || draft === null) return
|
if (document === null || draft === null) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setMessage(null)
|
|
||||||
|
|
||||||
const result = await saveSettings(document.version, draft)
|
const result = await saveSettings(document.version, draft)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setMessage({ tone: 'error', text: result.error })
|
toast.error(result.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDocument(result.document)
|
setDocument(result.document)
|
||||||
setDraft(clone(result.document.settings))
|
setDraft(clone(result.document.settings))
|
||||||
setMessage({ tone: 'info', text: '已保存,回到首页即可看到效果' })
|
toast.success('已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCategory = async (card: CategorySummary) => {
|
||||||
|
if (!window.confirm(`删除「${card.title}」会同时删除它下面的所有条目,确定继续?`)) return
|
||||||
|
const result = await deleteCategory(card.id)
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success(`已删除「${card.title}」`)
|
||||||
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticated = session?.authenticated === true
|
const authenticated = session?.authenticated === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
|
<Toaster />
|
||||||
<header className="admin-topbar">
|
<header className="admin-topbar">
|
||||||
<a className="site-brand" href="/" aria-label="Stash 首页">
|
<a className="site-brand" href="/" aria-label="Stash 首页">
|
||||||
<span className="site-brand-mark" aria-hidden="true">
|
<span className="site-brand-mark" aria-hidden="true">
|
||||||
@@ -141,22 +157,18 @@ export function AdminPage() {
|
|||||||
{authenticated && draft !== null && (
|
{authenticated && draft !== null && (
|
||||||
<>
|
<>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
className="admin-tabs"
|
||||||
items={[
|
items={[
|
||||||
{ key: 'header', label: '页头滚动信息' },
|
{ key: 'header', label: '页头滚动信息' },
|
||||||
{ key: 'sections', label: '首页卡片' },
|
{ key: 'sections', label: '首页卡片' },
|
||||||
{ key: 'sidebar', label: '侧栏导航' },
|
{ key: 'sidebar', label: '侧栏导航' },
|
||||||
|
{ key: 'profile', label: '资料' },
|
||||||
{ key: 'system', label: '系统' },
|
{ key: 'system', label: '系统' },
|
||||||
]}
|
]}
|
||||||
activeKey={tab}
|
activeKey={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{message !== null && (
|
|
||||||
<Notice tone={message.tone} title={message.tone === 'info' ? '保存成功' : '保存失败'}>
|
|
||||||
{message.text}
|
|
||||||
</Notice>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'header' ? (
|
{tab === 'header' ? (
|
||||||
<HeaderForm
|
<HeaderForm
|
||||||
value={draft.header}
|
value={draft.header}
|
||||||
@@ -167,23 +179,31 @@ export function AdminPage() {
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
value={draft.sections}
|
value={draft.sections}
|
||||||
onChange={(sections) => setDraft({ ...draft, sections })}
|
onChange={(sections) => setDraft({ ...draft, sections })}
|
||||||
|
onDelete={removeCategory}
|
||||||
|
onReload={load}
|
||||||
/>
|
/>
|
||||||
) : tab === 'sidebar' ? (
|
) : tab === 'sidebar' ? (
|
||||||
<SidebarForm
|
<SidebarForm
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={draft.sidebar}
|
value={draft.sidebar}
|
||||||
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
|
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
|
||||||
|
onDelete={removeCategory}
|
||||||
|
onReload={load}
|
||||||
|
/>
|
||||||
|
) : tab === 'profile' ? (
|
||||||
|
<ProfileForm
|
||||||
|
value={draft.profile}
|
||||||
|
onChange={(profile) => setDraft({ ...draft, profile })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SystemPanel />
|
<SystemPanel />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Space gap="sm" justify="end">
|
<Space gap="sm" justify="end" className="admin-actions">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDraft(clone(draft))
|
setDraft(clone(draft))
|
||||||
setMessage(null)
|
|
||||||
void load()
|
void load()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -316,10 +336,14 @@ function SidebarForm({
|
|||||||
categories,
|
categories,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onDelete,
|
||||||
|
onReload,
|
||||||
}: {
|
}: {
|
||||||
categories: CategorySummary[]
|
categories: CategorySummary[]
|
||||||
value: SiteSettings['sidebar']
|
value: SiteSettings['sidebar']
|
||||||
onChange: (sidebar: SiteSettings['sidebar']) => void
|
onChange: (sidebar: SiteSettings['sidebar']) => void
|
||||||
|
onDelete: (card: CategorySummary) => void
|
||||||
|
onReload: () => void
|
||||||
}) {
|
}) {
|
||||||
const ordered = useMemo(() => {
|
const ordered = useMemo(() => {
|
||||||
const ranks = new Map(value.order.map((id, index) => [id, index]))
|
const ranks = new Map(value.order.map((id, index) => [id, index]))
|
||||||
@@ -367,13 +391,18 @@ function SidebarForm({
|
|||||||
<IconButtonGroup
|
<IconButtonGroup
|
||||||
onUp={() => move(index, -1)}
|
onUp={() => move(index, -1)}
|
||||||
onDown={() => move(index, 1)}
|
onDown={() => move(index, 1)}
|
||||||
onRemove={undefined}
|
onRemove={() => onDelete(category)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{ordered.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
{ordered.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
||||||
|
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
新增或删除分类会同时影响首页卡片与侧栏。
|
||||||
|
</Text>
|
||||||
|
<CategoryCreator onCreated={onReload} />
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -384,10 +413,14 @@ function SectionForm({
|
|||||||
categories,
|
categories,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onDelete,
|
||||||
|
onReload,
|
||||||
}: {
|
}: {
|
||||||
categories: CategorySummary[]
|
categories: CategorySummary[]
|
||||||
value: SectionSettings
|
value: SectionSettings
|
||||||
onChange: (sections: SectionSettings) => void
|
onChange: (sections: SectionSettings) => void
|
||||||
|
onDelete: (card: CategorySummary) => void
|
||||||
|
onReload: () => void
|
||||||
}) {
|
}) {
|
||||||
const cards = useMemo(() => {
|
const cards = useMemo(() => {
|
||||||
const base = [
|
const base = [
|
||||||
@@ -470,12 +503,136 @@ function SectionForm({
|
|||||||
<IconButtonGroup
|
<IconButtonGroup
|
||||||
onUp={() => move(index, -1)}
|
onUp={() => move(index, -1)}
|
||||||
onDown={() => move(index, 1)}
|
onDown={() => move(index, 1)}
|
||||||
onRemove={undefined}
|
onRemove={card.id === 'local' ? undefined : () => onDelete(card)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{cards.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
{cards.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
||||||
|
|
||||||
|
<CategoryCreator onCreated={onReload} />
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 新增分类 ----------
|
||||||
|
|
||||||
|
function CategoryCreator({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const [id, setId] = useState('')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [kind, setKind] = useState<'apps' | 'posters' | 'articles'>('posters')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const result = await createCategory({ id: id.trim(), title: title.trim(), kind })
|
||||||
|
setBusy(false)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('分类已创建')
|
||||||
|
setId('')
|
||||||
|
setTitle('')
|
||||||
|
onCreated()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="column" gap="sm" align="stretch" className="admin-create">
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
新增分类(会同时出现在首页卡片与侧栏)。标识只允许小写字母、数字与连字符。
|
||||||
|
</Text>
|
||||||
|
<div className="admin-create-row">
|
||||||
|
<Input
|
||||||
|
value={id}
|
||||||
|
placeholder="标识,如 books"
|
||||||
|
aria-label="新分类标识"
|
||||||
|
onChange={(event) => setId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
placeholder="标题,如 书单"
|
||||||
|
aria-label="新分类标题"
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'apps', label: '应用' },
|
||||||
|
{ value: 'posters', label: '海报' },
|
||||||
|
{ value: 'articles', label: '图文' },
|
||||||
|
]}
|
||||||
|
value={kind}
|
||||||
|
onChange={(next) => setKind(next as typeof kind)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={busy}
|
||||||
|
disabled={id.trim() === '' || title.trim() === ''}
|
||||||
|
icon={<Icon name="add_line" />}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 资料 ----------
|
||||||
|
|
||||||
|
function ProfileForm({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: ProfileSettings
|
||||||
|
onChange: (profile: ProfileSettings) => void
|
||||||
|
}) {
|
||||||
|
const update = (patch: Partial<ProfileSettings>) => onChange({ ...value, ...patch })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||||
|
<FormField label="头像地址" htmlFor="profile-avatar" hint="留空则用图标占位;支持 https 图片地址">
|
||||||
|
<Input
|
||||||
|
id="profile-avatar"
|
||||||
|
value={value.avatar}
|
||||||
|
placeholder="https://…"
|
||||||
|
onChange={(event) => update({ avatar: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="名称" htmlFor="profile-name" required>
|
||||||
|
<Input
|
||||||
|
id="profile-name"
|
||||||
|
value={value.name}
|
||||||
|
onChange={(event) => update({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="角色" htmlFor="profile-role">
|
||||||
|
<Input
|
||||||
|
id="profile-role"
|
||||||
|
value={value.role}
|
||||||
|
onChange={(event) => update({ role: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="简介" htmlFor="profile-description">
|
||||||
|
<Textarea
|
||||||
|
id="profile-description"
|
||||||
|
rows={3}
|
||||||
|
value={value.description}
|
||||||
|
onChange={(event) => update({ description: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="位置" htmlFor="profile-location" hint="直接显示在资料卡底部,如「IP属地:局域网」">
|
||||||
|
<Input
|
||||||
|
id="profile-location"
|
||||||
|
value={value.location}
|
||||||
|
onChange={(event) => update({ location: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button, FormField, Icon, Input, Modal, Notice, Space, Text } from '@stash/ui'
|
import { Button, FormField, Icon, Modal, Notice, Space, Text } from '@stash/ui'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { login, setup, type SessionState } from '../../auth'
|
import { login, setup, type SessionState } from '../../auth'
|
||||||
|
import { PasswordInput } from './PasswordInput'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
|
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
|
||||||
@@ -84,32 +85,24 @@ export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogP
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField label="密码" htmlFor="login-password" required>
|
<FormField label="密码" htmlFor="login-password" required>
|
||||||
<Input
|
<PasswordInput
|
||||||
id="login-password"
|
id="login-password"
|
||||||
type="password"
|
|
||||||
icon="lock_line"
|
|
||||||
placeholder="输入密码"
|
placeholder="输入密码"
|
||||||
value={password}
|
value={password}
|
||||||
autoFocus
|
autoFocus
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={setPassword}
|
||||||
onKeyDown={(event) => {
|
onEnter={() => void submit()}
|
||||||
if (event.key === 'Enter') void submit()
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{isSetup && (
|
{isSetup && (
|
||||||
<FormField label="确认密码" htmlFor="login-confirm" required>
|
<FormField label="确认密码" htmlFor="login-confirm" required>
|
||||||
<Input
|
<PasswordInput
|
||||||
id="login-confirm"
|
id="login-confirm"
|
||||||
type="password"
|
|
||||||
icon="keyhole_line"
|
|
||||||
placeholder="再输入一次"
|
placeholder="再输入一次"
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(event) => setConfirm(event.target.value)}
|
onChange={setConfirm}
|
||||||
onKeyDown={(event) => {
|
onEnter={() => void submit()}
|
||||||
if (event.key === 'Enter') void submit()
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Icon, IconButton, Input } from '@stash/ui'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言无关的密码可见切换:右侧一个眼睛按钮,切换 input type。
|
||||||
|
* 错误提示后再点仍然有效(状态在组件里,不会被错误重置)。
|
||||||
|
*/
|
||||||
|
type PasswordInputProps = {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
placeholder?: string
|
||||||
|
autoFocus?: boolean
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onEnter?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
autoFocus = false,
|
||||||
|
onChange,
|
||||||
|
onEnter,
|
||||||
|
}: PasswordInputProps) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="password-field">
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
icon="lock_line"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onEnter?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label={visible ? '隐藏密码' : '显示密码'}
|
||||||
|
icon={<Icon name={visible ? 'eye_close_line' : 'eye_line'} />}
|
||||||
|
onClick={() => setVisible((current) => !current)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
|
toast,
|
||||||
type BreadcrumbItem,
|
type BreadcrumbItem,
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import type { ItemDetailModel } from '../../data/detail'
|
import type { ItemDetailModel } from '../../data/detail'
|
||||||
import type { RelatedItem } from '../../data/types'
|
import type { RelatedItem } from '../../data/types'
|
||||||
import { RelatedList } from './RelatedList'
|
import { RelatedList } from './RelatedList'
|
||||||
@@ -22,6 +24,8 @@ import { RelatedList } from './RelatedList'
|
|||||||
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
|
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
|
||||||
*/
|
*/
|
||||||
type ItemDetailViewProps = {
|
type ItemDetailViewProps = {
|
||||||
|
/** 条目业务键:浏览 / 点赞要写回数据库 */
|
||||||
|
itemKey: string
|
||||||
crumbs: BreadcrumbItem[]
|
crumbs: BreadcrumbItem[]
|
||||||
model: ItemDetailModel
|
model: ItemDetailModel
|
||||||
related: RelatedItem[]
|
related: RelatedItem[]
|
||||||
@@ -34,6 +38,7 @@ type ItemDetailViewProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ItemDetailView({
|
export function ItemDetailView({
|
||||||
|
itemKey,
|
||||||
crumbs,
|
crumbs,
|
||||||
model,
|
model,
|
||||||
related,
|
related,
|
||||||
@@ -42,6 +47,74 @@ export function ItemDetailView({
|
|||||||
onEdit,
|
onEdit,
|
||||||
canEdit,
|
canEdit,
|
||||||
}: ItemDetailViewProps) {
|
}: ItemDetailViewProps) {
|
||||||
|
const [views, setViews] = useState(model.views)
|
||||||
|
const [likes, setLikes] = useState(model.likes)
|
||||||
|
const [liked, setLiked] = useState(() => {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(`stash-liked:${itemKey}`) === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 进入详情页记一次浏览(每个页面打开一次)
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
fetch(`/api/items/${encodeURIComponent(itemKey)}/view`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
|
.then((payload: { views?: number; likes?: number } | null) => {
|
||||||
|
if (!alive || payload === null) return
|
||||||
|
if (typeof payload.views === 'number') setViews(payload.views)
|
||||||
|
if (typeof payload.likes === 'number') setLikes(payload.likes)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [itemKey])
|
||||||
|
|
||||||
|
const toggleLike = () => {
|
||||||
|
const next = !liked
|
||||||
|
setLiked(next)
|
||||||
|
setLikes((current) => Math.max(0, current + (next ? 1 : -1)))
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(`stash-liked:${itemKey}`, next ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// 隐私模式下 localStorage 不可用,忽略即可
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/items/${encodeURIComponent(itemKey)}/like`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ liked: next }),
|
||||||
|
})
|
||||||
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
|
.then((payload: { views?: number; likes?: number } | null) => {
|
||||||
|
if (payload === null) return
|
||||||
|
if (typeof payload.views === 'number') setViews(payload.views)
|
||||||
|
if (typeof payload.likes === 'number') setLikes(payload.likes)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = () => {
|
||||||
|
const url = window.location.href
|
||||||
|
const clipboard = navigator.clipboard
|
||||||
|
if (clipboard === undefined) {
|
||||||
|
toast.error('当前浏览器不支持复制,请手动复制地址栏')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() => toast.success('链接已复制'))
|
||||||
|
.catch(() => toast.error('复制失败,请手动复制地址栏'))
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="detail-view item-view">
|
<div className="detail-view item-view">
|
||||||
<Card padding="none" className="detail-nav">
|
<Card padding="none" className="detail-nav">
|
||||||
@@ -98,19 +171,35 @@ export function ItemDetailView({
|
|||||||
|
|
||||||
<div className="item-actions">
|
<div className="item-actions">
|
||||||
<Space gap="sm">
|
<Space gap="sm">
|
||||||
<Space gap="xs" className="item-stat">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="item-stat"
|
||||||
|
title="浏览量"
|
||||||
|
onClick={() => toast.success(`浏览量 ${views}`)}
|
||||||
|
>
|
||||||
<Icon name="eye_line" />
|
<Icon name="eye_line" />
|
||||||
<Text size="xs" tone="sub">
|
<Text size="xs" tone="sub">
|
||||||
{model.views}
|
{views}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</button>
|
||||||
<Space gap="xs" className="item-stat">
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`item-stat${liked ? ' is-liked' : ''}`}
|
||||||
|
title={liked ? '取消点赞' : '点赞'}
|
||||||
|
aria-pressed={liked}
|
||||||
|
onClick={toggleLike}
|
||||||
|
>
|
||||||
<Icon name="thumb_up_2_line" />
|
<Icon name="thumb_up_2_line" />
|
||||||
<Text size="xs" tone="sub">
|
<Text size="xs" tone="sub">
|
||||||
{model.likes}
|
{likes}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</button>
|
||||||
<IconButton label="分享(待接入)" size="sm" icon={<Icon name="share_3_line" />} />
|
<IconButton
|
||||||
|
label="分享链接"
|
||||||
|
size="sm"
|
||||||
|
icon={<Icon name="share_3_line" />}
|
||||||
|
onClick={share}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
|
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
|
||||||
|
|||||||
@@ -1,28 +1,34 @@
|
|||||||
import { Avatar, Card, Icon, Text, Title } from '@stash/ui'
|
import { Avatar, Card, Icon, Text, Title } from '@stash/ui'
|
||||||
import { PLACEHOLDER_PROFILE } from '../../profile'
|
import type { ProfileSettings } from '../../settings'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
|
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
|
||||||
* 数据来自 profile.ts 的占位对象,后端接入鉴权后替换。
|
* 内容来自站点设置(/admin → 资料),未设置时用默认文案。
|
||||||
*/
|
*/
|
||||||
export function ProfileCard() {
|
type ProfileCardProps = {
|
||||||
const { name, role, bio, location } = PLACEHOLDER_PROFILE
|
profile: ProfileSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card padding="none" className="profile-card">
|
<Card padding="none" className="profile-card">
|
||||||
|
{profile.avatar !== '' ? (
|
||||||
|
<Avatar size="xl" src={profile.avatar} alt={profile.name} verified />
|
||||||
|
) : (
|
||||||
<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
|
<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
|
||||||
|
)}
|
||||||
|
|
||||||
<Title level={4} weight="semibold" className="profile-name">
|
<Title level={4} weight="semibold" className="profile-name">
|
||||||
{name}
|
{profile.name}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<span className="profile-role">{role}</span>
|
<span className="profile-role">{profile.role}</span>
|
||||||
|
|
||||||
<Text size="xs" tone="sub" className="profile-bio">
|
<Text size="xs" tone="sub" className="profile-bio">
|
||||||
{bio}
|
{profile.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<span className="profile-location">IP属地:{location}</span>
|
<span className="profile-location">{profile.location}</span>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Project } from '../../api'
|
import type { Project } from '../../api'
|
||||||
import type { NavTarget } from '../../data/nav'
|
import type { NavTarget } from '../../data/nav'
|
||||||
import type { Category } from '../../data/types'
|
import type { Category } from '../../data/types'
|
||||||
|
import type { ProfileSettings } from '../../settings'
|
||||||
import { CategoryNav } from './CategoryNav'
|
import { CategoryNav } from './CategoryNav'
|
||||||
import { ProfileCard } from './ProfileCard'
|
import { ProfileCard } from './ProfileCard'
|
||||||
import { ServicePanel } from './ServicePanel'
|
import { ServicePanel } from './ServicePanel'
|
||||||
@@ -13,6 +14,7 @@ type SidebarProps = {
|
|||||||
projects: Project[]
|
projects: Project[]
|
||||||
/** 内容目录;侧栏直接由它派生 */
|
/** 内容目录;侧栏直接由它派生 */
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
|
profile: ProfileSettings
|
||||||
live: boolean
|
live: boolean
|
||||||
refreshing: boolean
|
refreshing: boolean
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
@@ -28,6 +30,7 @@ type SidebarProps = {
|
|||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
projects,
|
projects,
|
||||||
categories,
|
categories,
|
||||||
|
profile,
|
||||||
live,
|
live,
|
||||||
refreshing,
|
refreshing,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -39,7 +42,7 @@ export function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
<ProfileCard />
|
<ProfileCard profile={profile} />
|
||||||
|
|
||||||
<ServicePanel
|
<ServicePanel
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export type SectionSettings = {
|
|||||||
limits: Record<string, number>
|
limits: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProfileSettings = {
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
description: string
|
||||||
|
/** 完整文案,如「IP属地:局域网」 */
|
||||||
|
location: string
|
||||||
|
/** 头像地址;空表示用图标占位 */
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SiteSettings = {
|
export type SiteSettings = {
|
||||||
header: {
|
header: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -31,6 +41,7 @@ export type SiteSettings = {
|
|||||||
order: string[]
|
order: string[]
|
||||||
}
|
}
|
||||||
sections: SectionSettings
|
sections: SectionSettings
|
||||||
|
profile: ProfileSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsDocument = {
|
export type SettingsDocument = {
|
||||||
@@ -62,6 +73,13 @@ export function defaultSettings(): SiteSettings {
|
|||||||
},
|
},
|
||||||
sidebar: { hidden: [], order: [] },
|
sidebar: { hidden: [], order: [] },
|
||||||
sections: { hidden: [], order: [], titles: {}, limits: {} },
|
sections: { hidden: [], order: [], titles: {}, limits: {} },
|
||||||
|
profile: {
|
||||||
|
name: '本地管理员',
|
||||||
|
role: '管理员',
|
||||||
|
description: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
|
||||||
|
location: 'IP属地:局域网',
|
||||||
|
avatar: '',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,8 +107,7 @@ export async function fetchSettings(): Promise<SettingsDocument> {
|
|||||||
export async function saveSettings(
|
export async function saveSettings(
|
||||||
version: number,
|
version: number,
|
||||||
settings: SiteSettings,
|
settings: SiteSettings,
|
||||||
): Promise<SettingsResult> {
|
): Promise<SettingsResult> { try {
|
||||||
try {
|
|
||||||
const response = await fetch('/api/settings', {
|
const response = await fetch('/api/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -119,8 +136,7 @@ export async function saveSettings(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
|
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
|
||||||
*/
|
*/export function applySidebarSettings<T extends { id: string }>(
|
||||||
export function applySidebarSettings<T extends { id: string }>(
|
|
||||||
categories: T[],
|
categories: T[],
|
||||||
sidebar: SiteSettings['sidebar'],
|
sidebar: SiteSettings['sidebar'],
|
||||||
): T[] {
|
): T[] {
|
||||||
@@ -155,8 +171,7 @@ export function sectionLimit(
|
|||||||
return typeof override === 'number' && override > 0 ? override : fallback
|
return typeof override === 'number' && override > 0 ? override : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderAndFilter<T extends { id: string }>(
|
function orderAndFilter<T extends { id: string }>( categories: T[],
|
||||||
categories: T[],
|
|
||||||
config: { hidden: string[]; order: string[] },
|
config: { hidden: string[]; order: string[] },
|
||||||
): T[] {
|
): T[] {
|
||||||
const hidden = new Set(config.hidden)
|
const hidden = new Set(config.hidden)
|
||||||
@@ -172,3 +187,39 @@ function orderAndFilter<T extends { id: string }>(
|
|||||||
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
|
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
|
||||||
.map((entry) => entry.category)
|
.map((entry) => entry.category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 分区(首页卡片 / 侧栏项)的新增与删除 ----------
|
||||||
|
|
||||||
|
export type NewCategory = { id: string; title: string; kind: 'apps' | 'posters' | 'articles' }
|
||||||
|
|
||||||
|
export type SimpleResult = { ok: true } | { ok: false; error: string }
|
||||||
|
|
||||||
|
export async function createCategory(input: NewCategory): Promise<SimpleResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/categories', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
if (response.ok) return { ok: true }
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(id: string): Promise<SimpleResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (response.ok) return { ok: true }
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user