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 {
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<String>,
/// 分类显示顺序(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(())
}
+57 -15
View File
@@ -1,7 +1,8 @@
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
extract::{OriginalUri, Path, Request, State},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::{get, patch, post, put},
@@ -36,6 +37,9 @@ pub struct AppState {
pub project_updates: broadcast::Sender<Vec<AppProject>>,
/// 内容库:分类 / 分组 / 条目的唯一数据源
pub db: Arc<Db>,
/// 内容库写入后的广播:SSE 用它对所有打开的页面推「该重拉」。
/// 不能只靠 `PRAGMA data_version`——服务自己的连接写完不会让它变化。
pub catalog_updates: broadcast::Sender<()>,
/// 登录失败计数(单用户本地场景,一个进程一份就够)
pub login_guard: Arc<std::sync::Mutex<LoginGuard>>,
}
@@ -234,30 +238,29 @@ async fn catalog_api(State(state): State<AppState>) -> Response {
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.clone();
let mut updates = state.catalog_updates.subscribe();
let stream = async_stream::stream! {
let mut last = db.data_version().unwrap_or(0);
let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop {
ticker.tick().await;
let current = match db.data_version() {
Ok(version) => version,
Err(error) => {
warn!("读取内容库版本失败: {error}");
continue;
// 两条来源:本进程的写接口主动广播(快、准),
// 以及 data_version 轮询兜底(外部工具直接改库时用)。
let changed = tokio::select! {
_ = ticker.tick() => match db.data_version() {
Ok(current) if current != last => {
last = current;
true
}
_ => false,
},
received = updates.recv() => received.is_ok(),
};
// 只在其它连接提交之后才推送。
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
// 因为 data_version 不会因本连接自己的提交而变化。
tracing::debug!(last, current, "内容库版本检查");
if current == last {
if !changed {
continue;
}
last = current;
// 只告诉前端「变了」,具体内容由它重新 GET /api/catalog
yield Ok::<Event, Infallible>(
@@ -273,6 +276,36 @@ async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
)
}
/// 任何写请求成功后,通知所有打开的页面「内容库变了」。
///
/// 放在中间件里而不是逐个 handler 调:以后新增写接口不用再记得挂号。
/// 登录相关接口不涉及内容库,跳过以免无谓刷新。
async fn notify_catalog_writes(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_owned();
let response = next.run(request).await;
let mutating = matches!(
method,
Method::POST | Method::PATCH | Method::PUT | Method::DELETE
);
if mutating
&& path.starts_with("/api/")
&& !path.starts_with("/api/auth/")
&& response.status().is_success()
{
// 没人订阅(没有开着的页面)时 send 会失败,忽略即可
let _ = state.catalog_updates.send(());
}
response
}
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
let root = state.config.root.clone();
@@ -1146,7 +1179,12 @@ fn build_router(state: AppState) -> Router {
.route("/favicon.svg", get(web_root_file))
.route("/assets/{*path}", get(web_asset))
.fallback_service(serve_dir)
.with_state(state)
.with_state(state.clone())
// 写接口成功后广播「内容库变了」,让所有打开的页面自己重拉
.layer(middleware::from_fn_with_state(
state,
notify_catalog_writes,
))
.layer(TraceLayer::new_for_http());
if enable_cors {
@@ -1170,6 +1208,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
let (project_updates, _) = broadcast::channel(32);
watcher::start(config.root.clone(), project_updates.clone())?;
let (catalog_updates, _) = broadcast::channel(16);
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
let db = Arc::new(Db::open(&config)?);
@@ -1178,6 +1217,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
config: Arc::new(config.clone()),
project_updates,
db,
catalog_updates,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
};
@@ -1240,11 +1280,13 @@ mod tests {
config.enable_cors = false;
let (project_updates, _) = broadcast::channel(4);
let (catalog_updates, _) = broadcast::channel(4);
build_router(AppState {
config: Arc::new(config),
project_updates,
db: Arc::new(Db::in_memory()),
catalog_updates,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
})
}
+157 -49
View File
@@ -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;
+2 -1
View File
@@ -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')}
+95 -74
View File
@@ -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<string, unknown>[] {
return [
...(group.entries as unknown as Record<string, unknown>[]),
...group.children.flatMap((child) => collectEntries(child)),
...group.children
.filter((child) => child.enabled !== false)
.flatMap((child) => collectEntries(child)),
]
}
@@ -161,8 +171,6 @@ function matches(entry: Record<string, unknown>, keyword: string): boolean {
return text.includes(keyword)
}
const TOP_OFFSET = 104
export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute()
@@ -189,7 +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,30 +281,41 @@ 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<HTMLLinkElement>('link[rel="icon"]')
if (link === null) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
link.type = href.endsWith('.svg') ? 'image/svg+xml' : ''
}, [site.site.title, site.site.favicon])
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
sortNodes(nodes, parentKey).flatMap((node) => {
const sidebarItems: MenuItem[] = useMemo(() => {
// 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
// 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
const build = (nodes: GroupNode[]): MenuItem[] =>
[...nodes]
.sort((a, b) => a.sort - b.sort)
.flatMap((node) => {
if (node.enabled === false) return []
if (hidden.has(node.key)) return build(node.children, node.key)
const children = build(node.children, node.key)
const children = build(node.children)
return [
{
key: `g:${node.key}`,
@@ -325,8 +325,8 @@ export default function AppNext() {
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
}, [manage.groups])
const activeKey =
route.kind === 'home'
@@ -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<string, unknown>[])
: collectEntries(group)
const all = source.filter((entry) => matches(entry, keywordLower))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
{showTitle && <h3 className="card-group-title">{group.title}</h3>}
{showTitle && !rootGroup && <h3 className="card-group-title">{group.title}</h3>}
{renderEntries(group, all, { limit })}
</div>
)
@@ -406,7 +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 <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
}
const trail = findPath(manage.groups, key) ?? [group]
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
const rootGroup = group.parentKey === undefined || group.parentKey === null
return (
<div className="detail-view">
@@ -486,7 +493,9 @@ export default function AppNext() {
)}
<Space direction="column" gap="lg" align="stretch">
{group.children.map((child) => (
{group.children
.filter((child) => child.enabled !== false)
.map((child) => (
<section key={child.key} className="group-child-block">
<div className="group-title">
<span
@@ -495,6 +504,7 @@ export default function AppNext() {
aria-hidden="true"
/>
<h3>{child.title}</h3>
{!rootGroup && (
<button
type="button"
className="link-button"
@@ -502,9 +512,10 @@ export default function AppNext() {
>
查看全部
</button>
)}
</div>
{child.entries.length > 0 ? (
renderEntries(child, child.entries, { limit: 12 })
renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
) : (
<Empty text="暂无内容" />
)}
@@ -517,7 +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 <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
}
@@ -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={
<div className="search-toolbar">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
</div>
}
sider={
<div className="sidebar-wrap">
<div className="sidebar sidebar-home">
@@ -649,6 +669,7 @@ export default function AppNext() {
onRefresh={refresh}
/>
)}
{site.sidebar.show_nav !== false && (
<div className="nav-card">
<Menu
items={sidebarItems}
@@ -660,12 +681,12 @@ export default function AppNext() {
}}
/>
</div>
)}
</div>
</div>
}
>
<div className="content">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
+251 -73
View File
@@ -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<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 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 (
<div className="admin-page">
<Toaster />
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
<header className="admin-topbar">
<a className="site-brand" href="/" aria-label="Stash 首页">
<div className="site-header-inner">
<a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
{brandLogo !== '' ? (
<img className="site-brand-logo" src={brandLogo} alt="" />
) : (
<span className="site-brand-mark" aria-hidden="true">
S
{brandMark}
</span>
<span className="site-brand-name">STASH</span>
)}
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
</a>
<Space gap="sm">
<Button
variant="ghost"
<div className="header-actions">
<IconButton
label="返回首页"
icon={<Icon name="arrow_left_line" />}
onClick={() => window.location.assign('/')}
>
返回首页
</Button>
/>
{authenticated && (
<Button
variant="secondary"
<IconButton
label="退出登录"
icon={<Icon name="exit_line" />}
onClick={() => {
void logout().then(() => window.location.assign('/'))
}}
>
退出登录
</Button>
/>
)}
</Space>
</div>
</div>
</header>
<main className="admin-main">
@@ -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,29 +276,37 @@ export function AdminNext() {
{tab === 'content' && <ContentPanel groups={groups} onReload={load} />}
{tab === 'sidebar' && (
{tab === 'home' && (
<div className="admin-home">
<section className="admin-home-section">
<Text size="md" strong>
侧边栏
</Text>
<SidebarPanel
groups={groups}
value={draft.sidebar}
saving={saving}
onToggle={(key, visible) => {
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 }),
: card === 'service'
? { show_service: visible }
: { show_nav: visible }),
})
}}
/>
)}
</section>
{tab === 'cards' && (
<div className="sidebar-divider" />
<section className="admin-home-section">
<Text size="md" strong>
首页卡片
</Text>
<Text size="sm" tone="muted">
卡片 = 一张首页区块 + 勾选它包含哪些分组;分组本身的启停仍由「分组管理」决定。
</Text>
<CardsPanel
groups={groups}
cards={cards}
@@ -280,12 +314,16 @@ export function AdminNext() {
onReload={load}
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })}
/>
</section>
</div>
)}
{tab === 'header' && (
<HeaderPanel
value={draft.header}
onChange={(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) => (
<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 (
<Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
侧栏卡片与分组导航分开控制;开关修改后立即保存。
分组在侧边栏的显隐只由「分组管理 → 状态」决定;这里只管三块侧栏卡片。
</Text>
<Text size="sm" strong>
@@ -1258,16 +1273,15 @@ function SidebarPanel({
disabled={saving}
/>
</div>
<div className="sidebar-divider" />
<Text size="sm" strong>
分组导航
</Text>
<Text size="sm" tone="muted">
勾选要在侧边栏显示的分组;子分组可以单独勾选,父分组不勾选也不影响子项。
</Text>
{render(groups, 0)}
<div className="sidebar-toggle-row">
<Text className="admin-item-title">分组导航</Text>
<Switch
checked={value.show_nav !== false}
onChange={(visible) => onToggleCard('nav', visible)}
label="显示分组导航卡片"
disabled={saving}
/>
</div>
</Space>
)
}
@@ -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<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)
const groups = Object.entries(nextSelected).map(([groupKey, 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({
<thead>
<tr>
<th>分组名称</th>
<th className="cards-col-state">状态</th>
<th className="cards-col-limit">预览条数</th>
<th className="cards-col-order">顺序</th>
<th className="group-col-status">展示</th>
</tr>
</thead>
@@ -1660,6 +1701,11 @@ function CardTable({
<span className="group-name">{option.title}</span>
</div>
</td>
<td>
<span className={option.enabled ? 'group-state-on' : 'group-off-tag'}>
{option.enabled ? '启用' : '已停用'}
</span>
</td>
<td>
{checked ? (
<Input
@@ -1673,18 +1719,41 @@ function CardTable({
[option.key]: event.target.value,
}))
}
onBlur={() => void persistGroups(selected)}
onBlur={() => void persistGroups(selected, order)}
/>
) : (
<span className="entry-href entry-href-empty">—</span>
)}
</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>
<Switch
checked={checked}
onChange={(next) => toggle(option.key, next)}
label={`卡片包含 ${option.title}`}
disabled={busy}
disabled={busy || !option.enabled}
/>
</td>
</tr>
@@ -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 (
<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">
<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>
<Switch
checked={value.enabled}
+64 -12
View File
@@ -1,8 +1,15 @@
import { useEffect, useRef } from 'react'
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 配置)、右侧操作图标。
* 品牌(logo / 文字)与操作图标是否出现,都来自「页头」Tab 的站点设置。
* 外层 <header> 由 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<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 (
<div className="site-header-inner">
<a className="site-brand" href="/" aria-label="Stash 首页">
<a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
{brandLogo !== '' ? (
<img className="site-brand-logo" src={brandLogo} alt="" />
) : (
<span className="site-brand-mark" aria-hidden="true">
S
{brandMark}
</span>
<span className="site-brand-name">STASH</span>
)}
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
</a>
{headerEnabled && (
<div className="header-ticker" role="status" aria-label="运行状态">
<div
ref={trackRef}
className="header-ticker-track"
style={{ animationDuration: `${headerSpeed}s` }}
>
@@ -120,15 +168,19 @@ export function SiteHeader({
)}
<div className="header-actions">
{site.actions.refresh && (
<IconButton
label="聚焦搜索框(Ctrl+K)"
icon={<Icon name="search_3_line" />}
onClick={onFocusSearch}
label="刷新(重扫项目并重拉内容)"
icon={<Icon name="refresh_3_line" />}
spinning={refreshing}
onClick={onRefresh}
/>
{authenticated && (
)}
{site.actions.settings && authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
)}
{authenticated ? (
{site.actions.session &&
(authenticated ? (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
@@ -136,7 +188,7 @@ export function SiteHeader({
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
)}
))}
</div>
</div>
)
+8 -2
View File
@@ -189,9 +189,15 @@ export async function setCardGroups(
export function flattenGroups(
groups: GroupNode[],
depth = 0,
): { key: string; title: string; depth: number; format: GroupFormat }[] {
): { key: string; title: string; depth: number; format: GroupFormat; enabled: boolean }[] {
return groups.flatMap((group) => [
{ key: group.key, title: group.title, depth, format: group.format },
{
key: group.key,
title: group.title,
depth,
format: group.format,
enabled: group.enabled !== false,
},
...flattenGroups(group.children, depth + 1),
])
}
+34
View File
@@ -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 },
},
}
}