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:
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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' && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user