Compare commits

Author SHA1 Message Date
CNWei 259eb20411 feat: 「查看接口」改为站内路由,只换右侧内容
- ServicePanel 新增 onOpenApi,链接点击接管为 navigate('/interface');
  按住 Ctrl/Cmd/Shift/Alt 仍保留新标签打开
- 前台内容区已有 route.kind === 'api' 分支,直接渲染接口一览
- 顺手删掉两个没人引用的旧组件:sidebar/Sidebar.tsx、sidebar/CategoryNav.tsx
2026-11-01 22:22:57 +08:00
CNWei b1fd82a0b6 style(admin): 侧边栏去掉「侧栏卡片」小标题,说明贴近标题
- 删除 SidebarPanel 里的「侧栏卡片」标签
- SidebarPanel 不再挂 .admin-form(它带 20px margin-top),
  标题到说明的距离由 32px 变成 12px,与「内容卡片」一致
- 三个开关行间距不变(Space gap=md)
2026-11-01 22:14:08 +08:00
CNWei 6e773374bb refactor(admin): 折叠箭头与卡片列表改用库里的 Caret / SortableList
- 分组表格的折叠箭头换成 Caret,.group-caret 只保留按钮盒
- 首页卡片列表换成 SortableList,拖拽与排序计算交给组件;reorder 改为接收 key 顺序
- 删掉 .cards-side-item / .cards-side-title / .cards-side-count / .cards-drag-handle 等
  9 条样式,App.css 再减 42 行
2026-11-01 22:07:32 +08:00
CNWei 4ae69b29fe refactor(admin): 三张手写表格改用库里的 Table,文字按钮/开关行收进库
- 分组管理 / 数据管理 / 内容卡片 / 本地服务 四处手写 <table> 全部换成 Table size='sm'
- 行内操作(新建子分组 / 编辑 / 删除)改用 TextButton,删除统一 danger 色
- 侧边栏、页头共 7 处开关行改用 ToggleRow
- 空状态改用 Empty
- 删掉随之失效的 CSS:.group-table* / .entry-table* / .cards-table* / .group-action* /
  .sidebar-toggle-row / .admin-item-title 等,App.css 净减 144 行

视觉:表格外框、表头底色、行 hover 统一由 @stash/ui 提供,与接口页一致
2026-11-01 21:59:18 +08:00
CNWei a67ab7bfe0 fix: 分区角标改按左边缘定位,位数不同间距也一致
原来 right: -30px 按右边缘定位,badge 宽度随位数变化:
本地服务(1 位数)离标题 14px,推荐电影 / CES(2 位数)只有 8px。
改成 left: calc(100% + 8px),四张卡片实测间距全部为 8px。
2026-11-01 21:47:12 +08:00
CNWei 883504222b style: 滚动信息靠右,分区角标与标题拉开 8px
- .header-ticker 加 margin-left: auto,紧贴右侧操作图标;
  靠右改由它承担,.header-actions 去掉 margin-left: auto(无滚动信息时由 space-between 兜住)
- .count-badge 的 right 从 -18 调到 -30,标题与角标间距约 8px
2026-11-01 21:43:54 +08:00
CNWei 27a28ab583 feat: 新增接口一览页 /interface,滚动信息区域收窄
- 新页面 /interface:按用途分组的接口表(方法 / 路径 / 说明 / 权限),
  用库里的 Card + Table + Tag 排;服务端 SPA 路由加入该路径
- 侧边栏「查看接口」不再直接打开 /api/projects 的原始 JSON,改指到该页面
- 页头滚动信息宽度上限 720px(原来铺满,约减少 1/3)
2026-11-01 20:53:55 +08:00
CNWei 036551ee09 feat: 刷新按钮改为整页重载;滚动信息间距可配
- 前台刷新按钮改成 window.location.reload(),项目扫描 / 内容库 / 设置 / 主题全部重走
- settings.header 新增 gap(px,默认 26 ≈ 两个中文字宽,最大 120)
- 后台「页头 → 滚动信息」增加「条目间距」输入
2026-11-01 20:44:29 +08:00
CNWei 0375669a6e feat(admin): 页头拆成三个子页签,导航顺序与命名调整
- 首页管理下顺序改为 页头 / 侧边栏 / 内容卡片,默认展开
- 资料 -> 资料管理、系统 -> 系统管理
- 页头面板内用水平页签分为 站点信息 / 操作工具 / 滚动信息
- 滚动信息页列出可用通配符 {count} / {project} / {status} 及含义
2026-11-01 20:36:11 +08:00
CNWei 10b653e30f feat(admin): 后台改为左侧导航 + 右侧内容
- 原来的横向 Tabs 换成库里的 Menu,左侧固定导航、右侧面板
- 首页管理收成一组:侧边栏 / 内容卡片 / 页头 三个子项,页头从顶层移入
- 页头、资料仍需手动保存,保存/撤销按钮留在各自面板底部
- 窄屏(<=860px)导航折到内容上方
- 后台内容区宽度不变(1340),导航列 190px + 24px 间距
2026-11-01 20:27:36 +08:00
CNWei 8271adc307 style(admin): 拖动说明与下方卡片列表拉开间距
说明从列表底部挪到按钮下方后,原本的 margin-top 变成了「和按钮的间距」,
和列表贴在一起;改成 margin-bottom 16px,上下分别是 12 / 16。
2026-11-01 20:19:07 +08:00
CNWei 3f00a64b3b chore(admin): 副标题里的「首页卡片」改成「内容卡片」 2026-11-01 20:13:49 +08:00
CNWei a7391d7b1a fix(admin): 内容卡片说明放回标题下并改为当前行为,拖动说明移到新建按钮下方
- 卡片说明回到「内容卡片」标题下,改成:勾选分组 + 调整先后顺序与预览条数 + 停用由分组管理决定
- 「拖动卡片可调整首页顺序」移到新建卡片按钮下方(仍在左侧卡片列表里)
- 页头副标题里的「首页卡片」同步改成「内容卡片」
2026-11-01 20:13:20 +08:00
CNWei 30baa96237 style(admin): 首页管理 Tab 整理
- Tab 改名:首页 -> 首页管理
- 两个区块标题改用 Title(侧边栏 / 内容卡片,原「首页卡片」)
- 卡片表格状态列统一成胶囊:启用绿底绿字、已停用红底红字,形状一致
- 卡片删除按钮改成与数据管理一致的红色
- 顺序列 92 -> 112px
- 首页 Tab 内所有说明补上「说明:」前缀;新建卡片的说明挪到按钮下方
2026-11-01 20:08:09 +08:00
CNWei d0a79283ed fix(admin): 新建数据按钮恢复主按钮样式
上一版顺手改成了 secondary,用户要求只改文案、按钮风格保持原样。
2026-11-01 19:59:17 +08:00
CNWei 54d2025a6e style(admin): 内容页改为数据管理
- Tab 名称:内容 -> 数据管理
- 说明文案:数据只属于一个分组;前面加「说明:」
- 新建条目 -> 新建数据,与分组下拉按 8px 间距紧挨
- 操作列:编辑、删除配上图标
2026-11-01 19:55:54 +08:00
CNWei 784a7c54ac style(admin): 分组管理表格整理
- 折叠箭头改成和前台侧栏 Menu 一样的 CSS 角标(6px 折角,展开/收起旋转)
- 分组默认全部收起,首次拿到数据时统一折叠
- 操作列:加子分组 -> 新建子分组,并与编辑、删除一起配上图标
- 表格上方说明前加「说明:」
2026-11-01 19:36:42 +08:00
CNWei 923903b4b2 feat: 前台明暗切换改为本机偏好,不再要求登录
- 新增 src/theme.ts:localStorage 的 stash-theme 作为本机偏好,优先于站点默认
- 前台页头的月亮/太阳只写本机偏好,未登录也能用,刷新后保持
- 后台「页头 → 配色」与后台页头按钮改的是站点默认值;后台按钮同时清掉本机偏好,
  保证点完看到的就是新默认
- 两个壳都按「本机偏好 ?? 站点默认」决定 data-scheme
2026-11-01 19:00:05 +08:00
CNWei 84fc148a39 feat: 前台页头加明暗切换,刷新图标换成闪电
- settings.site.actions 新增 theme(默认开),后台「操作工具」可关
- 前台页头:月亮/太阳按钮,点击写回 site.theme,前后台同步;
  未登录时不显示(设置接口需要会话)
- 刷新图标 refresh_3_line -> lightning_line,刷新期间仍旋转
2026-11-01 16:26:57 +08:00
CNWei 5c170f8819 feat: 后台页头加明暗切换;内容卡改用库里的 EntryRow / EntryTile
- 后台页头新增月亮/太阳按钮:写回 site.theme 并即时切换,前后台一起变,
  忽略未登录与保存中
- AppCard -> EntryRow,CesCard -> EntryTile(PosterCard 本来就用 Cover)
- 删除 .app-card / .app-card-* / .ces-card / .ces-body* 手写样式,
  两套卡片的点击语义与悬停反馈统一由库提供

视觉变化:头像认证勾回到库默认尺寸(20px,之前应用里压到 10px)
2026-11-01 16:08:44 +08:00
CNWei 18e9543607 feat: 分组图标统一(iconfont 名字优先,字符兜底)+ 后台选图标
- 新增 components/common/GroupMark:值是图标名就渲染图标,否则按字符显示,
  兼容旧数据里的 emoji;后台的 GroupMark 改为复用同一判断
- 分组详情页 hero 之前把图标名当文本渲染(会直接显示 folder_2_line),已修
- 分组编辑弹窗加「选择图标」:搜索 + 网格,446 个图标按关键字过滤(默认展示前 120)
- 输入框仍可手填字符,旧数据不用迁移
2026-11-01 15:43:53 +08:00
CNWei 2f2ceb9ea0 feat: 接入深色配色(浅色 / 深色 / 跟随系统)
- settings.site 新增 theme,落在 <html data-scheme> 上
- 深色完全由组件库的令牌块接管,页面与组件都不用改
- 后台「页头 → 配色」可选,保存后前后台即时切换
- 顶部背景图是浅色渐变图,深色下隐藏(.site-background)
2026-11-01 15:41:22 +08:00
CNWei 8a782099f5 style: 统一前后台宽度与数值标尺,站点外壳改用 @stash/ui
对应风格审计的 1-4 条:

1. 宽度:后台内容区改为与页头内层同宽同左缘(1380 - 40 = 1340),
   修掉品牌 x=50、卡片 x=280 的错位
2. 阴影:--su-shadow-card / hover 的覆盖从 .app 挪到 :root,前后台同一套
3. 死代码:删除未被引用的 App.tsx(436 行)、AdminPage.tsx(54KB)与 .content-veil 样式
4. 标尺:37 处脱离 4/8/12/16/20/24/32/40 与字阶的间距/字号/圆角/行高改为令牌
   (含新增的 --su-font-size-2xl);6px 的认证角标按图标保留并注明原因

顺带把重复实现收进库:
- 页头品牌 -> Brand
- 页头滚动信息 -> Ticker(含实测宽度的无缝循环)
- 搜索框 -> SearchField(variant=card),删除 components/search/SearchBar.tsx
- 后台「首页 Logo」预览与后台页头同样复用 Brand
2026-11-01 15:28:22 +08:00
CNWei 59fd61d832 feat: 搜索带并入 Layout toolbar,后台重组首页配置
前台
- 搜索框从内容列移到 Layout 的 toolbar 插槽,去掉 fixed + TOP_OFFSET + top-blur 三处补偿
- 左右栏滚动容器不变,左栏吸顶改用 CSS sticky(修回顶时侧栏抖动)
- 只让分组导航滚动,资料卡与本地服务固定
- 页头操作支持配置:搜索改为刷新(重扫项目 + 重拉内容库)
- 站点级设置:页面标题 / favicon / 品牌 logo / 品牌文字
- 停用的分组在侧边栏、首页卡片、分组页、条目页统一不再出现

后台
- 「侧边栏」「首页卡片」合并为「首页」Tab
- 首页卡片表格新增状态列与顺序列(上下箭头即时保存)
- 分组显隐只由「分组管理 → 状态」决定,去掉侧栏独立勾选
- 页头 Tab 增加站点信息、首页 Logo(带实时预览)、操作工具开关
- 后台页头与前台同构并吸顶

后端
- settings 新增 site(标题 / favicon / 品牌 / 操作工具)与 sidebar.show_nav
- 写接口成功后广播内容库变更,SSE 改为「广播 + data_version 兜底」
- catalog SSE 事件名与前端监听对齐(catalog)
2026-11-01 15:15:20 +08:00
19 changed files with 1836 additions and 3705 deletions
+108
View File
@@ -50,6 +50,8 @@ pub struct HeaderSettings {
pub enabled: bool,
/// 滚动一轮的秒数
pub speed: u32,
/// 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽
pub gap: u32,
pub items: Vec<HeaderItem>,
}
@@ -58,6 +60,7 @@ impl Default for HeaderSettings {
Self {
enabled: true,
speed: 24,
gap: 26,
items: vec![
HeaderItem {
text: "共 {count} 个本地应用".into(),
@@ -88,6 +91,7 @@ impl Default for SidebarSettings {
Self {
show_profile: true,
show_service: true,
show_nav: true,
hidden: Vec::new(),
order: Vec::new(),
hidden_groups: Vec::new(),
@@ -108,6 +112,9 @@ pub struct SidebarSettings {
/// 是否显示「本地服务」面板
#[serde(default = "default_true")]
pub show_service: bool,
/// 是否显示「分组导航」卡片(首页 / 分组树)
#[serde(default = "default_true")]
pub show_nav: bool,
/// 隐藏的分类 id
pub hidden: Vec<String>,
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
@@ -160,6 +167,88 @@ impl Default for ProfileSettings {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BrandSettings {
/// 品牌文字(页头左侧,可留空只显示 logo)
pub name: String,
/// 没有 logo 图片时,圆形标记里显示的首字
pub mark: String,
/// logo 图片地址;空字符串表示用首字
pub logo: String,
}
impl Default for BrandSettings {
fn default() -> Self {
Self {
name: "STASH".into(),
mark: "S".into(),
logo: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ActionSettings {
/// 明暗切换图标
#[serde(default = "default_true")]
pub theme: bool,
/// 刷新图标(重扫本地项目并重拉内容库)
pub refresh: bool,
/// 站点设置图标(仅登录后出现)
pub settings: bool,
/// 登录 / 退出登录
pub session: bool,
}
impl Default for ActionSettings {
fn default() -> Self {
Self {
theme: true,
refresh: true,
settings: true,
session: true,
}
}
}
/// 配色:浅色 / 深色 / 跟随系统。
/// 落到 `<html data-scheme>` 上,深色由组件库的令牌块接管,页面不用改组件。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThemeSetting {
Light,
Dark,
#[default]
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SiteMetaSettings {
/// 浏览器标签标题
pub title: String,
/// favicon 地址;空字符串表示用内置图标
pub favicon: String,
/// 配色
pub theme: ThemeSetting,
pub brand: BrandSettings,
pub actions: ActionSettings,
}
impl Default for SiteMetaSettings {
fn default() -> Self {
Self {
title: "Stash · 本地应用工作区".into(),
favicon: String::new(),
theme: ThemeSetting::Auto,
brand: BrandSettings::default(),
actions: ActionSettings::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct Settings {
@@ -167,6 +256,7 @@ pub struct Settings {
pub sidebar: SidebarSettings,
pub sections: SectionsSettings,
pub profile: ProfileSettings,
pub site: SiteMetaSettings,
}
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
@@ -191,6 +281,9 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
"滚动速度需在 {MIN_SPEED}–{MAX_SPEED} 秒之间"
)));
}
if settings.header.gap > 120 {
return Err(StashError::InvalidDirectory("信息间距最多 120".into()));
}
for item in &settings.header.items {
let text = item.text.trim();
if text.is_empty() {
@@ -244,6 +337,21 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.profile.location.chars().count() > 40 {
return Err(StashError::InvalidDirectory("位置最多 40 个字".into()));
}
if settings.site.title.chars().count() > 120 {
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
}
if settings.site.favicon.chars().count() > 500 {
return Err(StashError::InvalidDirectory("favicon 地址最多 500 个字符".into()));
}
if settings.site.brand.name.chars().count() > 20 {
return Err(StashError::InvalidDirectory("品牌文字最多 20 个字".into()));
}
if settings.site.brand.mark.chars().count() > 4 {
return Err(StashError::InvalidDirectory("logo 标记最多 4 个字".into()));
}
if settings.site.brand.logo.chars().count() > 500 {
return Err(StashError::InvalidDirectory("logo 地址最多 500 个字符".into()));
}
Ok(())
}
+58 -16
View File
@@ -1,7 +1,8 @@
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
extract::{OriginalUri, Path, Request, State},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::{get, patch, post, put},
@@ -36,6 +37,9 @@ pub struct AppState {
pub project_updates: broadcast::Sender<Vec<AppProject>>,
/// 内容库:分类 / 分组 / 条目的唯一数据源
pub db: Arc<Db>,
/// 内容库写入后的广播:SSE 用它对所有打开的页面推「该重拉」。
/// 不能只靠 `PRAGMA data_version`——服务自己的连接写完不会让它变化。
pub catalog_updates: broadcast::Sender<()>,
/// 登录失败计数(单用户本地场景,一个进程一份就够)
pub login_guard: Arc<std::sync::Mutex<LoginGuard>>,
}
@@ -160,7 +164,7 @@ fn not_found_page(path: &str) -> Response {
const SPA_PREFIXES: &[&str] = &["/c/", "/g/"];
/// 精确匹配的前端页面(没有实体文件,返回入口页)
const SPA_PAGES: &[&str] = &["/login", "/admin"];
const SPA_PAGES: &[&str] = &["/login", "/admin", "/interface"];
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
fn is_spa_path(path: &str) -> bool {
@@ -234,30 +238,29 @@ async fn catalog_api(State(state): State<AppState>) -> Response {
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.clone();
let mut updates = state.catalog_updates.subscribe();
let stream = async_stream::stream! {
let mut last = db.data_version().unwrap_or(0);
let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop {
ticker.tick().await;
let current = match db.data_version() {
Ok(version) => version,
Err(error) => {
warn!("读取内容库版本失败: {error}");
continue;
// 两条来源:本进程的写接口主动广播(快、准),
// 以及 data_version 轮询兜底(外部工具直接改库时用)。
let changed = tokio::select! {
_ = ticker.tick() => match db.data_version() {
Ok(current) if current != last => {
last = current;
true
}
_ => false,
},
received = updates.recv() => received.is_ok(),
};
// 只在其它连接提交之后才推送。
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
// 因为 data_version 不会因本连接自己的提交而变化。
tracing::debug!(last, current, "内容库版本检查");
if current == last {
if !changed {
continue;
}
last = current;
// 只告诉前端「变了」,具体内容由它重新 GET /api/catalog
yield Ok::<Event, Infallible>(
@@ -273,6 +276,36 @@ async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
)
}
/// 任何写请求成功后,通知所有打开的页面「内容库变了」。
///
/// 放在中间件里而不是逐个 handler 调:以后新增写接口不用再记得挂号。
/// 登录相关接口不涉及内容库,跳过以免无谓刷新。
async fn notify_catalog_writes(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_owned();
let response = next.run(request).await;
let mutating = matches!(
method,
Method::POST | Method::PATCH | Method::PUT | Method::DELETE
);
if mutating
&& path.starts_with("/api/")
&& !path.starts_with("/api/auth/")
&& response.status().is_success()
{
// 没人订阅(没有开着的页面)时 send 会失败,忽略即可
let _ = state.catalog_updates.send(());
}
response
}
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
let root = state.config.root.clone();
@@ -1146,7 +1179,12 @@ fn build_router(state: AppState) -> Router {
.route("/favicon.svg", get(web_root_file))
.route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir)
.with_state(state)
.with_state(state.clone())
// 写接口成功后广播「内容库变了」,让所有打开的页面自己重拉
.layer(middleware::from_fn_with_state(
state,
notify_catalog_writes,
))
.layer(TraceLayer::new_for_http());
if enable_cors {
@@ -1170,6 +1208,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
let (project_updates, _) = broadcast::channel(32);
watcher::start(config.root.clone(), project_updates.clone())?;
let (catalog_updates, _) = broadcast::channel(16);
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
let db = Arc::new(Db::open(&config)?);
@@ -1178,6 +1217,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
config: Arc::new(config.clone()),
project_updates,
db,
catalog_updates,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
};
@@ -1240,11 +1280,13 @@ mod tests {
config.enable_cors = false;
let (project_updates, _) = broadcast::channel(4);
let (catalog_updates, _) = broadcast::channel(4);
build_router(AppState {
config: Arc::new(config),
project_updates,
db: Arc::new(Db::in_memory()),
catalog_updates,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
})
}
+337 -428
View File
File diff suppressed because it is too large Load Diff
-436
View File
@@ -1,436 +0,0 @@
import { Empty, Layout, Notice, Toaster } from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
import { DetailView } from './components/content/DetailView'
import { HomeView } from './components/content/HomeView'
import { ItemDetailView } from './components/content/ItemDetailView'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { Sidebar } from './components/sidebar/Sidebar'
import { withLocalCategory } from './data/local'
import {
appDetailModel,
posterDetailModel,
posterToRelatedItem,
toRelatedItem,
} from './data/detail'
import { filterCategories } from './data/filter'
import type { NavTarget } from './data/nav'
import {
type Category,
findAppItem,
findArticleItem,
findPosterItem,
relatedAppItems,
relatedPosterItems,
} from './data/types'
import { useProjects } from './useProjects'
import { useCatalog } from './useCatalog'
import { useViewRoute } from './useViewRoute'
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
import {
applySidebarGroups,
applySidebarSettings,
defaultSettings,
fetchSettings,
type SettingsDocument,
} from './settings'
import { LoginDialog } from './components/auth/LoginDialog'
import { ItemEditor } from './components/content/ItemEditor'
import { parseViewPath, viewPath, type View } from './data/view'
import './App.css'
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
const catalog = useCatalog()
const [keyword, setKeyword] = useState('')
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
const { view, go } = useViewRoute()
const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument>({
version: 0,
settings: defaultSettings(),
})
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
// 登录态:只用来切换页头按钮与后续的编辑入口
useEffect(() => {
let alive = true
fetchSession().then((next) => {
if (alive) setSession(next)
})
fetchSettings().then((next) => {
if (alive) setSettings(next)
})
return () => {
alive = false
}
}, [])
const categories = useMemo(
() => withLocalCategory(projects, catalog.categories),
[projects, catalog.categories],
)
const visibleCategories = useMemo(
() => filterCategories(categories, keyword),
[categories, keyword],
)
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
// 再按 /admin 里的顺序、显隐与分组设置重排一遍
const sidebarCategories = useMemo(
() =>
applySidebarGroups(
applySidebarSettings(
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
settings.settings.sidebar,
),
settings.settings.sidebar,
),
[categories, settings.settings.sidebar],
)
const detailView = view.type === 'detail' ? view : undefined
const itemView = view.type === 'item' ? view : undefined
const detailCategory =
detailView !== undefined
? categories.find((category) => category.id === detailView.categoryId)
: undefined
const itemCategory =
itemView !== undefined
? categories.find((category) => category.id === itemView.categoryId)
: undefined
// 应用与电影共用一个详情模型,这里按分类类型做映射
const detailModel = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return undefined
if (itemCategory.kind === 'apps') {
const item = findAppItem(itemCategory, itemView.itemId)
return item !== undefined ? appDetailModel(item) : undefined
}
if (itemCategory.kind === 'posters') {
const poster = findPosterItem(itemCategory, itemView.itemId)
return poster !== undefined ? posterDetailModel(poster) : undefined
}
return undefined
}, [itemView, itemCategory])
const relatedItems = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return []
if (itemCategory.kind === 'apps') {
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
}
if (itemCategory.kind === 'posters') {
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
}
return []
}, [itemView, itemCategory])
// 切换视图时把内容区(内部滚动)和窗口都回到顶部
useEffect(() => {
const scroller = document.getElementById('content-scroll')
scroller?.scrollTo({ top: 0 })
window.scrollTo({ top: 0 })
}, [view])
/**
* 滚动状态同时驱动「顶部渐隐」和「回到顶部」。
* 桌面端滚的是内容容器(scroll 事件不冒泡),所以用捕获阶段监听。
*/
useEffect(() => {
const sync = () => {
const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
const next = Math.max(inner, window.scrollY) > 4
setScrolled((current) => (current === next ? current : next))
}
sync()
window.addEventListener('scroll', sync, { passive: true })
document.addEventListener('scroll', sync, { passive: true, capture: true })
return () => {
window.removeEventListener('scroll', sync)
document.removeEventListener('scroll', sync, { capture: true })
}
}, [])
/**
* 「打开这个分区」应该落到哪个视图。
*
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
*/
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
if (category?.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) {
return { type: 'detail', categoryId, groupId: group.id }
}
}
return { type: 'detail', categoryId }
}
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
/** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */
const groupOfItem = (category: Category, itemId: string): string | undefined => {
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
}
const openItem = (categoryId: string, itemId: string) => {
const category = categories.find((item) => item.id === categoryId)
go({
type: 'item',
categoryId,
itemId,
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
})
}
/**
* 侧栏导航:带分组时直接打开那个分组;
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
*/
const navigate = (target: NavTarget) => {
if (target.groupId === undefined) {
openDetail(target.categoryId)
return
}
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
}
const goHome = () => go({ type: 'home' })
/** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */
const navigateHref = (href: string) => go(parseViewPath(href))
const searching = keyword.trim() !== ''
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
/**
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
*/
const dataPending =
viewCategoryId !== undefined &&
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
/**
* 条目详情按分类类型分流:
* 图文走长文版式,应用 / 电影走通用的条目版式。
*/
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
<ArticleDetailView
article={article}
categoryId={itemCategory.id}
categoryTitle={itemCategory.title}
onNavigate={navigateHref}
onEdit={() => setEditingKey(itemView.itemId)}
canEdit={session?.authenticated === true}
/>
)
}
if (detailModel === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
<ItemDetailView
itemKey={itemView.itemId}
crumbs={[
{ label: '首页', href: '/' },
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
{ label: detailModel.title },
]}
model={detailModel}
related={relatedItems}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
onEdit={() => setEditingKey(itemView.itemId)}
canEdit={session?.authenticated === true}
/>
)
}
const handleLogout = () => {
void logoutRequest().then(() => {
setSession({ authenticated: false, has_admin: true })
})
}
return (
<>
<Toaster />
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? false}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
}}
/>
<ItemEditor
open={editingKey !== null}
itemKey={editingKey}
onClose={() => setEditingKey(null)}
onSaved={() => catalog.reload()}
/>
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
<Layout
contained
maxWidth={1380}
gutter={20}
siderWidth={240}
gap={20}
headerHeight={60}
headerVariant="solid"
siderBordered={false}
contentPadding="none"
contentOffset={88}
height="screen"
scrollable
hideScrollbar
contentId="content-scroll"
className="app"
header={
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
headerItems={settings.settings.header.items}
headerEnabled={settings.settings.header.enabled}
headerSpeed={settings.settings.header.speed}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
onLogin={() => setLoginOpen(true)}
onLogout={handleLogout}
onSettings={() => window.location.assign('/admin')}
/>
}
sider={
<Sidebar
projects={projects}
categories={sidebarCategories}
profile={settings.settings.profile}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
homeActive={view.type === 'home'}
onNavigate={navigate}
onHome={goHome}
/>
}
>
<div className="content">
{/* 滚动后淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
<Notice
tone="error"
title="连不上 Stash 服务"
action={{ label: '重试', onClick: refresh }}
>
{error}
</Notice>
)}
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
{catalog.status === 'error' && (
<Notice
tone="error"
title="内容库读取失败"
action={{ label: '重试', onClick: catalog.reload }}
>
{catalog.error}
</Notice>
)}
{detailView === undefined && itemView === undefined && (
<>
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
<HomeView
categories={visibleCategories}
sections={settings.settings.sections}
onOpenDetail={openDetail}
onOpenItem={openItem}
/>
{searching && visibleCategories.length === 0 && (
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
换个关键字试试,或清空搜索查看全部。
</Notice>
)}
</>
)}
{detailView !== undefined && detailCategory !== undefined && (
<DetailView
category={detailCategory}
groupId={detailView.groupId}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
)}
{itemView !== undefined && renderItemView()}
</div>
</Layout>
<BackToTop visible={scrolled} />
</>
)
}
+144 -78
View File
@@ -3,6 +3,7 @@ import {
Layout,
Menu,
Notice,
SearchField,
Space,
Toaster,
type MenuItem,
@@ -17,7 +18,8 @@ import { ItemEditor } from './components/content/ItemEditor'
import { PosterGrid } from './components/content/PosterGrid'
import { SectionCard } from './components/content/SectionCard'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { ApiDocs } from './components/api/ApiDocs'
import { GroupMark } from './components/common/GroupMark'
import { LoginDialog } from './components/auth/LoginDialog'
import { ProfileCard } from './components/sidebar/ProfileCard'
import { ServicePanel } from './components/sidebar/ServicePanel'
@@ -28,6 +30,12 @@ import {
type SettingsDocument,
type SiteSettings,
} from './settings'
import {
isDarkScheme,
readThemeOverride,
writeThemeOverride,
type ThemeChoice,
} from './theme'
import {
appDetailModel,
posterDetailModel,
@@ -52,9 +60,11 @@ type Route =
| { kind: 'home' }
| { kind: 'group'; key: string }
| { kind: 'item'; key: string; itemId: string }
| { kind: 'api' }
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' }
if (
(segments[0] === 'g' || segments[0] === 'c') &&
segments[1] !== undefined
@@ -132,14 +142,24 @@ function findEntry(
}
function groupEntryCount(group: GroupNode): number {
return group.entries.length + group.children.reduce((total, child) => total + groupEntryCount(child), 0)
const children = group.children.filter((child) => child.enabled !== false)
return group.entries.length + children.reduce((total, child) => total + groupEntryCount(child), 0)
}
/** 分组自身与所有祖先都启用时才可见:停用父级会连带停用整棵子树 */
function isGroupVisible(groups: GroupNode[], key: string): boolean {
const path = findPath(groups, key)
if (path === undefined) return false
return path.every((node) => node.enabled !== false)
}
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
function collectEntries(group: GroupNode): Record<string, unknown>[] {
return [
...(group.entries as unknown as Record<string, unknown>[]),
...group.children.flatMap((child) => collectEntries(child)),
...group.children
.filter((child) => child.enabled !== false)
.flatMap((child) => collectEntries(child)),
]
}
@@ -161,8 +181,6 @@ function matches(entry: Record<string, unknown>, keyword: string): boolean {
return text.includes(keyword)
}
const TOP_OFFSET = 104
export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute()
@@ -189,7 +207,14 @@ export default function AppNext() {
void fetchSettings().then(setSettings)
const source = new EventSource('/api/catalog/events')
source.onmessage = () => load()
// 服务端推的是具名事件 `catalog`(不是默认的 message),两种都接;
// 收到后连站点设置一起重拉,改完设置不用手动刷新页面。
const onCatalogChange = () => {
load()
void fetchSettings().then(setSettings)
}
source.onmessage = onCatalogChange
source.addEventListener('catalog', onCatalogChange)
return () => source.close()
}, [])
@@ -205,77 +230,51 @@ export default function AppNext() {
return
}
// 左右两栏放进同一个滚动容器:滚动区域从搜索框下方开始,两栏位移完全一致
const sider = body.querySelector('aside')
// 左右两栏放进同一个滚动容器:滚动区域从搜索条下方开始,两栏位移完全一致。
// 左栏的吸顶交给 CSS position: sticky(App.css 里 #page-scroll > aside):
// 之前用 scroll 事件改 transform,主线程永远落后平滑滚动一帧,看起来就在抖。
// 注意别再给 aside 设内联 overflow,否则会盖掉 CSS 里的 overflow-y: auto,
// 侧栏比视口高时就没法在栏内滚动了。
body.id = 'page-scroll'
body.style.overflowY = 'auto'
body.style.overflowX = 'hidden'
body.style.marginTop = `${TOP_OFFSET}px`
body.style.height = `calc(100% - ${TOP_OFFSET}px)`
content.style.overflow = 'visible'
content.style.marginTop = '0'
main.style.height = 'auto'
if (sider instanceof HTMLElement) {
sider.style.overflow = 'visible'
sider.style.height = 'auto'
sider.style.alignSelf = 'flex-start'
}
const onScroll = () => setScrolled(body.scrollTop > 4)
// 顶部软虚化层:覆盖搜索区域与内容顶部,边缘用 mask 渐隐,避免硬分割线
const blur = document.createElement('div')
blur.className = 'top-blur'
blur.setAttribute('aria-hidden', 'true')
const appRoot = document.querySelector('.app')
appRoot?.appendChild(blur)
const updateSticky = () => {
const viewport = body.clientHeight
const maxLeft = Math.max(0, sider instanceof HTMLElement ? sider.offsetHeight - viewport : 0)
const offset = Math.max(0, body.scrollTop - maxLeft)
if (sider instanceof HTMLElement) {
sider.style.transform = offset > 0 ? `translateY(${offset}px)` : ''
}
// 滚动容器只覆盖 1380 的两栏,窗口两侧的空白、页头与搜索条都在它外面,
// 滚轮落在这些地方时浏览器找不到可滚动元素。统一转发给内容区。
const forwardWheel = (event: WheelEvent) => {
if (event.target instanceof Node && body.contains(event.target)) return
// deltaMode 1 = 按行(Firefox 鼠标滚轮),换算成像素
body.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
event.preventDefault()
}
window.addEventListener('wheel', forwardWheel, { passive: false })
body.addEventListener('scroll', onScroll, { passive: true })
body.addEventListener('scroll', updateSticky, { passive: true })
window.addEventListener('resize', updateSticky)
const observer = new ResizeObserver(updateSticky)
observer.observe(content)
if (sider instanceof HTMLElement) observer.observe(sider)
onScroll()
updateSticky()
return () => {
body.removeEventListener('scroll', onScroll)
body.removeEventListener('scroll', updateSticky)
window.removeEventListener('resize', updateSticky)
observer.disconnect()
blur.remove()
window.removeEventListener('wheel', forwardWheel)
body.id = ''
body.style.overflowY = ''
body.style.overflowX = ''
body.style.marginTop = ''
body.style.height = ''
content.style.overflow = ''
content.style.marginTop = ''
main.style.height = ''
if (sider instanceof HTMLElement) {
sider.style.overflow = ''
sider.style.height = ''
sider.style.alignSelf = ''
sider.style.transform = ''
}
}
}, [])
const site: SiteSettings = settings?.settings ?? {
header: { enabled: true, speed: 24, items: [] },
header: { enabled: true, speed: 24, gap: 26, items: [] },
sidebar: {
show_profile: true,
show_service: true,
show_nav: true,
hidden: [],
order: [],
hidden_groups: [],
@@ -292,30 +291,63 @@ export default function AppNext() {
location: '',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
theme: 'auto',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { theme: true, refresh: true, settings: true, session: true },
},
}
const keywordLower = keyword.trim().toLowerCase()
const sidebarItems: MenuItem[] = useMemo(() => {
const hidden = new Set(site.sidebar.tree_hidden)
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => {
const ranks = new Map(
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const),
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
// 深色由组件库的令牌块接管,页面与组件都不用改
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
readThemeOverride(),
)
return [...nodes].sort((a, b) => {
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
if (left === right) return a.sort - b.sort
return left - right
})
const effectiveTheme = themeOverride ?? site.site.theme
useEffect(() => {
document.documentElement.dataset.scheme = effectiveTheme
}, [effectiveTheme])
/** 当前实际是深色吗(auto 时按系统偏好算) */
const isDark = isDarkScheme(effectiveTheme)
/** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */
const toggleTheme = () => {
const next: ThemeChoice = isDark ? 'light' : 'dark'
setThemeOverride(next)
writeThemeOverride(next)
}
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
sortNodes(nodes, parentKey).flatMap((node) => {
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
useEffect(() => {
const title = site.site.title.trim()
if (title !== '') document.title = title
const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim()
let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (link === null) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
link.type = href.endsWith('.svg') ? 'image/svg+xml' : ''
}, [site.site.title, site.site.favicon])
const sidebarItems: MenuItem[] = useMemo(() => {
// 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
// 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
const build = (nodes: GroupNode[]): MenuItem[] =>
[...nodes]
.sort((a, b) => a.sort - b.sort)
.flatMap((node) => {
if (node.enabled === false) return []
if (hidden.has(node.key)) return build(node.children, node.key)
const children = build(node.children, node.key)
const children = build(node.children)
return [
{
key: `g:${node.key}`,
@@ -325,11 +357,11 @@ export default function AppNext() {
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
}, [manage.groups])
const activeKey =
route.kind === 'home'
route.kind === 'home' || route.kind === 'api'
? 'home'
: route.kind === 'group'
? `g:${route.key}`
@@ -367,11 +399,16 @@ export default function AppNext() {
}
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
const all = collectEntries(group).filter((entry) => matches(entry, keywordLower))
// 根分组(分区)只展示直接挂在它下面的条目,子分组各自成块,避免同一批内容出现两次
const rootGroup = group.parentKey === undefined || group.parentKey === null
const source = rootGroup
? (group.entries as unknown as Record<string, unknown>[])
: collectEntries(group)
const all = source.filter((entry) => matches(entry, keywordLower))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
{showTitle && <h3 className="card-group-title">{group.title}</h3>}
{showTitle && !rootGroup && <h3 className="card-group-title">{group.title}</h3>}
{renderEntries(group, all, { limit })}
</div>
)
@@ -406,7 +443,7 @@ export default function AppNext() {
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
.filter(
(item): item is { ref: typeof item.ref; group: GroupNode } =>
item.group !== undefined && item.group.enabled !== false,
item.group !== undefined && isGroupVisible(manage.groups, item.ref.groupKey),
)
const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
@@ -445,11 +482,13 @@ export default function AppNext() {
function renderGroupPage(key: string) {
const group = findGroup(manage.groups, key)
if (group === undefined) {
if (group === undefined || !isGroupVisible(manage.groups, key)) {
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
}
const trail = findPath(manage.groups, key) ?? [group]
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
const rootGroup = group.parentKey === undefined || group.parentKey === null
return (
<div className="detail-view">
@@ -473,7 +512,7 @@ export default function AppNext() {
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{group.icon || '•'}
<GroupMark icon={group.icon} fallback="•" />
</span>
<h2 className="detail-hero-title">{group.title}</h2>
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
@@ -486,7 +525,9 @@ export default function AppNext() {
)}
<Space direction="column" gap="lg" align="stretch">
{group.children.map((child) => (
{group.children
.filter((child) => child.enabled !== false)
.map((child) => (
<section key={child.key} className="group-child-block">
<div className="group-title">
<span
@@ -495,6 +536,7 @@ export default function AppNext() {
aria-hidden="true"
/>
<h3>{child.title}</h3>
{!rootGroup && (
<button
type="button"
className="link-button"
@@ -502,9 +544,10 @@ export default function AppNext() {
>
查看全部
</button>
)}
</div>
{child.entries.length > 0 ? (
renderEntries(child, child.entries, { limit: 12 })
renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
) : (
<Empty text="暂无内容" />
)}
@@ -517,7 +560,7 @@ export default function AppNext() {
function renderItemPage(key: string, itemId: string) {
const found = findEntry(manage.groups, key, itemId)
if (found === undefined) {
if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) {
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
}
@@ -613,7 +656,6 @@ export default function AppNext() {
headerVariant="solid"
siderBordered={false}
contentPadding="none"
contentOffset={TOP_OFFSET}
height="screen"
scrollable
hideScrollbar
@@ -627,9 +669,17 @@ export default function AppNext() {
headerItems={site.header.items}
headerEnabled={site.header.enabled}
headerSpeed={site.header.speed}
headerGap={site.header.gap}
site={site.site}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
refreshing={refreshing}
dark={isDark}
onToggleTheme={() => void toggleTheme()}
onRefresh={() => {
// 刷新 = 整页重载:项目扫描、内容库、设置、主题都重新走一遍
window.location.reload()
}}
onLogin={() => setLoginOpen(true)}
onLogout={() => {
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
@@ -637,6 +687,19 @@ export default function AppNext() {
onSettings={() => window.location.assign('/admin')}
/>
}
toolbar={
<div className="search-toolbar">
<SearchField
value={keyword}
onChange={setKeyword}
inputRef={searchRef}
placeholder="搜索本地项目…"
label="搜索本地项目"
variant="card"
hotkey="k"
/>
</div>
}
sider={
<div className="sidebar-wrap">
<div className="sidebar sidebar-home">
@@ -647,8 +710,10 @@ export default function AppNext() {
live={live}
refreshing={refreshing}
onRefresh={refresh}
onOpenApi={() => navigate('/interface')}
/>
)}
{site.sidebar.show_nav !== false && (
<div className="nav-card">
<Menu
items={sidebarItems}
@@ -660,12 +725,12 @@ export default function AppNext() {
}}
/>
</div>
)}
</div>
</div>
}
>
<div className="content">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
@@ -679,6 +744,7 @@ export default function AppNext() {
)}
{route.kind === 'home' && renderHome()}
{route.kind === 'api' && <ApiDocs />}
{route.kind === 'group' && renderGroupPage(route.key)}
{route.kind === 'item' && renderItemPage(route.key, route.itemId)}
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
import { Card, Space, Table, Tag, Text, Title } from '@stash/ui'
/**
* 接口一览:把 `/api/*` 按用途分组列出来。
*
* 页面只做展示,不请求后端——清单跟 `src/server.rs` 的路由表人工对齐,
* 改接口时顺手改这里(文件顶部有分组与权限说明)。
*/
type Endpoint = {
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
path: string
summary: string
/** 需要登录会话 */
auth?: boolean
}
const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
{
title: '健康与项目',
desc: '托管目录的扫描结果与实时推送。',
items: [
{ method: 'GET', path: '/api/health', summary: '存活检查,返回服务名与状态' },
{ method: 'GET', path: '/api/projects', summary: '扫描托管目录(每次请求都重新扫描)' },
{ method: 'GET', path: '/api/projects/events', summary: 'SSE:目录变化时推送最新项目列表' },
],
},
{
title: '内容库',
desc: '分区 / 分组 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
items: [
{ method: 'GET', path: '/api/catalog', summary: '旧版完整目录(兼容保留)' },
{ method: 'GET', path: '/api/catalog2', summary: '分组树 + 卡片,后台配置用' },
{ method: 'GET', path: '/api/catalog/events', summary: 'SSE:内容库变更时推送「该重拉」' },
{ method: 'POST', path: '/api/nodes', summary: '新建分区 / 分组', auth: true },
{ method: 'PATCH', path: '/api/nodes/{key}', summary: '改标题、所属、图标、排序、启停', auth: true },
{ method: 'DELETE', path: '/api/nodes/{key}', summary: '删除分区 / 分组(级联子节点)', auth: true },
{ method: 'GET', path: '/api/items/{key}', summary: '条目详情' },
{ method: 'POST', path: '/api/items', summary: '新建条目', auth: true },
{ method: 'PATCH', path: '/api/items/{key}', summary: '就地编辑(带 updatedAt 乐观锁)', auth: true },
{ method: 'DELETE', path: '/api/items/{key}', summary: '删除条目', auth: true },
{ method: 'POST', path: '/api/items/{key}/view', summary: '浏览数 +1' },
{ method: 'POST', path: '/api/items/{key}/like', summary: '点赞 / 取消点赞' },
{ method: 'POST', path: '/api/cards', summary: '新建首页卡片', auth: true },
{ method: 'PATCH', path: '/api/cards/{id}', summary: '改卡片标题 / 顺序', auth: true },
{ method: 'DELETE', path: '/api/cards/{id}', summary: '删除卡片', auth: true },
{ method: 'PUT', path: '/api/cards/{id}/groups', summary: '整组替换卡片包含的分组与顺序', auth: true },
{ method: 'POST', path: '/api/categories', summary: '旧版:新建分类', auth: true },
{ method: 'PATCH', path: '/api/categories/{id}', summary: '旧版:改分类', auth: true },
{ method: 'DELETE', path: '/api/categories/{id}', summary: '旧版:删分类', auth: true },
{ method: 'POST', path: '/api/categories/{id}/groups', summary: '旧版:新建分组', auth: true },
{ method: 'PATCH', path: '/api/groups/{id}', summary: '旧版:改分组', auth: true },
{ method: 'DELETE', path: '/api/groups/{id}', summary: '旧版:删分组', auth: true },
],
},
{
title: '站点设置',
desc: '页头、侧边栏、卡片、资料、站点信息等整份 JSON。',
items: [
{ method: 'GET', path: '/api/settings', summary: '读取设置(含 version 乐观锁)' },
{ method: 'PATCH', path: '/api/settings', summary: '整份保存设置', auth: true },
{ method: 'GET', path: '/api/backup', summary: '下载 SQLite 快照', auth: true },
],
},
{
title: '认证',
desc: '单管理员账号;会话 token 只存 sha256,Cookie 为 HttpOnly + SameSite=Strict。',
items: [
{ method: 'GET', path: '/api/auth/session', summary: '当前会话状态(是否登录、是否已设密码)' },
{ method: 'POST', path: '/api/auth/setup', summary: '首次设置管理员密码(仅空库可用)' },
{ method: 'POST', path: '/api/auth/login', summary: '登录;连续失败 5 次锁 60 秒' },
{ method: 'POST', path: '/api/auth/logout', summary: '退出并清掉会话' },
{ method: 'PATCH', path: '/api/auth/password', summary: '改密码(需当前密码)', auth: true },
],
},
]
const METHOD_TONE = {
GET: 'neutral',
POST: 'primary',
PATCH: 'warning',
PUT: 'warning',
DELETE: 'danger',
} as const
export function ApiDocs() {
return (
<div className="api-docs">
<Space direction="column" gap="sm" align="stretch">
<Title level={2}>接口一览</Title>
<Text size="sm" tone="muted">
说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源
Origin;带 <Tag tone="primary">需登录</Tag> 标记的即为写操作。
</Text>
</Space>
{GROUPS.map((group) => (
<Card key={group.title} padding="lg">
<Space direction="column" gap="sm" align="stretch">
<Space gap="sm" align="center">
<Title level={4} as="h2">
{group.title}
</Title>
<Text size="sm" tone="muted">
{group.items.length} 个
</Text>
</Space>
<Text size="sm" tone="muted">
{group.desc}
</Text>
<Table
rowKey="id"
columns={[
{ key: 'method', title: '方法', width: 90 },
{ key: 'path', title: '路径', width: 280 },
{ key: 'summary', title: '说明' },
{ key: 'auth', title: '权限', width: 100 },
]}
rows={group.items.map((item) => ({
id: `${item.method} ${item.path}`,
method: <Tag tone={METHOD_TONE[item.method]}>{item.method}</Tag>,
path: <code className="api-path">{item.path}</code>,
summary: item.summary,
auth: item.auth === true ? <Tag tone="primary">需登录</Tag> : <Text tone="muted">公开</Text>,
}))}
/>
</Space>
</Card>
))}
</div>
)
}
@@ -0,0 +1,14 @@
import { Icon, ICON_NAMES, type IconName } from '@stash/ui'
/**
* 分组图标:能对上 iconfont 名字就渲染图标,否则按字符渲染。
*
* 旧数据里存的是 emoji / 任意字符,新数据存图标名(如 `folder_2_line`);
* 两种都要能显示,所以这里做一次判定,前后台共用同一套规则。
*/
export function GroupMark({ icon, fallback = '' }: { icon: string; fallback?: string }) {
const value = icon.trim()
if (value === '') return fallback === '' ? null : <>{fallback}</>
if (ICON_NAMES.includes(value as IconName)) return <Icon name={value as IconName} />
return <>{value}</>
}
+8 -30
View File
@@ -1,9 +1,9 @@
import { Avatar, ItemBadge, Space, Text } from '@stash/ui'
import { EntryRow, ItemBadge } from '@stash/ui'
import type { AppItem } from '../../data/types'
/**
* 应用卡片:头像 + 名称(热门带 HOT 角标)+ 单行描述。
* 点击进入条目详情;按住修饰键时保持浏览器默认行为(新标签打开原始链接)。
* 版式与点击语义都在库里的 EntryRow,这里只做数据映射。
*/
type AppCardProps = {
item: AppItem
@@ -12,37 +12,15 @@ type AppCardProps = {
export function AppCard({ item, onOpen }: AppCardProps) {
return (
<a
className="app-card"
href={item.href}
<EntryRow
title={item.name}
onClick={(event) => {
if (onOpen === undefined) return
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
onOpen(item.id)
}}
>
<Avatar
size="lg"
tone="primary"
className="app-card-avatar"
src={item.cover}
description={item.desc}
href={item.href}
avatar={item.cover}
fallback={item.icon}
verified={item.hot === true}
badge={item.hot === true ? <ItemBadge tone="hot">HOT</ItemBadge> : undefined}
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
<div className="app-card-info">
<Space gap="xs">
<Text strong truncate className="app-card-name">
{item.name}
</Text>
{item.hot === true && <ItemBadge tone="hot">HOT</ItemBadge>}
</Space>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</div>
</a>
)
}
+7 -33
View File
@@ -1,9 +1,9 @@
import { Card, Cover, Space, Text } from '@stash/ui'
import { EntryTile } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
* 明细页图文卡:上图下文的白卡。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
* 版式与点击语义都在库里的 EntryTile,这里只做数据映射。
*/
type CesCardProps = {
item: ArticleItem
@@ -11,38 +11,12 @@ type CesCardProps = {
}
export function CesCard({ item, onOpen }: CesCardProps) {
const body = (
<Space direction="column" gap="xs" align="stretch">
<Text strong truncate>
{item.title}
</Text>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</Space>
)
return (
<Card padding="none" hoverable className="ces-card">
<Cover
src={item.cover}
alt={item.title}
ratio={16 / 10}
radius="none"
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
{onOpen === undefined ? (
<div className="ces-body">{body}</div>
) : (
<button
type="button"
className="ces-body ces-body--button"
<EntryTile
title={item.title}
onClick={() => onOpen(item.id)}
>
{body}
</button>
)}
</Card>
description={item.desc}
cover={item.cover}
onOpen={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
+64 -81
View File
@@ -1,9 +1,15 @@
import { Icon, IconButton, Tag } from '@stash/ui'
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings'
import { Brand, Icon, IconButton, Tag, Ticker } from '@stash/ui'
import {
fillHeaderText,
type HeaderItem,
type SettingsTone,
type SiteMetaSettings,
} from '../../settings'
/**
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
* 品牌与操作图标是否出现,都来自「页头」Tab 的站点设置。
* 外壳(品牌、跑马灯)都用组件库,这里只拼内容。
*/
type SiteHeaderProps = {
projectCount: number
@@ -15,10 +21,19 @@ type SiteHeaderProps = {
headerEnabled: boolean
/** 滚动一轮的秒数 */
headerSpeed: number
/** 信息条目之间的间距(px) */
headerGap?: number
/** 品牌与操作工具配置 */
site: SiteMetaSettings
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean
username?: string
onFocusSearch: () => void
/** 刷新中:刷新按钮转圈 */
refreshing?: boolean
/** 当前是否为深色,决定明暗按钮的图标与文案 */
dark?: boolean
onRefresh: () => void
onToggleTheme: () => void
onLogin: () => void
onLogout: () => void
onSettings: () => void
@@ -31,27 +46,39 @@ const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
success: 'success',
}
type InfoGroupProps = {
items: HeaderItem[]
projectCount: number
sampleProject?: string
live: boolean
'aria-hidden'?: boolean
}
/** 运行信息:同一份内容渲染三遍,轨道平移 1/3 形成无缝滚动 */
function InfoGroup({
items,
export function SiteHeader({
projectCount,
sampleProject,
live,
'aria-hidden': ariaHidden,
}: InfoGroupProps) {
headerItems,
headerEnabled,
headerSpeed,
headerGap,
site,
authenticated,
username,
refreshing = false,
dark = false,
onRefresh,
onToggleTheme,
onLogin,
onLogout,
onSettings,
}: SiteHeaderProps) {
const context = { count: projectCount, project: sampleProject, live }
return (
<span className="header-ticker-group" aria-hidden={ariaHidden}>
{items.map((item, index) => (
<div className="site-header-inner">
<Brand
href="/"
name={site.brand.name}
mark={site.brand.mark}
logo={site.brand.logo}
/>
{headerEnabled && (
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
{headerItems.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}>
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
<Tag tone={tagTone[item.tone]} shape="rounded">
@@ -61,74 +88,30 @@ function InfoGroup({
{fillHeaderText(item.text, context)}
</span>
))}
</span>
)
}
export function SiteHeader({
projectCount,
sampleProject,
live,
headerItems,
headerEnabled,
headerSpeed,
authenticated,
username,
onFocusSearch,
onLogin,
onLogout,
onSettings,
}: SiteHeaderProps) {
return (
<div className="site-header-inner">
<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>
{headerEnabled && (
<div className="header-ticker" role="status" aria-label="运行状态">
<div
className="header-ticker-track"
style={{ animationDuration: `${headerSpeed}s` }}
>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
/>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
// 后两份只用于视觉无缝,读屏忽略
aria-hidden
/>
<InfoGroup
items={headerItems}
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
aria-hidden
/>
</div>
</div>
</Ticker>
)}
<div className="header-actions">
{site.actions.theme && (
<IconButton
label="聚焦搜索框(Ctrl+K)"
icon={<Icon name="search_3_line" />}
onClick={onFocusSearch}
label={dark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={dark ? 'sun_line' : 'moon_line'} />}
onClick={onToggleTheme}
/>
{authenticated && (
)}
{site.actions.refresh && (
<IconButton
label="刷新(重扫项目并重拉内容)"
icon={<Icon name="lightning_line" />}
spinning={refreshing}
onClick={onRefresh}
/>
)}
{site.actions.settings && authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
)}
{authenticated ? (
{site.actions.session &&
(authenticated ? (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
@@ -136,7 +119,7 @@ export function SiteHeader({
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
)}
))}
</div>
</div>
)
@@ -1,47 +0,0 @@
import { Input } from '@stash/ui'
import { useEffect, type RefObject } from 'react'
/**
* 全局搜索框:吸在内容区顶部。
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
*/
type SearchBarProps = {
value: string
onChange: (value: string) => void
inputRef: RefObject<HTMLInputElement | null>
}
export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
inputRef.current?.focus()
inputRef.current?.select()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [inputRef])
return (
<div className="search-row">
<Input
inputRef={inputRef}
size="lg"
icon="search_3_line"
value={value}
placeholder="搜索本地项目…"
aria-label="搜索本地项目"
clearable
onClear={() => onChange('')}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') onChange('')
}}
/>
</div>
)
}
@@ -1,86 +0,0 @@
import { Card, Menu, type MenuItem } from '@stash/ui'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
/**
* 分类导航树:由内容树派生。
*
* - 有分组的分类(apps)是折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
* - 没有分组、或分组全被隐藏的分类,就是一个普通菜单项。
*
* 折叠状态由组件库的 Menu 内部维护;这里只负责把内容树翻译成 items。
*/
type CategoryNavProps = {
/** 内容目录;顺序即侧栏顺序 */
categories: Category[]
/** 当前明细视图的分类 / 分组,用于高亮 */
activeCategoryId?: string
activeGroupId?: string
/** 是否在首页(高亮「首页」项) */
homeActive?: boolean
onNavigate: (target: NavTarget) => void
/** 点击「首页」 */
onHome: () => void
}
const HOME_KEY = 'home'
const categoryKey = (id: string) => `cat:${id}`
const groupKey = (categoryId: string, groupId: string) => `group:${categoryId}:${groupId}`
export function CategoryNav({
categories,
activeCategoryId,
activeGroupId,
homeActive = false,
onNavigate,
onHome,
}: CategoryNavProps) {
const items: MenuItem[] = [
{ key: HOME_KEY, label: '首页' },
...categories.map((category) => {
// 「允许子分组」的分区才有 groups;空分组不出现在侧栏(后台仍保留)
const groups = category.groups.filter((group) => group.items.length > 0)
return {
key: categoryKey(category.id),
label: category.title,
children:
groups.length > 0
? groups.map((group) => ({
key: groupKey(category.id, group.id),
label: group.name,
}))
: undefined,
}
}),
]
const activeKey = homeActive
? HOME_KEY
: activeCategoryId === undefined
? undefined
: activeGroupId !== undefined
? groupKey(activeCategoryId, activeGroupId)
: categoryKey(activeCategoryId)
const handleSelect = (key: string) => {
if (key === HOME_KEY) {
onHome()
return
}
if (key.startsWith('group:')) {
const [, categoryId, groupId] = key.split(':')
onNavigate({ categoryId, groupId })
return
}
onNavigate({ categoryId: key.slice('cat:'.length) })
}
return (
<Card padding="none" className="nav-card">
<Menu items={items} activeKey={activeKey} onSelect={handleSelect} itemHeight={39} />
</Card>
)
}
@@ -10,9 +10,17 @@ type ServicePanelProps = {
live: boolean
refreshing: boolean
onRefresh: () => void
/** 打开接口一览:走站内路由,不整页刷新 */
onOpenApi: () => void
}
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
export function ServicePanel({
projects,
live,
refreshing,
onRefresh,
onOpenApi,
}: ServicePanelProps) {
const host = typeof window === 'undefined' ? '-' : window.location.host
return (
@@ -56,7 +64,16 @@ export function ServicePanel({ projects, live, refreshing, onRefresh }: ServiceP
重新扫描
</Button>
<a className="link-button" href="/api/projects" target="_blank" rel="noreferrer">
<a
className="link-button"
href="/interface"
onClick={(event) => {
// 无修饰键时接管点击,走站内路由,只换右侧内容;按住修饰键仍可新标签打开
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
onOpenApi()
}}
>
<Icon name="link_3_line" />
查看接口
</a>
@@ -1,64 +0,0 @@
import type { Project } from '../../api'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
import type { ProfileSettings } from '../../settings'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
/**
* 左侧栏容器:用户资料 + 本地服务面板 + 分类导航。
* 只负责布局与数据透传,具体内容由三个子组件分别渲染。
*/
type SidebarProps = {
projects: Project[]
/** 内容目录;侧栏直接由它派生 */
categories: Category[]
profile: ProfileSettings
live: boolean
refreshing: boolean
onRefresh: () => void
activeCategoryId?: string
activeGroupId?: string
/** 是否在首页 */
homeActive?: boolean
onNavigate: (target: NavTarget) => void
/** 点击「首页」 */
onHome: () => void
}
export function Sidebar({
projects,
categories,
profile,
live,
refreshing,
onRefresh,
activeCategoryId,
activeGroupId,
homeActive = false,
onNavigate,
onHome,
}: SidebarProps) {
return (
<div className="sidebar">
<ProfileCard profile={profile} />
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={onRefresh}
/>
<CategoryNav
categories={categories}
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
homeActive={homeActive}
onNavigate={onNavigate}
onHome={onHome}
/>
</div>
)
}
+8 -2
View File
@@ -189,9 +189,15 @@ export async function setCardGroups(
export function flattenGroups(
groups: GroupNode[],
depth = 0,
): { key: string; title: string; depth: number; format: GroupFormat }[] {
): { key: string; title: string; depth: number; format: GroupFormat; enabled: boolean }[] {
return groups.flatMap((group) => [
{ key: group.key, title: group.title, depth, format: group.format },
{
key: group.key,
title: group.title,
depth,
format: group.format,
enabled: group.enabled !== false,
},
...flattenGroups(group.children, depth + 1),
])
}
+44
View File
@@ -36,6 +36,8 @@ export type SiteSettings = {
enabled: boolean
/** 滚动一轮的秒数(8–120) */
speed: number
/** 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽 */
gap: number
items: HeaderItem[]
}
sidebar: {
@@ -43,6 +45,8 @@ export type SiteSettings = {
show_profile: boolean
/** 是否显示「本地服务」面板 */
show_service: boolean
/** 是否显示「分组导航」卡片(首页 / 分组树) */
show_nav: boolean
hidden: string[]
order: string[]
/** 隐藏的分组:`分区id/分组id` */
@@ -58,6 +62,37 @@ export type SiteSettings = {
}
sections: SectionSettings
profile: ProfileSettings
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
site: SiteMetaSettings
}
export type SiteTheme = 'light' | 'dark' | 'auto'
export type SiteMetaSettings = {
/** 浏览器标签标题 */
title: string
/** favicon 地址;空表示用内置图标 */
favicon: string
/** 配色:浅色 / 深色 / 跟随系统 */
theme: SiteTheme
brand: {
/** 页头品牌文字,可留空 */
name: string
/** 没有图片时圆形标记里的首字 */
mark: string
/** logo 图片地址;空表示用首字 */
logo: string
}
actions: {
/** 明暗切换 */
theme: boolean
/** 刷新图标(重扫本地项目并重拉内容库) */
refresh: boolean
/** 站点设置图标 */
settings: boolean
/** 登录 / 退出登录 */
session: boolean
}
}
export type SettingsDocument = {
@@ -81,6 +116,7 @@ export function defaultSettings(): SiteSettings {
header: {
enabled: true,
speed: 24,
gap: 26,
items: [
{ text: '共 {count} 个本地应用', tone: 'plain' },
{ text: '{project}', tag: '本地', tone: 'primary' },
@@ -90,6 +126,7 @@ export function defaultSettings(): SiteSettings {
sidebar: {
show_profile: true,
show_service: true,
show_nav: true,
hidden: [],
order: [],
hidden_groups: [],
@@ -106,6 +143,13 @@ export function defaultSettings(): SiteSettings {
location: 'IP属地:局域网',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
theme: 'auto',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { theme: true, refresh: true, settings: true, session: true },
},
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 明暗主题的「本机偏好」。
*
* 站点设置里的 `site.theme` 是**所有人**的默认值;这里的 localStorage 是**这台浏览器**
* 的选择,读到时优先于默认值。前台页头的切换按钮只改本机偏好,不需要登录;
* 后台「页头 → 配色」改的是站点默认值。
*/
export type ThemeChoice = 'light' | 'dark'
const STORAGE_KEY = 'stash-theme'
export function readThemeOverride(): ThemeChoice | null {
try {
const value = window.localStorage.getItem(STORAGE_KEY)
return value === 'light' || value === 'dark' ? value : null
} catch {
return null
}
}
export function writeThemeOverride(value: ThemeChoice | null): void {
try {
if (value === null) window.localStorage.removeItem(STORAGE_KEY)
else window.localStorage.setItem(STORAGE_KEY, value)
} catch {
// 隐私模式下 localStorage 可能不可写,忽略即可
}
}
/** `light` / `dark` / `auto` 里,当前实际是不是深色 */
export function isDarkScheme(scheme: string): boolean {
if (scheme === 'dark') return true
if (scheme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}