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 {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
+58
-16
@@ -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
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
+122
-101
@@ -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,41 +281,52 @@ export default function AppNext() {
|
||||
location: '',
|
||||
avatar: '',
|
||||
},
|
||||
site: {
|
||||
title: 'Stash · 本地应用工作区',
|
||||
favicon: '',
|
||||
brand: { name: 'STASH', mark: 'S', logo: '' },
|
||||
actions: { refresh: true, settings: true, session: true },
|
||||
},
|
||||
}
|
||||
|
||||
const keywordLower = keyword.trim().toLowerCase()
|
||||
|
||||
const sidebarItems: MenuItem[] = useMemo(() => {
|
||||
const hidden = new Set(site.sidebar.tree_hidden)
|
||||
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
|
||||
useEffect(() => {
|
||||
const title = site.site.title.trim()
|
||||
if (title !== '') document.title = title
|
||||
|
||||
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => {
|
||||
const ranks = new Map(
|
||||
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const),
|
||||
)
|
||||
return [...nodes].sort((a, b) => {
|
||||
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER
|
||||
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
|
||||
if (left === right) return a.sort - b.sort
|
||||
return left - right
|
||||
})
|
||||
const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim()
|
||||
let link = document.head.querySelector<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) => {
|
||||
if (node.enabled === false) return []
|
||||
if (hidden.has(node.key)) return build(node.children, node.key)
|
||||
const children = build(node.children, node.key)
|
||||
return [
|
||||
{
|
||||
key: `g:${node.key}`,
|
||||
label: node.title,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
const sidebarItems: MenuItem[] = useMemo(() => {
|
||||
// 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
|
||||
// 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
|
||||
const build = (nodes: GroupNode[]): MenuItem[] =>
|
||||
[...nodes]
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.flatMap((node) => {
|
||||
if (node.enabled === false) return []
|
||||
const children = build(node.children)
|
||||
return [
|
||||
{
|
||||
key: `g:${node.key}`,
|
||||
label: node.title,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
|
||||
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
|
||||
return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
|
||||
}, [manage.groups])
|
||||
|
||||
const activeKey =
|
||||
route.kind === 'home'
|
||||
@@ -367,11 +367,16 @@ export default function AppNext() {
|
||||
}
|
||||
|
||||
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
|
||||
const all = collectEntries(group).filter((entry) => matches(entry, keywordLower))
|
||||
// 根分组(分区)只展示直接挂在它下面的条目,子分组各自成块,避免同一批内容出现两次
|
||||
const rootGroup = group.parentKey === undefined || group.parentKey === null
|
||||
const source = rootGroup
|
||||
? (group.entries as unknown as Record<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,16 +504,18 @@ export default function AppNext() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3>{child.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
{!rootGroup && (
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
|
||||
>
|
||||
查看全部
|
||||
</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,23 +669,24 @@ export default function AppNext() {
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
)}
|
||||
<div className="nav-card">
|
||||
<Menu
|
||||
items={sidebarItems}
|
||||
activeKey={activeKey}
|
||||
itemHeight={39}
|
||||
onSelect={(key) => {
|
||||
if (key === 'home') navigate('/')
|
||||
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{site.sidebar.show_nav !== false && (
|
||||
<div className="nav-card">
|
||||
<Menu
|
||||
items={sidebarItems}
|
||||
activeKey={activeKey}
|
||||
itemHeight={39}
|
||||
onSelect={(key) => {
|
||||
if (key === 'home') navigate('/')
|
||||
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="content">
|
||||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
||||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||||
|
||||
{status === 'error' && (
|
||||
|
||||
@@ -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 首页">
|
||||
<span className="site-brand-mark" aria-hidden="true">
|
||||
S
|
||||
</span>
|
||||
<span className="site-brand-name">STASH</span>
|
||||
</a>
|
||||
<Space gap="sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={<Icon name="arrow_left_line" />}
|
||||
onClick={() => window.location.assign('/')}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
{authenticated && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<Icon name="exit_line" />}
|
||||
onClick={() => {
|
||||
void logout().then(() => window.location.assign('/'))
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<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">
|
||||
{brandMark}
|
||||
</span>
|
||||
)}
|
||||
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
|
||||
</a>
|
||||
|
||||
<div className="header-actions">
|
||||
<IconButton
|
||||
label="返回首页"
|
||||
icon={<Icon name="arrow_left_line" />}
|
||||
onClick={() => window.location.assign('/')}
|
||||
/>
|
||||
{authenticated && (
|
||||
<IconButton
|
||||
label="退出登录"
|
||||
icon={<Icon name="exit_line" />}
|
||||
onClick={() => {
|
||||
void logout().then(() => window.location.assign('/'))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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,42 +276,54 @@ export function AdminNext() {
|
||||
|
||||
{tab === 'content' && <ContentPanel groups={groups} onReload={load} />}
|
||||
|
||||
{tab === 'sidebar' && (
|
||||
<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 }),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tab === 'home' && (
|
||||
<div className="admin-home">
|
||||
<section className="admin-home-section">
|
||||
<Text size="md" strong>
|
||||
侧边栏
|
||||
</Text>
|
||||
<SidebarPanel
|
||||
value={draft.sidebar}
|
||||
saving={saving}
|
||||
onToggleCard={(card, visible) => {
|
||||
void saveSidebar({
|
||||
...draft.sidebar,
|
||||
...(card === 'profile'
|
||||
? { show_profile: visible }
|
||||
: card === 'service'
|
||||
? { show_service: visible }
|
||||
: { show_nav: visible }),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{tab === 'cards' && (
|
||||
<CardsPanel
|
||||
groups={groups}
|
||||
cards={cards}
|
||||
projects={projects}
|
||||
onReload={load}
|
||||
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })}
|
||||
/>
|
||||
<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}
|
||||
projects={projects}
|
||||
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]) => ({
|
||||
groupKey,
|
||||
previewLimit: limit.trim() === '' ? undefined : Number(limit),
|
||||
}))
|
||||
const groups = nextOrder
|
||||
.filter((key) => key in nextSelected)
|
||||
.map((groupKey) => {
|
||||
const limit = nextSelected[groupKey] ?? ''
|
||||
return {
|
||||
groupKey,
|
||||
previewLimit: limit.trim() === '' ? undefined : Number(limit),
|
||||
}
|
||||
})
|
||||
const result = await setCardGroups(card.id, groups)
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
@@ -1587,10 +1608,28 @@ function CardTable({
|
||||
|
||||
const toggle = (key: string, checked: boolean) => {
|
||||
const next = { ...selected }
|
||||
if (checked) next[key] = ''
|
||||
else delete next[key]
|
||||
let nextOrder = order
|
||||
if (checked) {
|
||||
next[key] = ''
|
||||
if (!order.includes(key)) nextOrder = [...order, key]
|
||||
} else {
|
||||
delete next[key]
|
||||
nextOrder = order.filter((item) => item !== key)
|
||||
}
|
||||
setSelected(next)
|
||||
void persistGroups(next)
|
||||
setOrder(nextOrder)
|
||||
void persistGroups(next, nextOrder)
|
||||
}
|
||||
|
||||
const moveGroup = (key: string, delta: number) => {
|
||||
const index = order.indexOf(key)
|
||||
const target = index + delta
|
||||
if (index < 0 || target < 0 || target >= order.length) return
|
||||
const nextOrder = [...order]
|
||||
nextOrder.splice(index, 1)
|
||||
nextOrder.splice(target, 0, key)
|
||||
setOrder(nextOrder)
|
||||
void persistGroups(selected, nextOrder)
|
||||
}
|
||||
|
||||
const saveTitle = async () => {
|
||||
@@ -1646,7 +1685,9 @@ function CardTable({
|
||||
<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}
|
||||
|
||||
@@ -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 首页">
|
||||
<span className="site-brand-mark" aria-hidden="true">
|
||||
S
|
||||
</span>
|
||||
<span className="site-brand-name">STASH</span>
|
||||
<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">
|
||||
{brandMark}
|
||||
</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,23 +168,27 @@ export function SiteHeader({
|
||||
)}
|
||||
|
||||
<div className="header-actions">
|
||||
<IconButton
|
||||
label="聚焦搜索框(Ctrl+K)"
|
||||
icon={<Icon name="search_3_line" />}
|
||||
onClick={onFocusSearch}
|
||||
/>
|
||||
{authenticated && (
|
||||
{site.actions.refresh && (
|
||||
<IconButton
|
||||
label="刷新(重扫项目并重拉内容)"
|
||||
icon={<Icon name="refresh_3_line" />}
|
||||
spinning={refreshing}
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
)}
|
||||
{site.actions.settings && authenticated && (
|
||||
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
|
||||
)}
|
||||
{authenticated ? (
|
||||
<IconButton
|
||||
label={username === undefined ? '退出登录' : `退出登录(${username})`}
|
||||
icon={<Icon name="exit_line" />}
|
||||
onClick={onLogout}
|
||||
/>
|
||||
) : (
|
||||
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
|
||||
)}
|
||||
{site.actions.session &&
|
||||
(authenticated ? (
|
||||
<IconButton
|
||||
label={username === undefined ? '退出登录' : `退出登录(${username})`}
|
||||
icon={<Icon name="exit_line" />}
|
||||
onClick={onLogout}
|
||||
/>
|
||||
) : (
|
||||
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user