feat(admin): 登录改页内弹窗 + P1 站点设置(页头/侧栏)

- 登录:LoginDialog(Modal)替代 /login 页面;/login 重定向回首页
- 设置接口:GET /api/settings 公开读、PATCH 需登录 + version 乐观锁(409/400)
- setting 表存 site JSON:页头(启用/速度/条目+标签+色调)、侧栏(hidden/order)
- /admin:Tabs 页头/侧栏,条目增删与上下移,保存后首页生效
- 首页应用设置:ticker 文本占位符与速度、侧栏顺序与显隐
- 测试 42 passed(settings 5 例)
This commit is contained in:
stash
2026-10-29 12:52:29 +08:00
parent dea7781b86
commit 9ae5a455c5
14 changed files with 1205 additions and 195 deletions
+4 -3
View File
@@ -31,11 +31,12 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
- Rust 工具链(2024 Edition) - Rust 工具链(2024 Edition)
- Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本 - Node.js 24,推荐用 [Volta](https://volta.sh/) 管理;`stash-web/package.json` 里已用 `volta` 字段固定版本
### 登录(v0.1.1 起) ### 登录与设置(v0.1.1 起)
- 首次打开 `http://127.0.0.1:8080/login` 是**设置密码**(至少 8 位),之后是登录; - 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐;
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天); - 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
- 读接口公开(本地工具可只读使用),写接口(`/api/auth/password` 及后续编辑接口)要求登录; - 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
- 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」; - 忘记密码:删除 `admin` 表里的那一行(或整个库)后重启,会回到「设置密码」;
- 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。 - 方案与后续阶段见 [`docs/ADMIN-PLAN.md`](docs/ADMIN-PLAN.md)。
+10
View File
@@ -158,3 +158,13 @@ DELETE /api/items/:id
**未做(P1/P2)**:`/admin` 设置页、侧栏/页头编辑、内容就编辑、`setting` 表的读写接口。 **未做(P1/P2)**:`/admin` 设置页、侧栏/页头编辑、内容就编辑、`setting` 表的读写接口。
### P1(已完成,2026-10-29)
- 登录改为**页内弹窗**(`LoginDialog`):`/login` 旧地址重定向回首页,不再单开页面;
- 设置读写:`GET /api/settings`(公开)、`PATCH /api/settings`(需登录,`version` 乐观锁,冲突 409、校验失败 400);
- `setting` 表存 `key='site'` 的 JSON:页头(启用 / 速度 / 条目与标签/色调)、侧栏(隐藏 / 顺序);
- `/admin` 设置页:Tabs(页头滚动信息 / 侧栏导航),条目支持增删、上移下移、占位符 `{count}`/`{project}`/`{status}`;
- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染;
- 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。
+19
View File
@@ -11,6 +11,7 @@
pub mod auth; pub mod auth;
mod catalog; mod catalog;
mod schema; mod schema;
pub mod settings;
use std::sync::Mutex; use std::sync::Mutex;
@@ -24,6 +25,8 @@ pub use schema::SeedReport;
/// 登录/会话的对外状态(server 层直接用)。 /// 登录/会话的对外状态(server 层直接用)。
pub use auth::SessionState; pub use auth::SessionState;
/// 站点设置:页头滚动信息 + 侧栏顺序/显隐。
pub use settings::{Settings, SettingsDocument};
/// 一次查询得到的完整内容目录。 /// 一次查询得到的完整内容目录。
/// ///
@@ -152,6 +155,22 @@ impl Db {
let mut conn = self.lock()?; let mut conn = self.lock()?;
auth::change_password(&mut conn, current, next) auth::change_password(&mut conn, current, next)
} }
// ---------- 站点设置 ----------
pub fn load_settings(&self) -> Result<SettingsDocument, StashError> {
let conn = self.lock()?;
settings::load(&conn)
}
pub fn save_settings(
&self,
next: Settings,
expected_version: i64,
) -> Result<SettingsDocument, StashError> {
let mut conn = self.lock()?;
settings::save(&mut conn, next, expected_version)
}
} }
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。 /// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
+293
View File
@@ -0,0 +1,293 @@
//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。
//!
//! 存 `setting` 表:`key = 'site'`,`value` 是一段 JSON,`version` 做乐观锁。
//! 读的时候任何字段缺失都回落默认值,坏 JSON 也回落默认值——
//! 设置坏掉不应该让首页白屏。
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use crate::{db::map_error, error::StashError};
pub const SETTINGS_KEY: &str = "site";
pub const MAX_HEADER_ITEMS: usize = 8;
pub const MIN_SPEED: u32 = 8;
pub const MAX_SPEED: u32 = 120;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Tone {
#[default]
Plain,
Primary,
Success,
Neutral,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct HeaderItem {
/// 文本,支持占位符 `{count}` / `{project}` / `{status}`
pub text: String,
/// 前置小标签,如「本地」「状态」
pub tag: Option<String>,
pub tone: Tone,
}
impl Default for HeaderItem {
fn default() -> Self {
Self {
text: String::new(),
tag: None,
tone: Tone::Plain,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct HeaderSettings {
pub enabled: bool,
/// 滚动一轮的秒数
pub speed: u32,
pub items: Vec<HeaderItem>,
}
impl Default for HeaderSettings {
fn default() -> Self {
Self {
enabled: true,
speed: 24,
items: vec![
HeaderItem {
text: "共 {count} 个本地应用".into(),
tag: None,
tone: Tone::Plain,
},
HeaderItem {
text: "{project}".into(),
tag: Some("本地".into()),
tone: Tone::Primary,
},
HeaderItem {
text: "{status}".into(),
tag: Some("状态".into()),
tone: Tone::Success,
},
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SidebarSettings {
/// 隐藏的分类 id
pub hidden: Vec<String>,
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
pub order: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct Settings {
pub header: HeaderSettings,
pub sidebar: SidebarSettings,
}
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SettingsDocument {
pub version: i64,
pub settings: Settings,
}
/// 校验:条数、长度、速度范围。
pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.header.items.is_empty() {
return Err(StashError::InvalidDirectory("页头至少保留一条信息".into()));
}
if settings.header.items.len() > MAX_HEADER_ITEMS {
return Err(StashError::InvalidDirectory(format!(
"页头最多 {MAX_HEADER_ITEMS} 条信息"
)));
}
if !(MIN_SPEED..=MAX_SPEED).contains(&settings.header.speed) {
return Err(StashError::InvalidDirectory(format!(
"滚动速度需在 {MIN_SPEED}–{MAX_SPEED} 秒之间"
)));
}
for item in &settings.header.items {
let text = item.text.trim();
if text.is_empty() {
return Err(StashError::InvalidDirectory("信息文本不能为空".into()));
}
if text.chars().count() > 60 {
return Err(StashError::InvalidDirectory("单条信息最多 60 个字".into()));
}
if let Some(tag) = &item.tag {
if tag.chars().count() > 8 {
return Err(StashError::InvalidDirectory("标签最多 8 个字".into()));
}
}
}
if settings.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 {
return Err(StashError::InvalidDirectory("侧栏配置过长".into()));
}
Ok(())
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
/// 读设置;没有记录时返回默认值与 version = 0。
pub fn load(conn: &Connection) -> Result<SettingsDocument, StashError> {
let row: Option<(String, i64)> = conn
.query_row(
"SELECT value, version FROM setting WHERE key = ?1",
[SETTINGS_KEY],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(map_error)?;
let Some((raw, version)) = row else {
return Ok(SettingsDocument {
version: 0,
settings: Settings::default(),
});
};
let settings = serde_json::from_str::<Settings>(&raw).unwrap_or_else(|error| {
tracing::warn!("设置 JSON 解析失败,使用默认值: {error}");
Settings::default()
});
Ok(SettingsDocument { version, settings })
}
/// 保存设置;`expected_version` 与库里不一致时返回冲突。
pub fn save(
conn: &mut Connection,
settings: Settings,
expected_version: i64,
) -> Result<SettingsDocument, StashError> {
validate(&settings)?;
let value = serde_json::to_string(&settings)
.map_err(|error| StashError::Database(format!("设置序列化失败: {error}")))?;
let tx = conn.transaction().map_err(map_error)?;
let updated = if expected_version == 0 {
tx.execute(
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, ?2, 1, ?3)
ON CONFLICT(key) DO NOTHING",
params![SETTINGS_KEY, value, now()],
)
.map_err(map_error)?
} else {
tx.execute(
"UPDATE setting SET value = ?1, version = version + 1, updated_at = ?2
WHERE key = ?3 AND version = ?4",
params![value, now(), SETTINGS_KEY, expected_version],
)
.map_err(map_error)?
};
if updated == 0 {
return Err(StashError::Conflict(
"设置已被其它页面修改,请刷新后重试".into(),
));
}
let (version,): (i64,) = tx
.query_row(
"SELECT version FROM setting WHERE key = ?1",
[SETTINGS_KEY],
|row| Ok((row.get(0)?,)),
)
.map_err(map_error)?;
tx.commit().map_err(map_error)?;
Ok(SettingsDocument { version, settings })
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"CREATE TABLE setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);",
)
.expect("应能建表");
conn
}
#[test]
fn defaults_are_stable_and_valid() {
let settings = Settings::default();
assert!(settings.header.enabled);
assert_eq!(settings.header.items.len(), 3);
assert!(validate(&settings).is_ok());
}
#[test]
fn roundtrip_and_version_bump() {
let mut conn = db();
let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功");
assert_eq!(saved.version, 1);
let loaded = load(&conn).expect("应能读");
assert_eq!(loaded, saved);
let mut next = saved.settings.clone();
next.header.speed = 40;
let saved2 = save(&mut conn, next, saved.version).expect("更新应成功");
assert_eq!(saved2.version, 2);
assert_eq!(saved2.settings.header.speed, 40);
}
#[test]
fn stale_version_conflicts() {
let mut conn = db();
let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功");
let error = save(&mut conn, Settings::default(), saved.version - 1).expect_err("应冲突");
assert!(matches!(error, StashError::Conflict(_)));
}
#[test]
fn invalid_settings_are_rejected() {
let mut conn = db();
let mut bad = Settings::default();
bad.header.speed = 1;
assert!(save(&mut conn, bad, 0).is_err());
let mut empty = Settings::default();
empty.header.items.clear();
assert!(save(&mut conn, empty, 0).is_err());
}
#[test]
fn broken_json_falls_back_to_defaults() {
let conn = db();
conn.execute(
"INSERT INTO setting (key, value, version, updated_at) VALUES (?1, '{bad json', 3, 0)",
[SETTINGS_KEY],
)
.expect("应能写入");
let loaded = load(&conn).expect("坏 JSON 不应报错");
assert_eq!(loaded.version, 3);
assert_eq!(loaded.settings, Settings::default());
}
}
+5
View File
@@ -30,6 +30,10 @@ pub enum StashError {
/// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。 /// 统一收敛成一个变体,避免把 rusqlite 的类型泄漏到库的公开 API。
#[error("数据库错误: {0}")] #[error("数据库错误: {0}")]
Database(String), Database(String),
/// 乐观锁冲突:资源在本次编辑期间被别人改过。
#[error("冲突: {0}")]
Conflict(String),
} }
impl IntoResponse for StashError { impl IntoResponse for StashError {
@@ -37,6 +41,7 @@ impl IntoResponse for StashError {
let (status, err_msg) = match self { let (status, err_msg) = match self {
StashError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), StashError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
StashError::InvalidDirectory(msg) => (StatusCode::BAD_REQUEST, msg), StashError::InvalidDirectory(msg) => (StatusCode::BAD_REQUEST, msg),
StashError::Conflict(msg) => (StatusCode::CONFLICT, msg),
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
}; };
+48
View File
@@ -534,6 +534,53 @@ async fn auth_password(
} }
} }
// ---------------- 站点设置 ----------------
#[derive(serde::Deserialize)]
struct SettingsPatch {
version: i64,
settings: crate::db::Settings,
}
/// GET /api/settings —— 读接口公开,前端首屏直接取
async fn get_settings(State(state): State<AppState>) -> Response {
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.load_settings()).await {
Ok(Ok(document)) => Json(document).into_response(),
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/settings —— 需要登录;`version` 做乐观锁
async fn patch_settings(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<SettingsPatch>,
) -> 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 (settings, version) = (body.settings, body.version);
match tokio::task::spawn_blocking(move || db.save_settings(settings, version)).await {
Ok(Ok(document)) => Json(document).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()),
}
}
/// 组装路由:API + 内嵌前端 + 托管目录。 /// 组装路由:API + 内嵌前端 + 托管目录。
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、 /// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
/// 起监听任务,没法在单元测试里用。 /// 起监听任务,没法在单元测试里用。
@@ -559,6 +606,7 @@ fn build_router(state: AppState) -> Router {
.route("/api/auth/login", post(auth_login)) .route("/api/auth/login", post(auth_login))
.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("/", 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))
+62 -18
View File
@@ -773,36 +773,80 @@
white-space: nowrap; white-space: nowrap;
} }
/* ---------- 登录页 ---------- */ /* ---------- 设置页 ---------- */
.login-page { .admin-page {
display: grid;
place-items: center;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
padding: var(--su-space-6);
background-color: var(--su-bg-page); background-color: var(--su-bg-page);
} }
.login-card { .admin-topbar {
display: flex; display: flex;
flex-direction: column;
gap: var(--su-space-4);
width: min(380px, 100%);
}
.login-brand {
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--su-space-4);
height: 64px;
padding: 0 var(--su-space-6);
border-bottom: 1px solid var(--su-border);
background-color: var(--su-bg-card);
} }
.login-back { .admin-main {
font-size: var(--su-font-size-sm); width: min(880px, calc(100% - 48px));
color: var(--su-text-muted); margin: 0 auto;
text-align: center; padding: var(--su-space-8) 0 var(--su-space-10);
} }
.login-back:hover { .admin-locked {
color: var(--su-color-primary); display: flex;
align-items: center;
justify-content: space-between;
gap: var(--su-space-4);
margin-top: var(--su-space-5);
padding: var(--su-space-4);
border: 1px dashed var(--su-border-strong);
border-radius: var(--su-radius-md);
}
.admin-form {
margin-top: var(--su-space-5);
}
.admin-row {
display: flex;
align-items: center;
gap: var(--su-space-3);
}
.admin-item-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 160px 120px auto;
align-items: center;
gap: var(--su-space-2);
}
.admin-item-row > :first-child:not(.admin-item-title) {
grid-column: span 1;
}
.admin-item-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-nav-row {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.admin-item-actions {
display: flex;
justify-content: flex-end;
}
.admin-item-row .su-switch {
justify-self: start;
} }
/* ---------- 响应式 ---------- */ /* ---------- 响应式 ---------- */
+42 -6
View File
@@ -29,6 +29,13 @@ import { useProjects } from './useProjects'
import { useCatalog } from './useCatalog' import { useCatalog } from './useCatalog'
import { useViewRoute } from './useViewRoute' import { useViewRoute } from './useViewRoute'
import { fetchSession, logout as logoutRequest, type SessionState } from './auth' import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
import {
applySidebarSettings,
defaultSettings,
fetchSettings,
type SettingsDocument,
} from './settings'
import { LoginDialog } from './components/auth/LoginDialog'
import { parseViewPath, viewPath, type View } from './data/view' import { parseViewPath, viewPath, type View } from './data/view'
import './App.css' import './App.css'
@@ -40,6 +47,11 @@ export default function App() {
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts) // 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
const { view, go } = useViewRoute() const { view, go } = useViewRoute()
const [session, setSession] = useState<SessionState | null>(null) const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument>({
version: 0,
settings: defaultSettings(),
})
const [loginOpen, setLoginOpen] = useState(false)
const [scrolled, setScrolled] = useState(false) const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null) const searchRef = useRef<HTMLInputElement>(null)
@@ -49,6 +61,9 @@ export default function App() {
fetchSession().then((next) => { fetchSession().then((next) => {
if (alive) setSession(next) if (alive) setSession(next)
}) })
fetchSettings().then((next) => {
if (alive) setSettings(next)
})
return () => { return () => {
alive = false alive = false
} }
@@ -63,13 +78,17 @@ export default function App() {
[categories, keyword], [categories, keyword],
) )
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区 // 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
// 再按 /admin 里的顺序与显隐设置重排一遍
const sidebarCategories = useMemo( const sidebarCategories = useMemo(
() => () =>
categories.filter( applySidebarSettings(
(category) => category.id !== 'local' && category.showInSidebar !== false, categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
settings.settings.sidebar,
), ),
[categories], [categories, settings.settings.sidebar],
) )
const detailView = view.type === 'detail' ? view : undefined const detailView = view.type === 'detail' ? view : undefined
@@ -258,11 +277,24 @@ export default function App() {
} }
const handleLogout = () => { const handleLogout = () => {
void logoutRequest().then(() => window.location.assign('/')) void logoutRequest().then(() => {
setSession({ authenticated: false, has_admin: true })
})
} }
return ( return (
<> <>
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? false}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
}}
/>
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */} {/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" /> <div className="site-background" aria-hidden="true" />
@@ -287,11 +319,15 @@ export default function App() {
projectCount={projects.length} projectCount={projects.length}
sampleProject={projects[0]?.name} sampleProject={projects[0]?.name}
live={live} live={live}
headerItems={settings.settings.header.items}
headerEnabled={settings.settings.header.enabled}
headerSpeed={settings.settings.header.speed}
authenticated={session?.authenticated === true} authenticated={session?.authenticated === true}
username={session?.username ?? undefined} username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()} onFocusSearch={() => searchRef.current?.focus()}
onLogin={() => window.location.assign('/login')} onLogin={() => setLoginOpen(true)}
onLogout={handleLogout} onLogout={handleLogout}
onSettings={() => window.location.assign('/admin')}
/> />
} }
sider={ sider={
@@ -0,0 +1,391 @@
import {
Button,
Card,
FormField,
Icon,
Input,
Notice,
Select,
Space,
Switch,
Tabs,
Text,
Title,
} from '@stash/ui'
import { useEffect, useMemo, useState } from 'react'
import { fetchSession, logout, type SessionState } from '../../auth'
import { LoginDialog } from '../auth/LoginDialog'
import {
fetchSettings,
saveSettings,
TONE_OPTIONS,
type HeaderItem,
type SettingsDocument,
type SettingsTone,
type SiteSettings,
} from '../../settings'
/**
* 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。
*
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
* 保存用 `version` 乐观锁,冲突时提示刷新。
*/
type CategorySummary = { id: string; title: string }
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
export function AdminPage() {
const [session, setSession] = useState<SessionState | null>(null)
const [loginOpen, setLoginOpen] = useState(false)
const [document, setDocument] = useState<SettingsDocument | null>(null)
const [draft, setDraft] = useState<SiteSettings | null>(null)
const [categories, setCategories] = useState<CategorySummary[]>([])
const [tab, setTab] = useState('header')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
const load = async () => {
const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()])
setSession(nextSession)
setDocument(nextDocument)
setDraft(clone(nextDocument.settings))
try {
const response = await fetch('/api/catalog', { credentials: 'same-origin' })
if (response.ok) {
const payload = (await response.json()) as { categories?: CategorySummary[] }
setCategories(payload.categories ?? [])
}
} catch {
setCategories([])
}
}
useEffect(() => {
void load()
}, [])
const save = async () => {
if (document === null || draft === null) return
setSaving(true)
setMessage(null)
const result = await saveSettings(document.version, draft)
setSaving(false)
if (!result.ok) {
setMessage({ tone: 'error', text: result.error })
return
}
setDocument(result.document)
setDraft(clone(result.document.settings))
setMessage({ tone: 'info', text: '已保存,回到首页即可看到效果' })
}
const authenticated = session?.authenticated === true
return (
<div className="admin-page">
<header className="admin-topbar">
<a className="site-brand" href="/" aria-label="Stash 首页">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</a>
<Space gap="sm">
<Button variant="ghost" icon={<Icon name="arrow_left_line" />} onClick={() => window.location.assign('/')}>
返回首页
</Button>
{authenticated && (
<Button
variant="secondary"
icon={<Icon name="exit_line" />}
onClick={() => {
void logout().then(() => window.location.assign('/'))
}}
>
退出登录
</Button>
)}
</Space>
</header>
<main className="admin-main">
<Card padding="lg">
<Space direction="column" gap="sm" align="stretch">
<Title level={2}>站点设置</Title>
<Text size="sm" tone="muted">
页头滚动信息与侧栏导航都保存在内容库里,改完立即对新打开的页面生效。
</Text>
</Space>
{!authenticated && (
<div className="admin-locked">
<Text>需要登录后才能修改设置。</Text>
<Button icon={<Icon name="lock_line" />} onClick={() => setLoginOpen(true)}>
登录
</Button>
</div>
)}
{authenticated && draft !== null && (
<>
<Tabs
items={[
{ key: 'header', label: '页头滚动信息' },
{ key: 'sidebar', label: '侧栏导航' },
]}
activeKey={tab}
onChange={setTab}
/>
{message !== null && (
<Notice tone={message.tone} title={message.tone === 'info' ? '保存成功' : '保存失败'}>
{message.text}
</Notice>
)}
{tab === 'header' ? (
<HeaderForm
value={draft.header}
onChange={(header) => setDraft({ ...draft, header })}
/>
) : (
<SidebarForm
categories={categories}
value={draft.sidebar}
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
/>
)}
<Space gap="sm" justify="end">
<Button
variant="secondary"
onClick={() => {
setDraft(clone(draft))
setMessage(null)
void load()
}}
>
撤销改动
</Button>
<Button loading={saving} icon={<Icon name="check_line" />} onClick={save}>
保存
</Button>
</Space>
</>
)}
</Card>
</main>
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? true}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
void load()
}}
/>
</div>
)
}
// ---------- 页头表单 ----------
const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label: option.label }))
function HeaderForm({
value,
onChange,
}: {
value: SiteSettings['header']
onChange: (header: SiteSettings['header']) => void
}) {
const update = (index: number, patch: Partial<HeaderItem>) => {
onChange({
...value,
items: value.items.map((item, itemIndex) =>
itemIndex === index ? { ...item, ...patch } : item,
),
})
}
const move = (index: number, step: -1 | 1) => {
const target = index + step
if (target < 0 || target >= value.items.length) return
const items = [...value.items]
const [item] = items.splice(index, 1)
items.splice(target, 0, item)
onChange({ ...value, items })
}
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<div className="admin-row">
<Text>显示页头滚动信息</Text>
<Switch
checked={value.enabled}
onChange={(enabled) => onChange({ ...value, enabled })}
label="显示页头滚动信息"
/>
<Text size="sm" tone="muted">
关闭后页头中间留空
</Text>
</div>
<FormField label="滚动速度" htmlFor="header-speed" hint="秒数越大越慢,范围 8–120">
<Input
id="header-speed"
type="number"
value={String(value.speed)}
onChange={(event) => {
const speed = Number(event.target.value)
if (Number.isFinite(speed)) onChange({ ...value, speed })
}}
/>
</FormField>
<Space direction="column" gap="sm" align="stretch">
<Text size="sm" tone="muted">
支持占位符:{'{count}'} 应用数、{'{project}'} 项目名、{'{status}'} 连接状态
</Text>
{value.items.map((item, index) => (
<div className="admin-item-row" key={index}>
<Input
value={item.text}
placeholder="信息文本"
onChange={(event) => update(index, { text: event.target.value })}
/>
<Input
value={item.tag ?? ''}
placeholder="标签(可选)"
onChange={(event) => update(index, { tag: event.target.value })}
/>
<Select
options={TONE_SELECT}
value={item.tone}
onChange={(tone) => update(index, { tone: tone as SettingsTone })}
/>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={() => onChange({ ...value, items: value.items.filter((_, i) => i !== index) })}
/>
</div>
))}
<Button
variant="secondary"
icon={<Icon name="add_line" />}
disabled={value.items.length >= 8}
onClick={() => onChange({ ...value, items: [...value.items, { text: '', tag: '', tone: 'plain' }] })}
>
添加一条
</Button>
</Space>
</Space>
)
}
// ---------- 侧栏表单 ----------
function SidebarForm({
categories,
value,
onChange,
}: {
categories: CategorySummary[]
value: SiteSettings['sidebar']
onChange: (sidebar: SiteSettings['sidebar']) => void
}) {
const ordered = useMemo(() => {
const ranks = new Map(value.order.map((id, index) => [id, index]))
return [...categories].sort((a, b) => {
const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (left === right) return 0
return left - right
})
}, [categories, value.order])
const move = (index: number, step: -1 | 1) => {
const target = index + step
if (target < 0 || target >= ordered.length) return
const ids = ordered.map((category) => category.id)
const [id] = ids.splice(index, 1)
ids.splice(target, 0, id)
onChange({ ...value, order: ids })
}
const toggle = (id: string, visible: boolean) => {
const hidden = visible
? value.hidden.filter((entry) => entry !== id)
: [...new Set([...value.hidden, id])]
onChange({ ...value, hidden, order: ordered.map((category) => category.id) })
}
return (
<Space direction="column" gap="sm" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。
</Text>
{ordered.map((category, index) => {
const visible = !value.hidden.includes(category.id)
return (
<div className="admin-item-row admin-nav-row" key={category.id}>
<Switch
checked={visible}
onChange={(next) => toggle(category.id, next)}
label={`显示 ${category.title}`}
/>
<Text className="admin-item-title">{category.title}</Text>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={undefined}
/>
</div>
)
})}
{ordered.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
</Space>
)
}
// ---------- 行内按钮组 ----------
function IconButtonGroup({
onUp,
onDown,
onRemove,
}: {
onUp: () => void
onDown: () => void
onRemove?: (() => void) | undefined
}) {
return (
<Space gap="xs" className="admin-item-actions">
<Button variant="ghost" size="sm" icon={<Icon name="arrow_up_line" />} onClick={onUp} aria-label="上移" />
<Button variant="ghost" size="sm" icon={<Icon name="arrow_down_line" />} onClick={onDown} aria-label="下移" />
{onRemove !== undefined && (
<Button
variant="ghost"
size="sm"
icon={<Icon name="delete_2_line" />}
onClick={onRemove}
aria-label="删除"
/>
)}
</Space>
)
}
@@ -0,0 +1,119 @@
import { Button, FormField, Icon, Input, Modal, Notice, Space, Text } from '@stash/ui'
import { useEffect, useState } from 'react'
import { login, setup, type SessionState } from '../../auth'
/**
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
*
* 成功后由父组件刷新会话状态;弹窗本身不跳转页面。
*/
type LoginDialogProps = {
open: boolean
hasAdmin: boolean
onClose: () => void
onSuccess: (session: SessionState) => void
}
export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogProps) {
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// 每次打开重置,避免上次的错误/输入残留
useEffect(() => {
if (!open) return
setPassword('')
setConfirm('')
setError('')
setBusy(false)
}, [open])
const isSetup = !hasAdmin
const submit = async () => {
setError('')
if (isSetup && password !== confirm) {
setError('两次输入的密码不一致')
return
}
setBusy(true)
const result = isSetup ? await setup(password) : await login(password)
setBusy(false)
if (!result.ok) {
setError(result.error)
return
}
onSuccess({
authenticated: true,
has_admin: true,
username: 'admin',
})
}
return (
<Modal
open={open}
onClose={onClose}
title={isSetup ? '设置管理员密码' : '登录'}
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
{isSetup ? '设置并登录' : '登录'}
</Button>
</Space>
}
>
<Space direction="column" gap="md" align="stretch">
<Text size="sm" tone="muted">
{isSetup
? '首次使用:设置一个至少 8 位的密码,之后用它登录编辑。'
: '登录后可以编辑页头滚动信息、侧栏顺序与显示。'}
</Text>
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<FormField label="密码" htmlFor="login-password" required>
<Input
id="login-password"
type="password"
icon="lock_line"
placeholder="输入密码"
value={password}
autoFocus
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
{isSetup && (
<FormField label="确认密码" htmlFor="login-confirm" required>
<Input
id="login-confirm"
type="password"
icon="keyhole_line"
placeholder="再输入一次"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
)}
</Space>
</Modal>
)
}
-126
View File
@@ -1,126 +0,0 @@
import { Button, Card, FormField, Icon, Input, Notice, Space, Text, Title } from '@stash/ui'
import { useEffect, useState } from 'react'
import { fetchSession, login, setup, type SessionState } from '../../auth'
/**
* 登录页:首次打开是「设置密码」,之后是「登录」。
*
* 成功后整页跳回首页(而不是 SPA 内跳转),让服务端重新下发 Cookie 后的首帧
* 也带上登录态,避免首屏闪一下未登录 UI。
*/
export function LoginPage() {
const [session, setSession] = useState<SessionState | null>(null)
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
let alive = true
fetchSession().then((next) => {
if (!alive) return
if (next.authenticated) {
window.location.assign('/')
return
}
setSession(next)
})
return () => {
alive = false
}
}, [])
const isSetup = session !== null && !session.has_admin
const submit = async () => {
setError('')
if (isSetup && password !== confirm) {
setError('两次输入的密码不一致')
return
}
setBusy(true)
const result = isSetup ? await setup(password) : await login(password)
setBusy(false)
if (result.ok) {
window.location.assign('/')
return
}
setError(result.error)
}
return (
<div className="login-page">
<Card padding="lg" className="login-card">
<Space gap="sm" className="login-brand">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</Space>
<Title level={2}>{isSetup ? '设置管理员密码' : '登录'}</Title>
<Text size="sm" tone="muted">
{isSetup
? '首次使用:设置一个至少 8 位的密码,之后用它在任意设备上登录。'
: '登录后可以编辑侧栏、页头滚动信息与内容。'}
</Text>
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<FormField
label="密码"
htmlFor="password"
required
hint={isSetup ? '至少 8 位,建议混合字母与数字' : undefined}
>
<Input
id="password"
type="password"
size="lg"
icon="lock_line"
placeholder="输入密码"
value={password}
autoFocus
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
{isSetup && (
<FormField label="确认密码" htmlFor="confirm" required>
<Input
id="confirm"
type="password"
size="lg"
icon="keyhole_line"
placeholder="再输入一次"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
)}
<Button size="lg" block loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
{isSetup ? '设置并登录' : '登录'}
</Button>
<a className="login-back" href="/">
返回首页
</a>
</Card>
</div>
)
}
+65 -35
View File
@@ -1,7 +1,8 @@
import { Icon, IconButton, Tag } from '@stash/ui' import { Icon, IconButton, Tag } from '@stash/ui'
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings'
/** /**
* 顶部栏:左品牌、中间滚动信息、右侧操作图标。 * 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。 * 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
*/ */
type SiteHeaderProps = { type SiteHeaderProps = {
@@ -9,47 +10,57 @@ type SiteHeaderProps = {
/** 用于展示的第一个项目名,没有项目时留空 */ /** 用于展示的第一个项目名,没有项目时留空 */
sampleProject?: string sampleProject?: string
live: boolean live: boolean
/** 页头滚动信息;为空时用默认三条 */
headerItems: HeaderItem[]
headerEnabled: boolean
/** 滚动一轮的秒数 */
headerSpeed: number
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */ /** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean authenticated: boolean
username?: string username?: string
onFocusSearch: () => void onFocusSearch: () => void
onLogin: () => void onLogin: () => void
onLogout: () => void onLogout: () => void
onSettings: () => void
}
const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
plain: 'neutral',
neutral: 'neutral',
primary: 'primary',
success: 'success',
} }
type InfoGroupProps = { type InfoGroupProps = {
items: HeaderItem[]
projectCount: number projectCount: number
sampleProject?: string sampleProject?: string
live: boolean live: boolean
'aria-hidden'?: boolean 'aria-hidden'?: boolean
} }
/** 运行信息:同一份内容渲染两遍,轨道平移 50% 形成无缝滚动 */ /** 运行信息:同一份内容渲染三遍,轨道平移 1/3 形成无缝滚动 */
function InfoGroup({ function InfoGroup({
items,
projectCount, projectCount,
sampleProject, sampleProject,
live, live,
'aria-hidden': ariaHidden, 'aria-hidden': ariaHidden,
}: InfoGroupProps) { }: InfoGroupProps) {
const context = { count: projectCount, project: sampleProject, live }
return ( return (
<span className="header-ticker-group" aria-hidden={ariaHidden}> <span className="header-ticker-group" aria-hidden={ariaHidden}>
<span className="header-ticker-item">共 {projectCount} 个本地应用</span> {items.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}>
{sampleProject !== undefined && ( {item.tag !== undefined && item.tag !== null && item.tag !== '' && (
<span className="header-ticker-item"> <Tag tone={tagTone[item.tone]} shape="rounded">
<Tag tone="primary" shape="rounded"> {item.tag}
本地 </Tag>
</Tag> )}
{sampleProject} {fillHeaderText(item.text, context)}
</span> </span>
)} ))}
<span className="header-ticker-item">
<Tag tone={live ? 'success' : 'neutral'} shape="rounded">
状态
</Tag>
{live ? '实时同步已连接' : '等待服务连接'}
</span>
</span> </span>
) )
} }
@@ -58,11 +69,15 @@ export function SiteHeader({
projectCount, projectCount,
sampleProject, sampleProject,
live, live,
headerItems,
headerEnabled,
headerSpeed,
authenticated, authenticated,
username, username,
onFocusSearch, onFocusSearch,
onLogin, onLogin,
onLogout, onLogout,
onSettings,
}: SiteHeaderProps) { }: SiteHeaderProps) {
return ( return (
<div className="site-header-inner"> <div className="site-header-inner">
@@ -73,24 +88,36 @@ export function SiteHeader({
<span className="site-brand-name">STASH</span> <span className="site-brand-name">STASH</span>
</a> </a>
<div className="header-ticker" role="status" aria-label="运行状态"> {headerEnabled && (
<div className="header-ticker-track"> <div className="header-ticker" role="status" aria-label="运行状态">
<InfoGroup projectCount={projectCount} sampleProject={sampleProject} live={live} /> <div
<InfoGroup className="header-ticker-track"
projectCount={projectCount} style={{ animationDuration: `${headerSpeed}s` }}
sampleProject={sampleProject} >
live={live} <InfoGroup
// 后两份只用于视觉无缝,读屏忽略 items={headerItems}
aria-hidden projectCount={projectCount}
/> sampleProject={sampleProject}
<InfoGroup live={live}
projectCount={projectCount} />
sampleProject={sampleProject} <InfoGroup
live={live} items={headerItems}
aria-hidden projectCount={projectCount}
/> sampleProject={sampleProject}
live={live}
// 后两份只用于视觉无缝,读屏忽略
aria-hidden
/>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
aria-hidden
/>
</div>
</div> </div>
</div> )}
<div className="header-actions"> <div className="header-actions">
<IconButton <IconButton
@@ -98,6 +125,9 @@ export function SiteHeader({
icon={<Icon name="search_3_line" />} icon={<Icon name="search_3_line" />}
onClick={onFocusSearch} onClick={onFocusSearch}
/> />
{authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
)}
{authenticated ? ( {authenticated ? (
<IconButton <IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`} label={username === undefined ? '退出登录' : `退出登录(${username})`}
+17 -7
View File
@@ -3,15 +3,25 @@ import { createRoot } from 'react-dom/client'
import '@stash/ui/style.css' import '@stash/ui/style.css'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { LoginPage } from './components/auth/LoginPage' import { AdminPage } from './components/admin/AdminPage'
/** /**
* 入口分流:登录页是独立壳(不挂 Layout / 侧栏 / 搜索), * 入口分流:
* 且必须在 App 的 `useViewRoute` 把地址规范化之前判定, * - `/admin`:设置页独立壳;
* 所以放在这里用首次加载的 pathname 决定,而不是放进 App 里。 * - `/login`:老地址,直接回首页(登录已改为页内弹窗);
* - 其它:正常站点。
*
* 判定放在这里而不是 App 里,是因为 App 的 `useViewRoute` 会把地址规范化,
* 组件内部再读 pathname 已经晚了。
*/ */
const isLoginPage = window.location.pathname === '/login' const path = window.location.pathname
createRoot(document.getElementById('root')!).render( if (path === '/login') {
<StrictMode>{isLoginPage ? <LoginPage /> : <App />}</StrictMode>, window.location.replace('/')
}
const root = createRoot(document.getElementById('root')!)
root.render(
<StrictMode>{path === '/admin' ? <AdminPage /> : <App />}</StrictMode>,
) )
+130
View File
@@ -0,0 +1,130 @@
/**
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。
*
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
*/
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
export type HeaderItem = {
text: string
tag?: string | null
tone: SettingsTone
}
export type SiteSettings = {
header: {
enabled: boolean
/** 滚动一轮的秒数(8–120) */
speed: number
items: HeaderItem[]
}
sidebar: {
hidden: string[]
order: string[]
}
}
export type SettingsDocument = {
version: number
settings: SiteSettings
}
export type SettingsResult =
| { ok: true; document: SettingsDocument }
| { ok: false; status: number; error: string }
export const TONE_OPTIONS: { value: SettingsTone; label: string }[] = [
{ value: 'plain', label: '无底色' },
{ value: 'neutral', label: '灰色' },
{ value: 'primary', label: '品牌色' },
{ value: 'success', label: '成功绿' },
]
export function defaultSettings(): SiteSettings {
return {
header: {
enabled: true,
speed: 24,
items: [
{ text: '共 {count} 个本地应用', tone: 'plain' },
{ text: '{project}', tag: '本地', tone: 'primary' },
{ text: '{status}', tag: '状态', tone: 'success' },
],
},
sidebar: { hidden: [], order: [] },
}
}
/** 占位符替换:`{count}` / `{project}` / `{status}` */
export function fillHeaderText(
text: string,
context: { count: number; project?: string; live: boolean },
): string {
return text
.replaceAll('{count}', String(context.count))
.replaceAll('{project}', context.project ?? '—')
.replaceAll('{status}', context.live ? '实时同步已连接' : '等待服务连接')
}
export async function fetchSettings(): Promise<SettingsDocument> {
try {
const response = await fetch('/api/settings', { credentials: 'same-origin' })
if (!response.ok) throw new Error(String(response.status))
return (await response.json()) as SettingsDocument
} catch {
return { version: 0, settings: defaultSettings() }
}
}
export async function saveSettings(
version: number,
settings: SiteSettings,
): Promise<SettingsResult> {
try {
const response = await fetch('/api/settings', {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ version, settings }),
})
if (response.ok) {
return { ok: true, document: (await response.json()) as SettingsDocument }
}
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 : '网络错误',
}
}
}
/**
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
*/
export function applySidebarSettings<T extends { id: string }>(
categories: T[],
sidebar: SiteSettings['sidebar'],
): T[] {
const hidden = new Set(sidebar.hidden)
const ranks = new Map(sidebar.order.map((id, index) => [id, index]))
return categories
.filter((category) => !hidden.has(category.id))
.map((category, index) => ({
category,
index,
rank: ranks.get(category.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.category)
}