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)
This commit is contained in:
2026-11-01 15:15:20 +08:00
parent bb5a615ed2
commit 59fd61d832
9 changed files with 822 additions and 296 deletions
+84
View File
@@ -88,6 +88,7 @@ impl Default for SidebarSettings {
Self { Self {
show_profile: true, show_profile: true,
show_service: true, show_service: true,
show_nav: true,
hidden: Vec::new(), hidden: Vec::new(),
order: Vec::new(), order: Vec::new(),
hidden_groups: Vec::new(), hidden_groups: Vec::new(),
@@ -108,6 +109,9 @@ pub struct SidebarSettings {
/// 是否显示「本地服务」面板 /// 是否显示「本地服务」面板
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub show_service: bool, pub show_service: bool,
/// 是否显示「分组导航」卡片(首页 / 分组树)
#[serde(default = "default_true")]
pub show_nav: bool,
/// 隐藏的分类 id /// 隐藏的分类 id
pub hidden: Vec<String>, pub hidden: Vec<String>,
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序 /// 分类显示顺序(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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)] #[serde(default)]
pub struct Settings { pub struct Settings {
@@ -167,6 +235,7 @@ pub struct Settings {
pub sidebar: SidebarSettings, pub sidebar: SidebarSettings,
pub sections: SectionsSettings, pub sections: SectionsSettings,
pub profile: ProfileSettings, pub profile: ProfileSettings,
pub site: SiteMetaSettings,
} }
/// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。 /// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。
@@ -244,6 +313,21 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.profile.location.chars().count() > 40 { if settings.profile.location.chars().count() > 40 {
return Err(StashError::InvalidDirectory("位置最多 40 个字".into())); 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(()) Ok(())
} }
+58 -16
View File
@@ -1,7 +1,8 @@
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{OriginalUri, Path, State}, extract::{OriginalUri, Path, Request, State},
http::{HeaderMap, HeaderValue, Method, StatusCode, header}, http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::sse::{Event, KeepAlive, Sse}, response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{get, patch, post, put}, routing::{get, patch, post, put},
@@ -36,6 +37,9 @@ pub struct AppState {
pub project_updates: broadcast::Sender<Vec<AppProject>>, pub project_updates: broadcast::Sender<Vec<AppProject>>,
/// 内容库:分类 / 分组 / 条目的唯一数据源 /// 内容库:分类 / 分组 / 条目的唯一数据源
pub db: Arc<Db>, pub db: Arc<Db>,
/// 内容库写入后的广播:SSE 用它对所有打开的页面推「该重拉」。
/// 不能只靠 `PRAGMA data_version`——服务自己的连接写完不会让它变化。
pub catalog_updates: broadcast::Sender<()>,
/// 登录失败计数(单用户本地场景,一个进程一份就够) /// 登录失败计数(单用户本地场景,一个进程一份就够)
pub login_guard: Arc<std::sync::Mutex<LoginGuard>>, pub login_guard: Arc<std::sync::Mutex<LoginGuard>>,
} }
@@ -234,30 +238,29 @@ async fn catalog_api(State(state): State<AppState>) -> Response {
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。 /// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse { async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.clone(); let db = state.db.clone();
let mut updates = state.catalog_updates.subscribe();
let stream = async_stream::stream! { let stream = async_stream::stream! {
let mut last = db.data_version().unwrap_or(0); let mut last = db.data_version().unwrap_or(0);
let mut ticker = tokio::time::interval(Duration::from_secs(5)); let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop { loop {
ticker.tick().await; // 两条来源:本进程的写接口主动广播(快、准),
// 以及 data_version 轮询兜底(外部工具直接改库时用)。
let current = match db.data_version() { let changed = tokio::select! {
Ok(version) => version, _ = ticker.tick() => match db.data_version() {
Err(error) => { Ok(current) if current != last => {
warn!("读取内容库版本失败: {error}"); last = current;
continue; true
} }
_ => false,
},
received = updates.recv() => received.is_ok(),
}; };
// 只在其它连接提交之后才推送。 if !changed {
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
// 因为 data_version 不会因本连接自己的提交而变化。
tracing::debug!(last, current, "内容库版本检查");
if current == last {
continue; continue;
} }
last = current;
// 只告诉前端「变了」,具体内容由它重新 GET /api/catalog // 只告诉前端「变了」,具体内容由它重新 GET /api/catalog
yield Ok::<Event, Infallible>( 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:先发送当前快照,再持续推送文件监听产生的项目列表变化。 /// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
async fn project_events(State(state): State<AppState>) -> impl IntoResponse { async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
let root = state.config.root.clone(); let root = state.config.root.clone();
@@ -1146,7 +1179,12 @@ fn build_router(state: AppState) -> Router {
.route("/favicon.svg", get(web_root_file)) .route("/favicon.svg", get(web_root_file))
.route("/assets/{*path}", get(web_asset)) .route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir) .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()); .layer(TraceLayer::new_for_http());
if enable_cors { if enable_cors {
@@ -1170,6 +1208,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
let (project_updates, _) = broadcast::channel(32); let (project_updates, _) = broadcast::channel(32);
watcher::start(config.root.clone(), project_updates.clone())?; watcher::start(config.root.clone(), project_updates.clone())?;
let (catalog_updates, _) = broadcast::channel(16);
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成 // 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
let db = Arc::new(Db::open(&config)?); 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()), config: Arc::new(config.clone()),
project_updates, project_updates,
db, db,
catalog_updates,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())), login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
}; };
@@ -1240,11 +1280,13 @@ mod tests {
config.enable_cors = false; config.enable_cors = false;
let (project_updates, _) = broadcast::channel(4); let (project_updates, _) = broadcast::channel(4);
let (catalog_updates, _) = broadcast::channel(4);
build_router(AppState { build_router(AppState {
config: Arc::new(config), config: Arc::new(config),
project_updates, project_updates,
db: Arc::new(Db::in_memory()), db: Arc::new(Db::in_memory()),
catalog_updates,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())), login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
}) })
} }
+157 -49
View File
@@ -6,7 +6,7 @@
z-index: 1; z-index: 1;
/* 透明:让固定背景图从下面透出来(Layout 默认有底色) */ /* 透明:让固定背景图从下面透出来(Layout 默认有底色) */
background-color: transparent; background-color: transparent;
/* 旧版阴影是 2% / 6%,比库默认更深,属于主题级覆盖 */ /* 旧版影子是 2% / 6%,比库默认更深,属于主题级覆盖 */
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02); --su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
--su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06); --su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
} }
@@ -63,6 +63,24 @@
font-weight: var(--su-font-weight-strong); 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 { .site-brand-name {
font-size: var(--su-font-size-lg); font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg); line-height: var(--su-line-height-lg);
@@ -114,8 +132,10 @@
transform: translateX(0); transform: translateX(0);
} }
/* 位移量由 SiteHeader 量出「一组」的真实宽度写进 --ticker-shift;
回退值仅在第一帧兜底,正常情况不会用到 */
to { to {
transform: translateX(calc(-100% / 3)); transform: translateX(var(--ticker-shift, -33.3333%));
} }
} }
@@ -129,16 +149,18 @@
margin-left: auto; 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 { .search-row {
position: fixed; width: min(480px, 100%);
top: 76px;
left: 50%;
z-index: var(--su-z-header);
width: min(480px, calc(100vw - 48px));
height: 44px; 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 { .content {
@@ -214,7 +211,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--su-space-4); gap: var(--su-space-4);
padding-top: 88px; padding-top: 0;
} }
/* ---------- 侧栏卡片 ---------- */ /* ---------- 侧栏卡片 ---------- */
@@ -855,13 +852,11 @@
background-color: var(--su-bg-page); background-color: var(--su-bg-page);
} }
/* 与前台页头同一套:整条吸顶,内部 .site-header-inner 负责 60 高与限宽 */
.admin-topbar { .admin-topbar {
display: flex; position: sticky;
align-items: center; top: 0;
justify-content: space-between; z-index: var(--su-z-header);
gap: var(--su-space-4);
height: 64px;
padding: 0 var(--su-space-6);
border-bottom: 1px solid var(--su-border); border-bottom: 1px solid var(--su-border);
background-color: var(--su-bg-card); background-color: var(--su-bg-card);
} }
@@ -949,15 +944,12 @@
display: none; display: none;
} }
.search-row { .search-toolbar {
position: static; padding: var(--su-space-5) 0 var(--su-space-6);
width: 100%;
height: 36px;
transform: none;
} }
.content { .content {
padding-top: var(--su-space-4); padding-top: var(--su-space-2);
} }
} }
@@ -1326,7 +1318,7 @@ a.entry-href:hover {
min-width: 0; min-width: 0;
} }
/* 首页:左右栏共用一个滚动区,左侧到底后吸底 */ /* 首页:左右栏共用一个滚动区,左侧吸顶交给 CSS sticky */
#page-scroll { #page-scroll {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
@@ -1336,6 +1328,46 @@ a.entry-href:hover {
display: none; 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 { .sidebar-home {
padding-top: 0; padding-top: 0;
padding-bottom: 24px; padding-bottom: 24px;
@@ -1477,10 +1509,29 @@ a.entry-href:hover {
.cards-table-wrap { .cards-table-wrap {
margin-top: var(--su-space-2); margin-top: var(--su-space-2);
/* 列多了以后表格会比面板宽,横向滚动而不是把名字挤成两三行 */
overflow-x: auto;
}
.cards-table th,
.cards-table td {
padding: var(--su-space-2);
} }
.cards-col-limit { .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, .cards-table th,
@@ -1488,6 +1539,63 @@ a.entry-href:hover {
padding: var(--su-space-2) var(--su-space-4); 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) { @media (max-width: 860px) {
.cards-manager { .cards-manager {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+2 -1
View File
@@ -341,9 +341,10 @@ export default function App() {
headerItems={settings.settings.header.items} headerItems={settings.settings.header.items}
headerEnabled={settings.settings.header.enabled} headerEnabled={settings.settings.header.enabled}
headerSpeed={settings.settings.header.speed} headerSpeed={settings.settings.header.speed}
site={settings.settings.site}
authenticated={session?.authenticated === true} authenticated={session?.authenticated === true}
username={session?.username ?? undefined} username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()} onRefresh={() => refresh()}
onLogin={() => setLoginOpen(true)} onLogin={() => setLoginOpen(true)}
onLogout={handleLogout} onLogout={handleLogout}
onSettings={() => window.location.assign('/admin')} onSettings={() => window.location.assign('/admin')}
+122 -101
View File
@@ -132,14 +132,24 @@ function findEntry(
} }
function groupEntryCount(group: GroupNode): number { 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>[] { function collectEntries(group: GroupNode): Record<string, unknown>[] {
return [ return [
...(group.entries as unknown as Record<string, unknown>[]), ...(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 +171,6 @@ function matches(entry: Record<string, unknown>, keyword: string): boolean {
return text.includes(keyword) return text.includes(keyword)
} }
const TOP_OFFSET = 104
export default function AppNext() { export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects() const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute() const { route, navigate } = useRoute()
@@ -189,7 +197,14 @@ export default function AppNext() {
void fetchSettings().then(setSettings) void fetchSettings().then(setSettings)
const source = new EventSource('/api/catalog/events') 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() return () => source.close()
}, []) }, [])
@@ -205,69 +220,42 @@ export default function AppNext() {
return 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.id = 'page-scroll'
body.style.overflowY = 'auto' body.style.overflowY = 'auto'
body.style.overflowX = 'hidden' body.style.overflowX = 'hidden'
body.style.marginTop = `${TOP_OFFSET}px`
body.style.height = `calc(100% - ${TOP_OFFSET}px)`
content.style.overflow = 'visible' content.style.overflow = 'visible'
content.style.marginTop = '0' content.style.marginTop = '0'
main.style.height = 'auto' 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) const onScroll = () => setScrolled(body.scrollTop > 4)
// 顶部软虚化层:覆盖搜索区域与内容顶部,边缘用 mask 渐隐,避免硬分割线 // 滚动容器只覆盖 1380 的两栏,窗口两侧的空白、页头与搜索条都在它外面,
const blur = document.createElement('div') // 滚轮落在这些地方时浏览器找不到可滚动元素。统一转发给内容区。
blur.className = 'top-blur' const forwardWheel = (event: WheelEvent) => {
blur.setAttribute('aria-hidden', 'true') if (event.target instanceof Node && body.contains(event.target)) return
const appRoot = document.querySelector('.app') // deltaMode 1 = 按行(Firefox 鼠标滚轮),换算成像素
appRoot?.appendChild(blur) body.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
event.preventDefault()
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)` : ''
}
} }
window.addEventListener('wheel', forwardWheel, { passive: false })
body.addEventListener('scroll', onScroll, { passive: true }) 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() onScroll()
updateSticky()
return () => { return () => {
body.removeEventListener('scroll', onScroll) body.removeEventListener('scroll', onScroll)
body.removeEventListener('scroll', updateSticky) window.removeEventListener('wheel', forwardWheel)
window.removeEventListener('resize', updateSticky)
observer.disconnect()
blur.remove()
body.id = '' body.id = ''
body.style.overflowY = '' body.style.overflowY = ''
body.style.overflowX = '' body.style.overflowX = ''
body.style.marginTop = ''
body.style.height = ''
content.style.overflow = '' content.style.overflow = ''
content.style.marginTop = '' content.style.marginTop = ''
main.style.height = '' 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: { sidebar: {
show_profile: true, show_profile: true,
show_service: true, show_service: true,
show_nav: true,
hidden: [], hidden: [],
order: [], order: [],
hidden_groups: [], hidden_groups: [],
@@ -292,41 +281,52 @@ export default function AppNext() {
location: '', location: '',
avatar: '', avatar: '',
}, },
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { refresh: true, settings: true, session: true },
},
} }
const keywordLower = keyword.trim().toLowerCase() const keywordLower = keyword.trim().toLowerCase()
const sidebarItems: MenuItem[] = useMemo(() => { // 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
const hidden = new Set(site.sidebar.tree_hidden) useEffect(() => {
const title = site.site.title.trim()
if (title !== '') document.title = title
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => { const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim()
const ranks = new Map( let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const), if (link === null) {
) link = document.createElement('link')
return [...nodes].sort((a, b) => { link.rel = 'icon'
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER document.head.appendChild(link)
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
if (left === right) return a.sort - b.sort
return left - right
})
} }
link.href = href
link.type = href.endsWith('.svg') ? 'image/svg+xml' : ''
}, [site.site.title, site.site.favicon])
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] => const sidebarItems: MenuItem[] = useMemo(() => {
sortNodes(nodes, parentKey).flatMap((node) => { // 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
if (node.enabled === false) return [] // 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
if (hidden.has(node.key)) return build(node.children, node.key) const build = (nodes: GroupNode[]): MenuItem[] =>
const children = build(node.children, node.key) [...nodes]
return [ .sort((a, b) => a.sort - b.sort)
{ .flatMap((node) => {
key: `g:${node.key}`, if (node.enabled === false) return []
label: node.title, const children = build(node.children)
children: children.length > 0 ? children : undefined, return [
}, {
] key: `g:${node.key}`,
}) label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')] return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order]) }, [manage.groups])
const activeKey = const activeKey =
route.kind === 'home' route.kind === 'home'
@@ -367,11 +367,16 @@ export default function AppNext() {
} }
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) { 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 if (all.length === 0) return null
return ( return (
<div key={group.key} className="card-group-block"> <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 })} {renderEntries(group, all, { limit })}
</div> </div>
) )
@@ -406,7 +411,7 @@ export default function AppNext() {
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) })) .map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
.filter( .filter(
(item): item is { ref: typeof item.ref; group: GroupNode } => (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) const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
@@ -445,11 +450,13 @@ export default function AppNext() {
function renderGroupPage(key: string) { function renderGroupPage(key: string) {
const group = findGroup(manage.groups, key) const group = findGroup(manage.groups, key)
if (group === undefined) { if (group === undefined || !isGroupVisible(manage.groups, key)) {
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} /> return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
} }
const trail = findPath(manage.groups, key) ?? [group] const trail = findPath(manage.groups, key) ?? [group]
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
const rootGroup = group.parentKey === undefined || group.parentKey === null
return ( return (
<div className="detail-view"> <div className="detail-view">
@@ -486,7 +493,9 @@ export default function AppNext() {
)} )}
<Space direction="column" gap="lg" align="stretch"> <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"> <section key={child.key} className="group-child-block">
<div className="group-title"> <div className="group-title">
<span <span
@@ -495,16 +504,18 @@ export default function AppNext() {
aria-hidden="true" aria-hidden="true"
/> />
<h3>{child.title}</h3> <h3>{child.title}</h3>
<button {!rootGroup && (
type="button" <button
className="link-button" type="button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)} className="link-button"
> onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
查看全部 >
</button> 查看全部
</button>
)}
</div> </div>
{child.entries.length > 0 ? ( {child.entries.length > 0 ? (
renderEntries(child, child.entries, { limit: 12 }) renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
) : ( ) : (
<Empty text="暂无内容" /> <Empty text="暂无内容" />
)} )}
@@ -517,7 +528,7 @@ export default function AppNext() {
function renderItemPage(key: string, itemId: string) { function renderItemPage(key: string, itemId: string) {
const found = findEntry(manage.groups, key, itemId) const found = findEntry(manage.groups, key, itemId)
if (found === undefined) { if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) {
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} /> return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
} }
@@ -613,7 +624,6 @@ export default function AppNext() {
headerVariant="solid" headerVariant="solid"
siderBordered={false} siderBordered={false}
contentPadding="none" contentPadding="none"
contentOffset={TOP_OFFSET}
height="screen" height="screen"
scrollable scrollable
hideScrollbar hideScrollbar
@@ -627,9 +637,14 @@ export default function AppNext() {
headerItems={site.header.items} headerItems={site.header.items}
headerEnabled={site.header.enabled} headerEnabled={site.header.enabled}
headerSpeed={site.header.speed} headerSpeed={site.header.speed}
site={site.site}
authenticated={session?.authenticated === true} authenticated={session?.authenticated === true}
username={session?.username ?? undefined} username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()} refreshing={refreshing}
onRefresh={() => {
refresh()
load()
}}
onLogin={() => setLoginOpen(true)} onLogin={() => setLoginOpen(true)}
onLogout={() => { onLogout={() => {
void logout().then(() => setSession({ authenticated: false, has_admin: true })) void logout().then(() => setSession({ authenticated: false, has_admin: true }))
@@ -637,6 +652,11 @@ export default function AppNext() {
onSettings={() => window.location.assign('/admin')} onSettings={() => window.location.assign('/admin')}
/> />
} }
toolbar={
<div className="search-toolbar">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
</div>
}
sider={ sider={
<div className="sidebar-wrap"> <div className="sidebar-wrap">
<div className="sidebar sidebar-home"> <div className="sidebar sidebar-home">
@@ -649,23 +669,24 @@ export default function AppNext() {
onRefresh={refresh} onRefresh={refresh}
/> />
)} )}
<div className="nav-card"> {site.sidebar.show_nav !== false && (
<Menu <div className="nav-card">
items={sidebarItems} <Menu
activeKey={activeKey} items={sidebarItems}
itemHeight={39} activeKey={activeKey}
onSelect={(key) => { itemHeight={39}
if (key === 'home') navigate('/') onSelect={(key) => {
else navigate(`/g/${encodeURIComponent(key.slice(2))}`) if (key === 'home') navigate('/')
}} else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
/> }}
</div> />
</div>
)}
</div> </div>
</div> </div>
} }
> >
<div className="content"> <div className="content">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1> <h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && ( {status === 'error' && (
+282 -104
View File
@@ -4,6 +4,7 @@ import {
FormField, FormField,
Icon, Icon,
ICON_NAMES, ICON_NAMES,
IconButton,
Input, Input,
Modal, Modal,
Notice, 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 = [ const FORMAT_OPTIONS = [
{ value: 'apps', label: '应用' }, { value: 'apps', label: '应用' },
@@ -124,6 +125,23 @@ export function AdminNext() {
void load() 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<HTMLLinkElement>('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 authenticated = session?.authenticated === true
const save = async () => { const save = async () => {
@@ -173,36 +191,45 @@ export function AdminNext() {
await load() 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 ( return (
<div className="admin-page"> <div className="admin-page">
<Toaster /> <Toaster />
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
<header className="admin-topbar"> <header className="admin-topbar">
<a className="site-brand" href="/" aria-label="Stash 首页"> <div className="site-header-inner">
<span className="site-brand-mark" aria-hidden="true"> <a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
S {brandLogo !== '' ? (
</span> <img className="site-brand-logo" src={brandLogo} alt="" />
<span className="site-brand-name">STASH</span> ) : (
</a> <span className="site-brand-mark" aria-hidden="true">
<Space gap="sm"> {brandMark}
<Button </span>
variant="ghost" )}
icon={<Icon name="arrow_left_line" />} {brandName !== '' && <span className="site-brand-name">{brandName}</span>}
onClick={() => window.location.assign('/')} </a>
>
返回首页 <div className="header-actions">
</Button> <IconButton
{authenticated && ( label="返回首页"
<Button icon={<Icon name="arrow_left_line" />}
variant="secondary" onClick={() => window.location.assign('/')}
icon={<Icon name="exit_line" />} />
onClick={() => { {authenticated && (
void logout().then(() => window.location.assign('/')) <IconButton
}} label="退出登录"
> icon={<Icon name="exit_line" />}
退出登录 onClick={() => {
</Button> void logout().then(() => window.location.assign('/'))
)} }}
</Space> />
)}
</div>
</div>
</header> </header>
<main className="admin-main"> <main className="admin-main">
@@ -230,8 +257,7 @@ export function AdminNext() {
items={[ items={[
{ key: 'groups', label: '分组管理' }, { key: 'groups', label: '分组管理' },
{ key: 'content', label: '内容' }, { key: 'content', label: '内容' },
{ key: 'sidebar', label: '侧边栏' }, { key: 'home', label: '首页' },
{ key: 'cards', label: '首页卡片' },
{ key: 'header', label: '页头' }, { key: 'header', label: '页头' },
{ key: 'profile', label: '资料' }, { key: 'profile', label: '资料' },
{ key: 'system', label: '系统' }, { key: 'system', label: '系统' },
@@ -250,42 +276,54 @@ export function AdminNext() {
{tab === 'content' && <ContentPanel groups={groups} onReload={load} />} {tab === 'content' && <ContentPanel groups={groups} onReload={load} />}
{tab === 'sidebar' && ( {tab === 'home' && (
<SidebarPanel <div className="admin-home">
groups={groups} <section className="admin-home-section">
value={draft.sidebar} <Text size="md" strong>
saving={saving} 侧边栏
onToggle={(key, visible) => { </Text>
const hidden = new Set(draft.sidebar.tree_hidden) <SidebarPanel
if (visible) hidden.delete(key) value={draft.sidebar}
else hidden.add(key) saving={saving}
void saveSidebar({ ...draft.sidebar, tree_hidden: [...hidden] }) onToggleCard={(card, visible) => {
}} void saveSidebar({
onToggleCard={(card, visible) => { ...draft.sidebar,
void saveSidebar({ ...(card === 'profile'
...draft.sidebar, ? { show_profile: visible }
...(card === 'profile' : card === 'service'
? { show_profile: visible } ? { show_service: visible }
: { show_service: visible }), : { show_nav: visible }),
}) })
}} }}
/> />
)} </section>
{tab === 'cards' && ( <div className="sidebar-divider" />
<CardsPanel
groups={groups} <section className="admin-home-section">
cards={cards} <Text size="md" strong>
projects={projects} 首页卡片
onReload={load} </Text>
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })} <Text size="sm" tone="muted">
/> 卡片 = 一张首页区块 + 勾选它包含哪些分组;分组本身的启停仍由「分组管理」决定。
</Text>
<CardsPanel
groups={groups}
cards={cards}
projects={projects}
onReload={load}
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })}
/>
</section>
</div>
)} )}
{tab === 'header' && ( {tab === 'header' && (
<HeaderPanel <HeaderPanel
value={draft.header} value={draft.header}
onChange={(header) => setDraft({ ...draft, header })} onChange={(header) => setDraft({ ...draft, header })}
site={draft.site}
onChangeSite={(site) => setDraft({ ...draft, site })}
/> />
)} )}
@@ -1200,41 +1238,18 @@ function EntryDialog({
// ---------- 侧边栏(勾选分组树) ---------- // ---------- 侧边栏(勾选分组树) ----------
function SidebarPanel({ function SidebarPanel({
groups,
value, value,
onToggle,
onToggleCard, onToggleCard,
saving, saving,
}: { }: {
groups: GroupNode[]
value: SiteSettings['sidebar'] value: SiteSettings['sidebar']
onToggle: (key: string, visible: boolean) => void onToggleCard: (card: 'profile' | 'service' | 'nav', visible: boolean) => void
onToggleCard: (card: 'profile' | 'service', visible: boolean) => void
saving: boolean saving: boolean
}) { }) {
const hidden = new Set(value.tree_hidden)
function render(nodes: GroupNode[], depth: number) {
return nodes.map((node) => (
<div key={node.key} style={{ marginLeft: depth * 22 }}>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">{node.title}</Text>
<Switch
checked={!hidden.has(node.key)}
onChange={(visible) => onToggle(node.key, visible)}
label={`侧栏显示 ${node.title}`}
disabled={saving}
/>
</div>
{render(node.children, depth + 1)}
</div>
))
}
return ( return (
<Space direction="column" gap="md" align="stretch" className="admin-form"> <Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">
侧栏卡片与分组导航分开控制;开关修改后立即保存。 分组在侧边栏的显隐只由「分组管理 → 状态」决定;这里只管三块侧栏卡片。
</Text> </Text>
<Text size="sm" strong> <Text size="sm" strong>
@@ -1258,16 +1273,15 @@ function SidebarPanel({
disabled={saving} disabled={saving}
/> />
</div> </div>
<div className="sidebar-toggle-row">
<div className="sidebar-divider" /> <Text className="admin-item-title">分组导航</Text>
<Switch
<Text size="sm" strong> checked={value.show_nav !== false}
分组导航 onChange={(visible) => onToggleCard('nav', visible)}
</Text> label="显示分组导航卡片"
<Text size="sm" tone="muted"> disabled={saving}
勾选要在侧边栏显示的分组;子分组可以单独勾选,父分组不勾选也不影响子项。 />
</Text> </div>
{render(groups, 0)}
</Space> </Space>
) )
} }
@@ -1552,7 +1566,7 @@ function CardTable({
onDelete, onDelete,
}: { }: {
card: CardItem card: CardItem
options: { key: string; title: string; depth: number }[] options: { key: string; title: string; depth: number; enabled: boolean }[]
onReload: () => void onReload: () => void
onDelete: (id: number, title: string) => void onDelete: (id: number, title: string) => void
}) { }) {
@@ -1568,13 +1582,20 @@ function CardTable({
), ),
) )
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
/** 勾选的分组按此顺序在首页卡片里出现;启用先后不再决定顺序 */
const [order, setOrder] = useState<string[]>(() => card.groups.map((group) => group.groupKey))
const persistGroups = async (nextSelected: Record<string, string>) => { const persistGroups = async (nextSelected: Record<string, string>, nextOrder: string[]) => {
setBusy(true) setBusy(true)
const groups = Object.entries(nextSelected).map(([groupKey, limit]) => ({ const groups = nextOrder
groupKey, .filter((key) => key in nextSelected)
previewLimit: limit.trim() === '' ? undefined : Number(limit), .map((groupKey) => {
})) const limit = nextSelected[groupKey] ?? ''
return {
groupKey,
previewLimit: limit.trim() === '' ? undefined : Number(limit),
}
})
const result = await setCardGroups(card.id, groups) const result = await setCardGroups(card.id, groups)
setBusy(false) setBusy(false)
if (!result.ok) { if (!result.ok) {
@@ -1587,10 +1608,28 @@ function CardTable({
const toggle = (key: string, checked: boolean) => { const toggle = (key: string, checked: boolean) => {
const next = { ...selected } const next = { ...selected }
if (checked) next[key] = '' let nextOrder = order
else delete next[key] if (checked) {
next[key] = ''
if (!order.includes(key)) nextOrder = [...order, key]
} else {
delete next[key]
nextOrder = order.filter((item) => item !== key)
}
setSelected(next) 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 () => { const saveTitle = async () => {
@@ -1646,7 +1685,9 @@ function CardTable({
<thead> <thead>
<tr> <tr>
<th>分组名称</th> <th>分组名称</th>
<th className="cards-col-state">状态</th>
<th className="cards-col-limit">预览条数</th> <th className="cards-col-limit">预览条数</th>
<th className="cards-col-order">顺序</th>
<th className="group-col-status">展示</th> <th className="group-col-status">展示</th>
</tr> </tr>
</thead> </thead>
@@ -1660,6 +1701,11 @@ function CardTable({
<span className="group-name">{option.title}</span> <span className="group-name">{option.title}</span>
</div> </div>
</td> </td>
<td>
<span className={option.enabled ? 'group-state-on' : 'group-off-tag'}>
{option.enabled ? '启用' : '已停用'}
</span>
</td>
<td> <td>
{checked ? ( {checked ? (
<Input <Input
@@ -1673,18 +1719,41 @@ function CardTable({
[option.key]: event.target.value, [option.key]: event.target.value,
})) }))
} }
onBlur={() => void persistGroups(selected)} onBlur={() => void persistGroups(selected, order)}
/> />
) : ( ) : (
<span className="entry-href entry-href-empty">—</span> <span className="entry-href entry-href-empty">—</span>
)} )}
</td> </td>
<td>
{checked ? (
<div className="card-order-cell">
<span className="card-order-index">{order.indexOf(option.key) + 1}</span>
<IconButton
label={`${option.title} 上移`}
icon={<Icon name="arrow_up_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === 0}
onClick={() => moveGroup(option.key, -1)}
/>
<IconButton
label={`${option.title} 下移`}
icon={<Icon name="arrow_down_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === order.length - 1}
onClick={() => moveGroup(option.key, 1)}
/>
</div>
) : (
<span className="entry-href entry-href-empty">—</span>
)}
</td>
<td> <td>
<Switch <Switch
checked={checked} checked={checked}
onChange={(next) => toggle(option.key, next)} onChange={(next) => toggle(option.key, next)}
label={`卡片包含 ${option.title}`} label={`卡片包含 ${option.title}`}
disabled={busy} disabled={busy || !option.enabled}
/> />
</td> </td>
</tr> </tr>
@@ -1704,13 +1773,122 @@ const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label:
function HeaderPanel({ function HeaderPanel({
value, value,
onChange, onChange,
site,
onChangeSite,
}: { }: {
value: SiteSettings['header'] value: SiteSettings['header']
onChange: (header: SiteSettings['header']) => void onChange: (header: SiteSettings['header']) => void
site: SiteSettings['site']
onChangeSite: (site: SiteSettings['site']) => void
}) { }) {
return ( return (
<Space direction="column" gap="lg" align="stretch" className="admin-form"> <Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" strong>
站点信息
</Text>
<FormField label="页面标题" htmlFor="site-title" hint="浏览器标签页上的文字">
<Input
id="site-title"
value={site.title}
placeholder="Stash · 本地应用工作区"
onChange={(event) => onChangeSite({ ...site, title: event.target.value })}
/>
</FormField>
<FormField label="Favicon 地址" htmlFor="site-favicon" hint="留空使用内置图标;支持 .svg / .png 或完整 URL">
<Input
id="site-favicon"
value={site.favicon}
placeholder="/favicon.svg"
onChange={(event) => onChangeSite({ ...site, favicon: event.target.value })}
/>
</FormField>
<Text size="sm" strong>
首页 Logo
</Text>
<div className="brand-preview" aria-hidden="true">
{site.brand.logo.trim() !== '' ? (
<img className="site-brand-logo" src={site.brand.logo} alt="" />
) : (
<span className="site-brand-mark">
{(site.brand.mark.trim() || site.brand.name.trim().slice(0, 1) || 'S').slice(0, 4)}
</span>
)}
{site.brand.name.trim() !== '' && (
<span className="site-brand-name">{site.brand.name}</span>
)}
</div>
<FormField label="Logo 图片地址" htmlFor="site-logo" hint="留空则用右侧的首字圆形标记">
<Input
id="site-logo"
value={site.brand.logo}
placeholder="https://… 或 /logo.svg"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, logo: event.target.value } })
}
/>
</FormField>
<div className="admin-row"> <div className="admin-row">
<FormField label="品牌文字" htmlFor="site-brand-name" hint="可留空只显示 logo">
<Input
id="site-brand-name"
value={site.brand.name}
placeholder="STASH"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, name: event.target.value } })
}
/>
</FormField>
<FormField label="首字标记" htmlFor="site-brand-mark" hint="1–4 个字">
<Input
id="site-brand-mark"
value={site.brand.mark}
placeholder="S"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, mark: event.target.value } })
}
/>
</FormField>
</div>
<Text size="sm" strong>
操作工具
</Text>
<div className="sidebar-toggle-row">
<Text>刷新</Text>
<Switch
checked={site.actions.refresh}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, refresh: next } })
}
label="页头显示刷新按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>站点设置</Text>
<Switch
checked={site.actions.settings}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, settings: next } })
}
label="页头显示站点设置按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>登录 / 退出</Text>
<Switch
checked={site.actions.session}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, session: next } })
}
label="页头显示登录或退出按钮"
/>
</div>
<Text size="sm" strong>
滚动信息
</Text>
<div className="sidebar-toggle-row">
<Text>显示页头滚动信息</Text> <Text>显示页头滚动信息</Text>
<Switch <Switch
checked={value.enabled} checked={value.enabled}
+75 -23
View File
@@ -1,8 +1,15 @@
import { useEffect, useRef } from 'react'
import { Icon, IconButton, Tag } from '@stash/ui' import { Icon, IconButton, Tag } from '@stash/ui'
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings' import {
fillHeaderText,
type HeaderItem,
type SettingsTone,
type SiteMetaSettings,
} from '../../settings'
/** /**
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。 * 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 品牌(logo / 文字)与操作图标是否出现,都来自「页头」Tab 的站点设置。
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。 * 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
*/ */
type SiteHeaderProps = { type SiteHeaderProps = {
@@ -15,10 +22,14 @@ type SiteHeaderProps = {
headerEnabled: boolean headerEnabled: boolean
/** 滚动一轮的秒数 */ /** 滚动一轮的秒数 */
headerSpeed: number headerSpeed: number
/** 品牌与操作工具配置 */
site: SiteMetaSettings
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */ /** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean authenticated: boolean
username?: string username?: string
onFocusSearch: () => void /** 刷新中:刷新按钮转圈 */
refreshing?: boolean
onRefresh: () => void
onLogin: () => void onLogin: () => void
onLogout: () => void onLogout: () => void
onSettings: () => void onSettings: () => void
@@ -72,25 +83,62 @@ export function SiteHeader({
headerItems, headerItems,
headerEnabled, headerEnabled,
headerSpeed, headerSpeed,
site,
authenticated, authenticated,
username, username,
onFocusSearch, refreshing = false,
onRefresh,
onLogin, onLogin,
onLogout, onLogout,
onSettings, onSettings,
}: SiteHeaderProps) { }: 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<HTMLDivElement>(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 ( return (
<div className="site-header-inner"> <div className="site-header-inner">
<a className="site-brand" href="/" aria-label="Stash 首页"> <a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
<span className="site-brand-mark" aria-hidden="true"> {brandLogo !== '' ? (
S <img className="site-brand-logo" src={brandLogo} alt="" />
</span> ) : (
<span className="site-brand-name">STASH</span> <span className="site-brand-mark" aria-hidden="true">
{brandMark}
</span>
)}
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
</a> </a>
{headerEnabled && ( {headerEnabled && (
<div className="header-ticker" role="status" aria-label="运行状态"> <div className="header-ticker" role="status" aria-label="运行状态">
<div <div
ref={trackRef}
className="header-ticker-track" className="header-ticker-track"
style={{ animationDuration: `${headerSpeed}s` }} style={{ animationDuration: `${headerSpeed}s` }}
> >
@@ -120,23 +168,27 @@ export function SiteHeader({
)} )}
<div className="header-actions"> <div className="header-actions">
<IconButton {site.actions.refresh && (
label="聚焦搜索框(Ctrl+K)" <IconButton
icon={<Icon name="search_3_line" />} label="刷新(重扫项目并重拉内容)"
onClick={onFocusSearch} icon={<Icon name="refresh_3_line" />}
/> spinning={refreshing}
{authenticated && ( onClick={onRefresh}
/>
)}
{site.actions.settings && authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} /> <IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
)} )}
{authenticated ? ( {site.actions.session &&
<IconButton (authenticated ? (
label={username === undefined ? '退出登录' : `退出登录(${username})`} <IconButton
icon={<Icon name="exit_line" />} label={username === undefined ? '退出登录' : `退出登录(${username})`}
onClick={onLogout} icon={<Icon name="exit_line" />}
/> onClick={onLogout}
) : ( />
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} /> ) : (
)} <IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
))}
</div> </div>
</div> </div>
) )
+8 -2
View File
@@ -189,9 +189,15 @@ export async function setCardGroups(
export function flattenGroups( export function flattenGroups(
groups: GroupNode[], groups: GroupNode[],
depth = 0, depth = 0,
): { key: string; title: string; depth: number; format: GroupFormat }[] { ): { key: string; title: string; depth: number; format: GroupFormat; enabled: boolean }[] {
return groups.flatMap((group) => [ 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), ...flattenGroups(group.children, depth + 1),
]) ])
} }
+34
View File
@@ -43,6 +43,8 @@ export type SiteSettings = {
show_profile: boolean show_profile: boolean
/** 是否显示「本地服务」面板 */ /** 是否显示「本地服务」面板 */
show_service: boolean show_service: boolean
/** 是否显示「分组导航」卡片(首页 / 分组树) */
show_nav: boolean
hidden: string[] hidden: string[]
order: string[] order: string[]
/** 隐藏的分组:`分区id/分组id` */ /** 隐藏的分组:`分区id/分组id` */
@@ -58,6 +60,31 @@ export type SiteSettings = {
} }
sections: SectionSettings sections: SectionSettings
profile: ProfileSettings 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 = { export type SettingsDocument = {
@@ -90,6 +117,7 @@ export function defaultSettings(): SiteSettings {
sidebar: { sidebar: {
show_profile: true, show_profile: true,
show_service: true, show_service: true,
show_nav: true,
hidden: [], hidden: [],
order: [], order: [],
hidden_groups: [], hidden_groups: [],
@@ -106,6 +134,12 @@ export function defaultSettings(): SiteSettings {
location: 'IP属地:局域网', location: 'IP属地:局域网',
avatar: '', avatar: '',
}, },
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { refresh: true, settings: true, session: true },
},
} }
} }