From 59fd61d83220c5f33d81ce0d6950fc862e75c538 Mon Sep 17 00:00:00 2001 From: CNWei Date: Sun, 1 Nov 2026 15:15:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=90=9C=E7=B4=A2=E5=B8=A6=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=20Layout=20toolbar=EF=BC=8C=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E9=87=8D=E7=BB=84=E9=A6=96=E9=A1=B5=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前台 - 搜索框从内容列移到 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) --- src/db/settings.rs | 84 ++++ src/server.rs | 74 +++- stash-web/src/App.css | 206 +++++++--- stash-web/src/App.tsx | 3 +- stash-web/src/AppNext.tsx | 223 +++++----- stash-web/src/components/admin/AdminNext.tsx | 386 +++++++++++++----- .../src/components/header/SiteHeader.tsx | 98 +++-- stash-web/src/manage.ts | 10 +- stash-web/src/settings.ts | 34 ++ 9 files changed, 822 insertions(+), 296 deletions(-) diff --git a/src/db/settings.rs b/src/db/settings.rs index 208a884..646aede 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -88,6 +88,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 +109,9 @@ pub struct SidebarSettings { /// 是否显示「本地服务」面板 #[serde(default = "default_true")] pub show_service: bool, + /// 是否显示「分组导航」卡片(首页 / 分组树) + #[serde(default = "default_true")] + pub show_nav: bool, /// 隐藏的分类 id pub hidden: Vec, /// 分类显示顺序(id);未列出的排在后面,保持原有顺序 @@ -160,6 +164,70 @@ 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 { + /// 刷新图标(重扫本地项目并重拉内容库) + pub refresh: bool, + /// 站点设置图标(仅登录后出现) + pub settings: bool, + /// 登录 / 退出登录 + pub session: bool, +} + +impl Default for ActionSettings { + fn default() -> Self { + Self { + refresh: true, + settings: true, + session: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct SiteMetaSettings { + /// 浏览器标签标题 + pub title: String, + /// favicon 地址;空字符串表示用内置图标 + pub favicon: String, + pub brand: BrandSettings, + pub actions: ActionSettings, +} + +impl Default for SiteMetaSettings { + fn default() -> Self { + Self { + title: "Stash · 本地应用工作区".into(), + favicon: String::new(), + brand: BrandSettings::default(), + actions: ActionSettings::default(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct Settings { @@ -167,6 +235,7 @@ pub struct Settings { pub sidebar: SidebarSettings, pub sections: SectionsSettings, pub profile: ProfileSettings, + pub site: SiteMetaSettings, } /// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。 @@ -244,6 +313,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(()) } diff --git a/src/server.rs b/src/server.rs index bc1a1ff..2eac9f1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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>, /// 内容库:分类 / 分组 / 条目的唯一数据源 pub db: Arc, + /// 内容库写入后的广播:SSE 用它对所有打开的页面推「该重拉」。 + /// 不能只靠 `PRAGMA data_version`——服务自己的连接写完不会让它变化。 + pub catalog_updates: broadcast::Sender<()>, /// 登录失败计数(单用户本地场景,一个进程一份就够) pub login_guard: Arc>, } @@ -234,30 +238,29 @@ async fn catalog_api(State(state): State) -> Response { /// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。 async fn catalog_events(State(state): State) -> 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::( @@ -273,6 +276,36 @@ async fn catalog_events(State(state): State) -> impl IntoResponse { ) } +/// 任何写请求成功后,通知所有打开的页面「内容库变了」。 +/// +/// 放在中间件里而不是逐个 handler 调:以后新增写接口不用再记得挂号。 +/// 登录相关接口不涉及内容库,跳过以免无谓刷新。 +async fn notify_catalog_writes( + State(state): State, + 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) -> 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 { 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 { 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())), }) } diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 2681128..c4886fa 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -6,7 +6,7 @@ z-index: 1; /* 透明:让固定背景图从下面透出来(Layout 默认有底色) */ background-color: transparent; - /* 旧版阴影是 2% / 6%,比库默认更深,属于主题级覆盖 */ + /* 旧版影子是 2% / 6%,比库默认更深,属于主题级覆盖 */ --su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02); --su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06); } @@ -63,6 +63,24 @@ font-weight: var(--su-font-weight-strong); } +.site-brand-logo { + width: 32px; + height: 32px; + border-radius: var(--su-radius-sm); + object-fit: contain; +} + +/* 后台「首页 Logo」的实时预览 */ +.brand-preview { + display: flex; + align-items: center; + gap: var(--su-space-2); + padding: var(--su-space-3) var(--su-space-4); + border: 1px dashed var(--su-border); + border-radius: var(--su-radius-md); + background-color: var(--su-bg-card); +} + .site-brand-name { font-size: var(--su-font-size-lg); line-height: var(--su-line-height-lg); @@ -114,8 +132,10 @@ transform: translateX(0); } + /* 位移量由 SiteHeader 量出「一组」的真实宽度写进 --ticker-shift; + 回退值仅在第一帧兜底,正常情况不会用到 */ to { - transform: translateX(calc(-100% / 3)); + transform: translateX(var(--ticker-shift, -33.3333%)); } } @@ -129,16 +149,18 @@ margin-left: auto; } -/* ---------- 搜索:悬浮在页头下方居中 ---------- */ +/* ---------- 搜索:页头下的工具条(Layout 的 toolbar 插槽) ---------- */ + +.search-toolbar { + display: flex; + justify-content: center; + /* 整条带 120(32 + 44 + 44):搜索框比贴着页头时再往下挪 16 */ + padding: var(--su-space-8) 0 calc(var(--su-space-10) + var(--su-space-1)); +} .search-row { - position: fixed; - top: 76px; - left: 50%; - z-index: var(--su-z-header); - width: min(480px, calc(100vw - 48px)); + width: min(480px, 100%); height: 44px; - transform: translateX(-50%); } /* 旧版搜索是「浅边框 + 卡片阴影」,聚焦时不额外加边框/焦点环 */ @@ -151,31 +173,6 @@ /* 搜索框保持实底清晰,透明度/虚化只作用于周围区域 */ -/* 搜索区域软虚化:上下都用 mask 渐隐,不出现横向分割线 */ -.top-blur { - position: fixed; - top: 60px; - right: 0; - left: 0; - z-index: 4; - height: 120px; - pointer-events: none; - background: linear-gradient( - to bottom, - color-mix(in srgb, var(--su-bg-page) 22%, transparent) 0%, - transparent 80% - ); - backdrop-filter: blur(8px); - mask-image: linear-gradient(to bottom, transparent 0%, #000 28%, #000 62%, transparent 100%); - -webkit-mask-image: linear-gradient( - to bottom, - transparent 0%, - #000 28%, - #000 62%, - transparent 100% - ); -} - /* ---------- 两栏骨架 ---------- */ .content { @@ -214,7 +211,7 @@ display: flex; flex-direction: column; gap: var(--su-space-4); - padding-top: 88px; + padding-top: 0; } /* ---------- 侧栏卡片 ---------- */ @@ -855,13 +852,11 @@ background-color: var(--su-bg-page); } +/* 与前台页头同一套:整条吸顶,内部 .site-header-inner 负责 60 高与限宽 */ .admin-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--su-space-4); - height: 64px; - padding: 0 var(--su-space-6); + position: sticky; + top: 0; + z-index: var(--su-z-header); border-bottom: 1px solid var(--su-border); background-color: var(--su-bg-card); } @@ -949,15 +944,12 @@ display: none; } - .search-row { - position: static; - width: 100%; - height: 36px; - transform: none; + .search-toolbar { + padding: var(--su-space-5) 0 var(--su-space-6); } .content { - padding-top: var(--su-space-4); + padding-top: var(--su-space-2); } } @@ -1326,7 +1318,7 @@ a.entry-href:hover { min-width: 0; } -/* 首页:左右栏共用一个滚动区,左侧到底后吸底 */ +/* 首页:左右栏共用一个滚动区,左侧吸顶交给 CSS sticky */ #page-scroll { scrollbar-width: none; -ms-overflow-style: none; @@ -1336,6 +1328,46 @@ a.entry-href:hover { display: none; } +/* 左栏吸顶:sticky 由滚动引擎处理,不会像 JS 改 transform 那样落后平滑滚动一帧。 + 高度锁在滚动区高度内,栏内只有「分组导航」可滚,资料卡与本地服务保持不动。 */ +#page-scroll > aside { + position: sticky; + top: 0; + align-self: flex-start; + height: 100%; + max-height: 100%; + overflow: hidden; +} + +#page-scroll > aside .sidebar-wrap, +#page-scroll > aside .sidebar-home { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +#page-scroll > aside .profile-card, +#page-scroll > aside .service-panel { + flex-shrink: 0; +} + +/* 只有导航卡参与滚动,滚动条隐藏 */ +#page-scroll > aside .nav-card { + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + -ms-overflow-style: none; +} + +#page-scroll > aside .nav-card::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + .sidebar-home { padding-top: 0; padding-bottom: 24px; @@ -1477,10 +1509,29 @@ a.entry-href:hover { .cards-table-wrap { margin-top: var(--su-space-2); + /* 列多了以后表格会比面板宽,横向滚动而不是把名字挤成两三行 */ + overflow-x: auto; +} + +.cards-table th, +.cards-table td { + padding: var(--su-space-2); } .cards-col-limit { - width: 140px; + width: 100px; +} + +/* 分组名不换行,超长省略;列宽固定,保证五列都落在面板内 */ +.cards-table { + table-layout: fixed; +} + +.cards-table td:first-child .group-name { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .cards-table th, @@ -1488,6 +1539,63 @@ a.entry-href:hover { padding: var(--su-space-2) var(--su-space-4); } +/* 首页 Tab:侧边栏设置 + 首页卡片上下两段 */ +.admin-home { + display: flex; + flex-direction: column; + gap: var(--su-space-4); +} + +.admin-home-section { + display: flex; + flex-direction: column; + gap: var(--su-space-3); +} + +/* 首页卡片:分组展示顺序(表格里的「顺序」列,上下箭头即时保存) */ +.cards-col-state { + width: 72px; +} + +.cards-col-order { + width: 92px; +} + +.card-order-cell { + display: flex; + align-items: center; + gap: 2px; +} + +/* 分组管理里状态为「关闭」的分组:卡片里只能看、不能勾选 */ +.group-off-tag { + display: inline-block; + padding: 0 8px; + border-radius: var(--su-radius-pill); + background-color: var(--su-gray-2); + color: var(--su-text-muted); + font-size: var(--su-font-size-xs); + line-height: 20px; + white-space: nowrap; +} + +.group-state-on { + font-size: var(--su-font-size-xs); + color: var(--su-text-muted); +} + +.card-order-index { + display: grid; + place-items: center; + width: 20px; + height: 20px; + margin-right: var(--su-space-1); + border-radius: var(--su-radius-pill); + background-color: var(--su-color-primary-soft); + color: var(--su-color-primary); + font-size: var(--su-font-size-xs); +} + @media (max-width: 860px) { .cards-manager { grid-template-columns: 1fr; diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 4f412df..a5717df 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -341,9 +341,10 @@ export default function App() { headerItems={settings.settings.header.items} headerEnabled={settings.settings.header.enabled} headerSpeed={settings.settings.header.speed} + site={settings.settings.site} authenticated={session?.authenticated === true} username={session?.username ?? undefined} - onFocusSearch={() => searchRef.current?.focus()} + onRefresh={() => refresh()} onLogin={() => setLoginOpen(true)} onLogout={handleLogout} onSettings={() => window.location.assign('/admin')} diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index ef20199..020972c 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -132,14 +132,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[] { return [ ...(group.entries as unknown as Record[]), - ...group.children.flatMap((child) => collectEntries(child)), + ...group.children + .filter((child) => child.enabled !== false) + .flatMap((child) => collectEntries(child)), ] } @@ -161,8 +171,6 @@ function matches(entry: Record, 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 +197,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,69 +220,42 @@ 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 = '' - } } }, []) @@ -276,6 +264,7 @@ export default function AppNext() { sidebar: { show_profile: true, show_service: true, + show_nav: true, hidden: [], order: [], hidden_groups: [], @@ -292,41 +281,52 @@ export default function AppNext() { location: '', avatar: '', }, + site: { + title: 'Stash · 本地应用工作区', + favicon: '', + brand: { name: 'STASH', mark: 'S', logo: '' }, + actions: { refresh: true, settings: true, session: true }, + }, } const keywordLower = keyword.trim().toLowerCase() - const sidebarItems: MenuItem[] = useMemo(() => { - const hidden = new Set(site.sidebar.tree_hidden) + // 站点级设置:浏览器标签标题与 favicon(空值回落到内置) + useEffect(() => { + const title = site.site.title.trim() + if (title !== '') document.title = title - const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => { - const ranks = new Map( - (site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const), - ) - 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 href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim() + let link = document.head.querySelector('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 build = (nodes: GroupNode[], parentKey: string): MenuItem[] => - sortNodes(nodes, parentKey).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) - return [ - { - key: `g:${node.key}`, - label: node.title, - children: children.length > 0 ? children : undefined, - }, - ] - }) + const sidebarItems: MenuItem[] = useMemo(() => { + // 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航, + // 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。 + const build = (nodes: GroupNode[]): MenuItem[] => + [...nodes] + .sort((a, b) => a.sort - b.sort) + .flatMap((node) => { + if (node.enabled === false) return [] + const children = build(node.children) + return [ + { + key: `g:${node.key}`, + label: node.title, + children: children.length > 0 ? children : undefined, + }, + ] + }) - 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' @@ -367,11 +367,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[]) + : collectEntries(group) + const all = source.filter((entry) => matches(entry, keywordLower)) if (all.length === 0) return null return (
- {showTitle &&

{group.title}

} + {showTitle && !rootGroup &&

{group.title}

} {renderEntries(group, all, { limit })}
) @@ -406,7 +411,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 +450,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 } const trail = findPath(manage.groups, key) ?? [group] + // 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」 + const rootGroup = group.parentKey === undefined || group.parentKey === null return (
@@ -486,7 +493,9 @@ export default function AppNext() { )} - {group.children.map((child) => ( + {group.children + .filter((child) => child.enabled !== false) + .map((child) => (
{child.entries.length > 0 ? ( - renderEntries(child, child.entries, { limit: 12 }) + renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 }) ) : ( )} @@ -517,7 +528,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 } @@ -613,7 +624,6 @@ export default function AppNext() { headerVariant="solid" siderBordered={false} contentPadding="none" - contentOffset={TOP_OFFSET} height="screen" scrollable hideScrollbar @@ -627,9 +637,14 @@ export default function AppNext() { headerItems={site.header.items} headerEnabled={site.header.enabled} headerSpeed={site.header.speed} + site={site.site} authenticated={session?.authenticated === true} username={session?.username ?? undefined} - onFocusSearch={() => searchRef.current?.focus()} + refreshing={refreshing} + onRefresh={() => { + refresh() + load() + }} onLogin={() => setLoginOpen(true)} onLogout={() => { void logout().then(() => setSession({ authenticated: false, has_admin: true })) @@ -637,6 +652,11 @@ export default function AppNext() { onSettings={() => window.location.assign('/admin')} /> } + toolbar={ +
+ +
+ } sider={
@@ -649,23 +669,24 @@ export default function AppNext() { onRefresh={refresh} /> )} -
- { - if (key === 'home') navigate('/') - else navigate(`/g/${encodeURIComponent(key.slice(2))}`) - }} - /> -
+ {site.sidebar.show_nav !== false && ( +
+ { + if (key === 'home') navigate('/') + else navigate(`/g/${encodeURIComponent(key.slice(2))}`) + }} + /> +
+ )}
} >
-

Stash 本地应用工作区

{status === 'error' && ( diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index 3644a5e..159b3f2 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -4,6 +4,7 @@ import { FormField, Icon, ICON_NAMES, + IconButton, Input, Modal, Notice, @@ -58,7 +59,7 @@ import type { Project } from '../../api' * - 内容是条目,挂在分组上; * - 侧边栏、首页卡片都只是「勾选分组」的展示组合。 */ -type TabKey = 'groups' | 'content' | 'sidebar' | 'cards' | 'header' | 'profile' | 'system' +type TabKey = 'groups' | 'content' | 'home' | 'header' | 'profile' | 'system' const FORMAT_OPTIONS = [ { value: 'apps', label: '应用' }, @@ -124,6 +125,23 @@ export function AdminNext() { void load() }, []) + // 后台壳也要吃站点级设置:保存后标签标题 / favicon 立刻变,否则会以为「设了没反应」 + // 注意:这里的 `document` 是设置文档,浏览器文档要用 `window.document` + useEffect(() => { + const meta = document?.settings.site + if (meta === undefined) return + const title = meta.title.trim() + if (title !== '') window.document.title = title + const href = meta.favicon.trim() === '' ? '/favicon.svg' : meta.favicon.trim() + let link = window.document.head.querySelector('link[rel="icon"]') + if (link === null) { + link = window.document.createElement('link') + link.rel = 'icon' + window.document.head.appendChild(link) + } + link.href = href + }, [document?.settings.site.title, document?.settings.site.favicon]) + const authenticated = session?.authenticated === true const save = async () => { @@ -173,36 +191,45 @@ export function AdminNext() { await load() } + const brand = document?.settings.site.brand ?? { name: 'STASH', mark: 'S', logo: '' } + const brandName = brand.name.trim() + const brandMark = brand.mark.trim() || brandName.slice(0, 1) || 'S' + const brandLogo = brand.logo.trim() + return (
+ {/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
- - - STASH - - - - {authenticated && ( - - )} - +
+ + {brandLogo !== '' ? ( + + ) : ( + + )} + {brandName !== '' && {brandName}} + + +
+ } + onClick={() => window.location.assign('/')} + /> + {authenticated && ( + } + onClick={() => { + void logout().then(() => window.location.assign('/')) + }} + /> + )} +
+
@@ -230,8 +257,7 @@ export function AdminNext() { items={[ { key: 'groups', label: '分组管理' }, { key: 'content', label: '内容' }, - { key: 'sidebar', label: '侧边栏' }, - { key: 'cards', label: '首页卡片' }, + { key: 'home', label: '首页' }, { key: 'header', label: '页头' }, { key: 'profile', label: '资料' }, { key: 'system', label: '系统' }, @@ -250,42 +276,54 @@ export function AdminNext() { {tab === 'content' && } - {tab === 'sidebar' && ( - { - const hidden = new Set(draft.sidebar.tree_hidden) - if (visible) hidden.delete(key) - else hidden.add(key) - void saveSidebar({ ...draft.sidebar, tree_hidden: [...hidden] }) - }} - onToggleCard={(card, visible) => { - void saveSidebar({ - ...draft.sidebar, - ...(card === 'profile' - ? { show_profile: visible } - : { show_service: visible }), - }) - }} - /> - )} + {tab === 'home' && ( +
+
+ + 侧边栏 + + { + void saveSidebar({ + ...draft.sidebar, + ...(card === 'profile' + ? { show_profile: visible } + : card === 'service' + ? { show_service: visible } + : { show_nav: visible }), + }) + }} + /> +
- {tab === 'cards' && ( - setPending({ kind: 'card', id: String(id), title })} - /> +
+ +
+ + 首页卡片 + + + 卡片 = 一张首页区块 + 勾选它包含哪些分组;分组本身的启停仍由「分组管理」决定。 + + setPending({ kind: 'card', id: String(id), title })} + /> +
+
)} {tab === 'header' && ( setDraft({ ...draft, header })} + site={draft.site} + onChangeSite={(site) => setDraft({ ...draft, site })} /> )} @@ -1200,41 +1238,18 @@ function EntryDialog({ // ---------- 侧边栏(勾选分组树) ---------- function SidebarPanel({ - groups, value, - onToggle, onToggleCard, saving, }: { - groups: GroupNode[] value: SiteSettings['sidebar'] - onToggle: (key: string, visible: boolean) => void - onToggleCard: (card: 'profile' | 'service', visible: boolean) => void + onToggleCard: (card: 'profile' | 'service' | 'nav', visible: boolean) => void saving: boolean }) { - const hidden = new Set(value.tree_hidden) - - function render(nodes: GroupNode[], depth: number) { - return nodes.map((node) => ( -
-
- {node.title} - onToggle(node.key, visible)} - label={`侧栏显示 ${node.title}`} - disabled={saving} - /> -
- {render(node.children, depth + 1)} -
- )) - } - return ( - 侧栏卡片与分组导航分开控制;开关修改后立即保存。 + 分组在侧边栏的显隐只由「分组管理 → 状态」决定;这里只管三块侧栏卡片。 @@ -1258,16 +1273,15 @@ function SidebarPanel({ disabled={saving} />
- -
- - - 分组导航 - - - 勾选要在侧边栏显示的分组;子分组可以单独勾选,父分组不勾选也不影响子项。 - - {render(groups, 0)} +
+ 分组导航 + onToggleCard('nav', visible)} + label="显示分组导航卡片" + disabled={saving} + /> +
) } @@ -1552,7 +1566,7 @@ function CardTable({ onDelete, }: { card: CardItem - options: { key: string; title: string; depth: number }[] + options: { key: string; title: string; depth: number; enabled: boolean }[] onReload: () => void onDelete: (id: number, title: string) => void }) { @@ -1568,13 +1582,20 @@ function CardTable({ ), ) const [busy, setBusy] = useState(false) + /** 勾选的分组按此顺序在首页卡片里出现;启用先后不再决定顺序 */ + const [order, setOrder] = useState(() => card.groups.map((group) => group.groupKey)) - const persistGroups = async (nextSelected: Record) => { + const persistGroups = async (nextSelected: Record, nextOrder: string[]) => { setBusy(true) - const groups = Object.entries(nextSelected).map(([groupKey, limit]) => ({ - groupKey, - previewLimit: limit.trim() === '' ? undefined : Number(limit), - })) + const groups = nextOrder + .filter((key) => key in nextSelected) + .map((groupKey) => { + const limit = nextSelected[groupKey] ?? '' + return { + groupKey, + previewLimit: limit.trim() === '' ? undefined : Number(limit), + } + }) const result = await setCardGroups(card.id, groups) setBusy(false) if (!result.ok) { @@ -1587,10 +1608,28 @@ function CardTable({ const toggle = (key: string, checked: boolean) => { const next = { ...selected } - if (checked) next[key] = '' - else delete next[key] + let nextOrder = order + if (checked) { + next[key] = '' + if (!order.includes(key)) nextOrder = [...order, key] + } else { + delete next[key] + nextOrder = order.filter((item) => item !== key) + } setSelected(next) - void persistGroups(next) + setOrder(nextOrder) + void persistGroups(next, nextOrder) + } + + const moveGroup = (key: string, delta: number) => { + const index = order.indexOf(key) + const target = index + delta + if (index < 0 || target < 0 || target >= order.length) return + const nextOrder = [...order] + nextOrder.splice(index, 1) + nextOrder.splice(target, 0, key) + setOrder(nextOrder) + void persistGroups(selected, nextOrder) } const saveTitle = async () => { @@ -1646,7 +1685,9 @@ function CardTable({ 分组名称 + 状态 预览条数 + 顺序 展示 @@ -1660,6 +1701,11 @@ function CardTable({ {option.title}
+ + + {option.enabled ? '启用' : '已停用'} + + {checked ? ( void persistGroups(selected)} + onBlur={() => void persistGroups(selected, order)} /> ) : ( — )} + + {checked ? ( +
+ {order.indexOf(option.key) + 1} + } + size="xs" + disabled={busy || order.indexOf(option.key) === 0} + onClick={() => moveGroup(option.key, -1)} + /> + } + size="xs" + disabled={busy || order.indexOf(option.key) === order.length - 1} + onClick={() => moveGroup(option.key, 1)} + /> +
+ ) : ( + — + )} + toggle(option.key, next)} label={`卡片包含 ${option.title}`} - disabled={busy} + disabled={busy || !option.enabled} /> @@ -1704,13 +1773,122 @@ const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label: function HeaderPanel({ value, onChange, + site, + onChangeSite, }: { value: SiteSettings['header'] onChange: (header: SiteSettings['header']) => void + site: SiteSettings['site'] + onChangeSite: (site: SiteSettings['site']) => void }) { return ( + + 站点信息 + + + onChangeSite({ ...site, title: event.target.value })} + /> + + + onChangeSite({ ...site, favicon: event.target.value })} + /> + + + + 首页 Logo + + + + + onChangeSite({ ...site, brand: { ...site.brand, logo: event.target.value } }) + } + /> +
+ + + onChangeSite({ ...site, brand: { ...site.brand, name: event.target.value } }) + } + /> + + + + onChangeSite({ ...site, brand: { ...site.brand, mark: event.target.value } }) + } + /> + +
+ + + 操作工具 + +
+ 刷新 + + onChangeSite({ ...site, actions: { ...site.actions, refresh: next } }) + } + label="页头显示刷新按钮" + /> +
+
+ 站点设置 + + onChangeSite({ ...site, actions: { ...site.actions, settings: next } }) + } + label="页头显示站点设置按钮" + /> +
+
+ 登录 / 退出 + + onChangeSite({ ...site, actions: { ...site.actions, session: next } }) + } + label="页头显示登录或退出按钮" + /> +
+ + + 滚动信息 + +
显示页头滚动信息 由 Layout 提供,这里只渲染栏内的内容。 */ type SiteHeaderProps = { @@ -15,10 +22,14 @@ type SiteHeaderProps = { headerEnabled: boolean /** 滚动一轮的秒数 */ headerSpeed: number + /** 品牌与操作工具配置 */ + site: SiteMetaSettings /** 是否已登录:决定右侧是「登录」还是「退出登录」 */ authenticated: boolean username?: string - onFocusSearch: () => void + /** 刷新中:刷新按钮转圈 */ + refreshing?: boolean + onRefresh: () => void onLogin: () => void onLogout: () => void onSettings: () => void @@ -72,25 +83,62 @@ export function SiteHeader({ headerItems, headerEnabled, headerSpeed, + site, authenticated, username, - onFocusSearch, + refreshing = false, + onRefresh, onLogin, onLogout, onSettings, }: SiteHeaderProps) { + const brandName = site.brand.name.trim() + const brandMark = site.brand.mark.trim() || brandName.slice(0, 1) || 'S' + const brandLogo = site.brand.logo.trim() + + // 跑马灯的位移要正好等于「一组」的宽度,循环才接得上。 + // CSS 里的 -100%/3 会按轨道的 border-box(只有一组宽)算,导致提前 2/3 回跳, + // 所以这里量一次真实宽度写进 --ticker-shift。 + const trackRef = useRef(null) + const tickerKey = `${headerItems.map((item) => `${item.tag ?? ''}:${item.text}`).join('|')}@${headerSpeed}` + + useEffect(() => { + const track = trackRef.current + if (track === null) return + + const sync = () => { + const group = track.firstElementChild + if (!(group instanceof HTMLElement)) return + track.style.setProperty('--ticker-shift', `${-group.getBoundingClientRect().width}px`) + } + + sync() + const observer = new ResizeObserver(sync) + observer.observe(track) + window.addEventListener('resize', sync) + return () => { + observer.disconnect() + window.removeEventListener('resize', sync) + } + }, [tickerKey]) + return (
- - - STASH + + {brandLogo !== '' ? ( + + ) : ( + + )} + {brandName !== '' && {brandName}} {headerEnabled && (
@@ -120,23 +168,27 @@ export function SiteHeader({ )}
- } - onClick={onFocusSearch} - /> - {authenticated && ( + {site.actions.refresh && ( + } + spinning={refreshing} + onClick={onRefresh} + /> + )} + {site.actions.settings && authenticated && ( } onClick={onSettings} /> )} - {authenticated ? ( - } - onClick={onLogout} - /> - ) : ( - } onClick={onLogin} /> - )} + {site.actions.session && + (authenticated ? ( + } + onClick={onLogout} + /> + ) : ( + } onClick={onLogin} /> + ))}
) diff --git a/stash-web/src/manage.ts b/stash-web/src/manage.ts index 74933d6..03a0362 100644 --- a/stash-web/src/manage.ts +++ b/stash-web/src/manage.ts @@ -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), ]) } diff --git a/stash-web/src/settings.ts b/stash-web/src/settings.ts index 199c8f1..f4bbec3 100644 --- a/stash-web/src/settings.ts +++ b/stash-web/src/settings.ts @@ -43,6 +43,8 @@ export type SiteSettings = { show_profile: boolean /** 是否显示「本地服务」面板 */ show_service: boolean + /** 是否显示「分组导航」卡片(首页 / 分组树) */ + show_nav: boolean hidden: string[] order: string[] /** 隐藏的分组:`分区id/分组id` */ @@ -58,6 +60,31 @@ export type SiteSettings = { } sections: SectionSettings profile: ProfileSettings + /** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */ + site: SiteMetaSettings +} + +export type SiteMetaSettings = { + /** 浏览器标签标题 */ + title: string + /** favicon 地址;空表示用内置图标 */ + favicon: string + brand: { + /** 页头品牌文字,可留空 */ + name: string + /** 没有图片时圆形标记里的首字 */ + mark: string + /** logo 图片地址;空表示用首字 */ + logo: string + } + actions: { + /** 刷新图标(重扫本地项目并重拉内容库) */ + refresh: boolean + /** 站点设置图标 */ + settings: boolean + /** 登录 / 退出登录 */ + session: boolean + } } export type SettingsDocument = { @@ -90,6 +117,7 @@ export function defaultSettings(): SiteSettings { sidebar: { show_profile: true, show_service: true, + show_nav: true, hidden: [], order: [], hidden_groups: [], @@ -106,6 +134,12 @@ export function defaultSettings(): SiteSettings { location: 'IP属地:局域网', avatar: '', }, + site: { + title: 'Stash · 本地应用工作区', + favicon: '', + brand: { name: 'STASH', mark: 'S', logo: '' }, + actions: { refresh: true, settings: true, session: true }, + }, } }