style: 统一前后台宽度与数值标尺,站点外壳改用 @stash/ui
对应风格审计的 1-4 条: 1. 宽度:后台内容区改为与页头内层同宽同左缘(1380 - 40 = 1340), 修掉品牌 x=50、卡片 x=280 的错位 2. 阴影:--su-shadow-card / hover 的覆盖从 .app 挪到 :root,前后台同一套 3. 死代码:删除未被引用的 App.tsx(436 行)、AdminPage.tsx(54KB)与 .content-veil 样式 4. 标尺:37 处脱离 4/8/12/16/20/24/32/40 与字阶的间距/字号/圆角/行高改为令牌 (含新增的 --su-font-size-2xl);6px 的认证角标按图标保留并注明原因 顺带把重复实现收进库: - 页头品牌 -> Brand - 页头滚动信息 -> Ticker(含实测宽度的无缝循环) - 搜索框 -> SearchField(variant=card),删除 components/search/SearchBar.tsx - 后台「首页 Logo」预览与后台页头同样复用 Brand
This commit is contained in:
+50
-159
@@ -1,14 +1,17 @@
|
||||
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
|
||||
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
|
||||
|
||||
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套阴影 */
|
||||
:root {
|
||||
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
|
||||
--su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
|
||||
}
|
||||
|
||||
.app {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* 透明:让固定背景图从下面透出来(Layout 默认有底色) */
|
||||
background-color: transparent;
|
||||
/* 旧版影子是 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);
|
||||
}
|
||||
|
||||
/* 顶部背景图:固定 65vh,底部渐隐到页面底色 */
|
||||
@@ -45,32 +48,7 @@
|
||||
padding: 0 var(--su-space-5);
|
||||
}
|
||||
|
||||
.site-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.site-brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--su-radius-pill);
|
||||
background-color: var(--su-text-main);
|
||||
color: var(--su-white);
|
||||
font-size: var(--su-font-size-base);
|
||||
font-weight: var(--su-font-weight-strong);
|
||||
}
|
||||
|
||||
.site-brand-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--su-radius-sm);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 后台「首页 Logo」的实时预览 */
|
||||
/* 后台「首页 Logo」的实时预览:外观交给库里的 Brand,这里只管外框 */
|
||||
.brand-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -81,42 +59,9 @@
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.site-brand-name {
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---------- 页头滚动信息 ---------- */
|
||||
|
||||
.header-ticker {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
#000 32px,
|
||||
#000 calc(100% - 32px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.header-ticker-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: header-ticker 24s linear infinite;
|
||||
}
|
||||
|
||||
/* 每组占满整条轨道宽度:任何时候只看到一份信息,滚动时下一份从右侧进入 */
|
||||
.header-ticker-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
gap: var(--su-space-8);
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* 跑马灯外壳在 @stash/ui 的 Ticker 里,这里只剩每条信息的排版 */
|
||||
.header-ticker-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -127,24 +72,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes header-ticker {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 位移量由 SiteHeader 量出「一组」的真实宽度写进 --ticker-shift;
|
||||
回退值仅在第一帧兜底,正常情况不会用到 */
|
||||
to {
|
||||
transform: translateX(var(--ticker-shift, -33.3333%));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.header-ticker-track {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -158,21 +85,6 @@
|
||||
padding: var(--su-space-8) 0 calc(var(--su-space-10) + var(--su-space-1));
|
||||
}
|
||||
|
||||
.search-row {
|
||||
width: min(480px, 100%);
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* 旧版搜索是「浅边框 + 卡片阴影」,聚焦时不额外加边框/焦点环 */
|
||||
.search-row > *:first-child,
|
||||
.search-row > *:first-child:focus-within {
|
||||
border-color: var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
box-shadow: var(--su-shadow-card);
|
||||
}
|
||||
|
||||
/* 搜索框保持实底清晰,透明度/虚化只作用于周围区域 */
|
||||
|
||||
/* ---------- 两栏骨架 ---------- */
|
||||
|
||||
.content {
|
||||
@@ -183,30 +95,6 @@
|
||||
padding-bottom: var(--su-space-10);
|
||||
}
|
||||
|
||||
/* 顶部渐隐:sticky 固定在滚动区顶端,内容从下面淡出而不是被硬切 */
|
||||
.content-veil {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
height: 32px;
|
||||
/* 抵消自身高度 + 父容器 gap,避免把首张卡片往下顶 */
|
||||
margin-bottom: calc(-32px - var(--su-space-5));
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: linear-gradient(to bottom, var(--su-bg-page) 0%, transparent 100%);
|
||||
transition: opacity var(--su-duration-fast) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.content-veil.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.content-veil {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -220,8 +108,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 18px 14px 14px;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-5) var(--su-space-4) var(--su-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -230,11 +118,11 @@
|
||||
}
|
||||
|
||||
.profile-role {
|
||||
padding: 1px 8px;
|
||||
padding: 0 var(--su-space-2);
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-pill);
|
||||
font-size: var(--su-font-size-xs);
|
||||
line-height: 18px;
|
||||
line-height: var(--su-line-height-sm);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
@@ -246,7 +134,7 @@
|
||||
.profile-location {
|
||||
margin-top: 2px;
|
||||
font-size: var(--su-font-size-xs);
|
||||
line-height: 18px;
|
||||
line-height: var(--su-line-height-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
@@ -256,30 +144,30 @@
|
||||
}
|
||||
|
||||
.service-panel {
|
||||
padding: 12px 14px;
|
||||
padding: var(--su-space-3) var(--su-space-4);
|
||||
}
|
||||
|
||||
/* 旧版标题与统计值的行高比库默认更紧 */
|
||||
.service-panel h2 {
|
||||
line-height: 20px;
|
||||
line-height: var(--su-line-height-sm);
|
||||
}
|
||||
|
||||
/* 旧版连接状态药丸:22 高、12px */
|
||||
.service-status {
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
padding: 0 var(--su-space-3);
|
||||
font-size: var(--su-font-size-xs);
|
||||
}
|
||||
|
||||
/* 旧版统计行是 12/18 */
|
||||
.service-stats span {
|
||||
line-height: 18px;
|
||||
line-height: var(--su-line-height-sm);
|
||||
}
|
||||
|
||||
.service-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: var(--su-space-2);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@@ -334,10 +222,10 @@
|
||||
right: -18px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
padding: 0 var(--su-space-1);
|
||||
border-radius: var(--su-radius-sm);
|
||||
box-shadow: 0 1px 3px color-mix(in srgb, var(--su-color-danger) 30%, transparent);
|
||||
font-size: 12px;
|
||||
font-size: var(--su-font-size-xs);
|
||||
font-weight: var(--su-font-weight-strong);
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -356,7 +244,7 @@
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
margin-right: var(--su-space-2);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--su-radius-xs);
|
||||
background-color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
@@ -379,7 +267,7 @@
|
||||
.group-crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-2) var(--su-space-3);
|
||||
}
|
||||
|
||||
@@ -401,7 +289,7 @@
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
margin-right: var(--su-space-2);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--su-radius-xs);
|
||||
background-color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
@@ -410,8 +298,8 @@
|
||||
.app-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 6px 0;
|
||||
gap: var(--su-space-4);
|
||||
padding: var(--su-space-2) 0;
|
||||
border-radius: var(--su-radius-sm);
|
||||
}
|
||||
|
||||
@@ -422,7 +310,7 @@
|
||||
.app-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--su-space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -430,13 +318,15 @@
|
||||
.app-card-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 18px;
|
||||
font-size: var(--su-font-size-lg);
|
||||
}
|
||||
|
||||
.app-card-avatar > span + span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-width: 1px;
|
||||
/* 6px 不是文字尺寸:这是 10px 圆标里的一枚「✓」字形,按图标对待;
|
||||
设计系统的字阶(12 起)不覆盖这种装饰字形 */
|
||||
font-size: 6px;
|
||||
}
|
||||
|
||||
@@ -495,7 +385,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0 4px;
|
||||
padding: var(--su-space-3) 0 var(--su-space-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -509,7 +399,7 @@
|
||||
border-radius: 50%;
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
font-size: 24px;
|
||||
font-size: var(--su-font-size-xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -529,7 +419,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 28px;
|
||||
padding: var(--su-space-6);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
@@ -545,7 +435,7 @@
|
||||
/* 旧版标签:2px 10px、12px、主色浅底 */
|
||||
.item-tag {
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
padding: 0 var(--su-space-3);
|
||||
font-size: var(--su-font-size-xs);
|
||||
}
|
||||
|
||||
@@ -565,7 +455,7 @@
|
||||
.item-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 18px 0 10px;
|
||||
margin: var(--su-space-5) 0 var(--su-space-3);
|
||||
overflow: hidden;
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
@@ -590,13 +480,13 @@
|
||||
}
|
||||
|
||||
.item-cover-letter {
|
||||
font-size: 32px;
|
||||
font-size: var(--su-font-size-2xl);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.item-quote {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: var(--su-space-2);
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
text-align: left;
|
||||
@@ -604,7 +494,7 @@
|
||||
|
||||
.item-quote-mark {
|
||||
flex-shrink: 0;
|
||||
font-size: 32px;
|
||||
font-size: var(--su-font-size-2xl);
|
||||
font-weight: var(--su-font-weight-strong);
|
||||
line-height: 1;
|
||||
color: color-mix(in srgb, var(--su-color-danger) 35%, transparent);
|
||||
@@ -647,7 +537,7 @@
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
gap: var(--su-space-2);
|
||||
height: 36px;
|
||||
border-radius: var(--su-radius-cover);
|
||||
background-color: var(--su-color-primary);
|
||||
@@ -717,7 +607,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-3);
|
||||
padding: 32px 40px 40px;
|
||||
padding: var(--su-space-8) var(--su-space-10) var(--su-space-10);
|
||||
}
|
||||
|
||||
.article-lead {
|
||||
@@ -735,7 +625,7 @@
|
||||
.related-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 24px;
|
||||
gap: var(--su-space-4) var(--su-space-6);
|
||||
margin-top: var(--su-space-4);
|
||||
}
|
||||
|
||||
@@ -749,9 +639,9 @@
|
||||
.related-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: var(--su-space-3);
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
padding: var(--su-space-1);
|
||||
border-radius: var(--su-radius-cover);
|
||||
text-align: left;
|
||||
transition: background-color var(--su-duration-fast) var(--su-ease-in-out);
|
||||
@@ -862,9 +752,10 @@
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
width: min(880px, calc(100% - 48px));
|
||||
/* 与页头内层同宽同左缘:页头 1380 减左右各 20 的内边距 = 内容从 50 起 */
|
||||
width: min(var(--su-layout-width), 100%);
|
||||
margin: 0 auto;
|
||||
padding: var(--su-space-8) 0 var(--su-space-10);
|
||||
padding: var(--su-space-8) var(--su-space-5) var(--su-space-10);
|
||||
}
|
||||
|
||||
.admin-locked {
|
||||
@@ -1434,7 +1325,7 @@ a.entry-href:hover {
|
||||
.cards-side-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--su-space-1);
|
||||
}
|
||||
|
||||
.cards-side-item {
|
||||
@@ -1564,18 +1455,18 @@ a.entry-href:hover {
|
||||
.card-order-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: var(--su-space-1);
|
||||
}
|
||||
|
||||
/* 分组管理里状态为「关闭」的分组:卡片里只能看、不能勾选 */
|
||||
.group-off-tag {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
padding: 0 var(--su-space-2);
|
||||
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;
|
||||
line-height: var(--su-line-height-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
import { Empty, Layout, Notice, Toaster } from '@stash/ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BackToTop } from './components/common/BackToTop'
|
||||
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
||||
import { DetailView } from './components/content/DetailView'
|
||||
import { HomeView } from './components/content/HomeView'
|
||||
import { ItemDetailView } from './components/content/ItemDetailView'
|
||||
import { SiteHeader } from './components/header/SiteHeader'
|
||||
import { SearchBar } from './components/search/SearchBar'
|
||||
import { Sidebar } from './components/sidebar/Sidebar'
|
||||
import { withLocalCategory } from './data/local'
|
||||
import {
|
||||
appDetailModel,
|
||||
posterDetailModel,
|
||||
posterToRelatedItem,
|
||||
toRelatedItem,
|
||||
} from './data/detail'
|
||||
import { filterCategories } from './data/filter'
|
||||
import type { NavTarget } from './data/nav'
|
||||
import {
|
||||
type Category,
|
||||
findAppItem,
|
||||
findArticleItem,
|
||||
findPosterItem,
|
||||
relatedAppItems,
|
||||
relatedPosterItems,
|
||||
} from './data/types'
|
||||
import { useProjects } from './useProjects'
|
||||
import { useCatalog } from './useCatalog'
|
||||
import { useViewRoute } from './useViewRoute'
|
||||
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
|
||||
import {
|
||||
applySidebarGroups,
|
||||
applySidebarSettings,
|
||||
defaultSettings,
|
||||
fetchSettings,
|
||||
type SettingsDocument,
|
||||
} from './settings'
|
||||
import { LoginDialog } from './components/auth/LoginDialog'
|
||||
import { ItemEditor } from './components/content/ItemEditor'
|
||||
import { parseViewPath, viewPath, type View } from './data/view'
|
||||
import './App.css'
|
||||
|
||||
export default function App() {
|
||||
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
||||
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
|
||||
const catalog = useCatalog()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
|
||||
const { view, go } = useViewRoute()
|
||||
const [session, setSession] = useState<SessionState | null>(null)
|
||||
const [settings, setSettings] = useState<SettingsDocument>({
|
||||
version: 0,
|
||||
settings: defaultSettings(),
|
||||
})
|
||||
const [loginOpen, setLoginOpen] = useState(false)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 登录态:只用来切换页头按钮与后续的编辑入口
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
fetchSession().then((next) => {
|
||||
if (alive) setSession(next)
|
||||
})
|
||||
fetchSettings().then((next) => {
|
||||
if (alive) setSettings(next)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const categories = useMemo(
|
||||
() => withLocalCategory(projects, catalog.categories),
|
||||
[projects, catalog.categories],
|
||||
)
|
||||
const visibleCategories = useMemo(
|
||||
() => filterCategories(categories, keyword),
|
||||
[categories, keyword],
|
||||
)
|
||||
|
||||
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
|
||||
// 再按 /admin 里的顺序、显隐与分组设置重排一遍
|
||||
const sidebarCategories = useMemo(
|
||||
() =>
|
||||
applySidebarGroups(
|
||||
applySidebarSettings(
|
||||
categories.filter(
|
||||
(category) => category.id !== 'local' && category.showInSidebar !== false,
|
||||
),
|
||||
settings.settings.sidebar,
|
||||
),
|
||||
settings.settings.sidebar,
|
||||
),
|
||||
[categories, settings.settings.sidebar],
|
||||
)
|
||||
|
||||
const detailView = view.type === 'detail' ? view : undefined
|
||||
const itemView = view.type === 'item' ? view : undefined
|
||||
|
||||
const detailCategory =
|
||||
detailView !== undefined
|
||||
? categories.find((category) => category.id === detailView.categoryId)
|
||||
: undefined
|
||||
|
||||
const itemCategory =
|
||||
itemView !== undefined
|
||||
? categories.find((category) => category.id === itemView.categoryId)
|
||||
: undefined
|
||||
|
||||
// 应用与电影共用一个详情模型,这里按分类类型做映射
|
||||
const detailModel = useMemo(() => {
|
||||
if (itemView === undefined || itemCategory === undefined) return undefined
|
||||
|
||||
if (itemCategory.kind === 'apps') {
|
||||
const item = findAppItem(itemCategory, itemView.itemId)
|
||||
return item !== undefined ? appDetailModel(item) : undefined
|
||||
}
|
||||
|
||||
if (itemCategory.kind === 'posters') {
|
||||
const poster = findPosterItem(itemCategory, itemView.itemId)
|
||||
return poster !== undefined ? posterDetailModel(poster) : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [itemView, itemCategory])
|
||||
|
||||
const relatedItems = useMemo(() => {
|
||||
if (itemView === undefined || itemCategory === undefined) return []
|
||||
|
||||
if (itemCategory.kind === 'apps') {
|
||||
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
|
||||
}
|
||||
|
||||
if (itemCategory.kind === 'posters') {
|
||||
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
|
||||
}
|
||||
|
||||
return []
|
||||
}, [itemView, itemCategory])
|
||||
|
||||
// 切换视图时把内容区(内部滚动)和窗口都回到顶部
|
||||
useEffect(() => {
|
||||
const scroller = document.getElementById('content-scroll')
|
||||
scroller?.scrollTo({ top: 0 })
|
||||
window.scrollTo({ top: 0 })
|
||||
}, [view])
|
||||
|
||||
/**
|
||||
* 滚动状态同时驱动「顶部渐隐」和「回到顶部」。
|
||||
* 桌面端滚的是内容容器(scroll 事件不冒泡),所以用捕获阶段监听。
|
||||
*/
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
|
||||
const next = Math.max(inner, window.scrollY) > 4
|
||||
setScrolled((current) => (current === next ? current : next))
|
||||
}
|
||||
|
||||
sync()
|
||||
window.addEventListener('scroll', sync, { passive: true })
|
||||
document.addEventListener('scroll', sync, { passive: true, capture: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', sync)
|
||||
document.removeEventListener('scroll', sync, { capture: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 「打开这个分区」应该落到哪个视图。
|
||||
*
|
||||
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
|
||||
*/
|
||||
const detailViewFor = (categoryId: string): View => {
|
||||
const category = categories.find((item) => item.id === categoryId)
|
||||
|
||||
if (category?.block !== undefined) {
|
||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
||||
if (group !== undefined) {
|
||||
return { type: 'detail', categoryId, groupId: group.id }
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'detail', categoryId }
|
||||
}
|
||||
|
||||
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
|
||||
|
||||
/** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */
|
||||
const groupOfItem = (category: Category, itemId: string): string | undefined => {
|
||||
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
|
||||
}
|
||||
|
||||
const openItem = (categoryId: string, itemId: string) => {
|
||||
const category = categories.find((item) => item.id === categoryId)
|
||||
|
||||
go({
|
||||
type: 'item',
|
||||
categoryId,
|
||||
itemId,
|
||||
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
|
||||
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏导航:带分组时直接打开那个分组;
|
||||
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
|
||||
*/
|
||||
const navigate = (target: NavTarget) => {
|
||||
if (target.groupId === undefined) {
|
||||
openDetail(target.categoryId)
|
||||
return
|
||||
}
|
||||
|
||||
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
|
||||
}
|
||||
|
||||
const goHome = () => go({ type: 'home' })
|
||||
|
||||
/** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */
|
||||
const navigateHref = (href: string) => go(parseViewPath(href))
|
||||
|
||||
const searching = keyword.trim() !== ''
|
||||
|
||||
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
|
||||
|
||||
/**
|
||||
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
|
||||
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
|
||||
*/
|
||||
const dataPending =
|
||||
viewCategoryId !== undefined &&
|
||||
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
|
||||
|
||||
/**
|
||||
* 条目详情按分类类型分流:
|
||||
* 图文走长文版式,应用 / 电影走通用的条目版式。
|
||||
*/
|
||||
function renderItemView() {
|
||||
if (itemView === undefined) return null
|
||||
if (itemCategory === undefined) {
|
||||
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
||||
}
|
||||
|
||||
if (itemCategory.kind === 'articles') {
|
||||
const article = findArticleItem(itemCategory, itemView.itemId)
|
||||
if (article === undefined) {
|
||||
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ArticleDetailView
|
||||
article={article}
|
||||
categoryId={itemCategory.id}
|
||||
categoryTitle={itemCategory.title}
|
||||
onNavigate={navigateHref}
|
||||
onEdit={() => setEditingKey(itemView.itemId)}
|
||||
canEdit={session?.authenticated === true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (detailModel === undefined) {
|
||||
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemDetailView
|
||||
itemKey={itemView.itemId}
|
||||
crumbs={[
|
||||
{ label: '首页', href: '/' },
|
||||
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
|
||||
{ label: detailModel.title },
|
||||
]}
|
||||
model={detailModel}
|
||||
related={relatedItems}
|
||||
onNavigate={navigateHref}
|
||||
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
||||
onEdit={() => setEditingKey(itemView.itemId)}
|
||||
canEdit={session?.authenticated === true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void logoutRequest().then(() => {
|
||||
setSession({ authenticated: false, has_admin: true })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
|
||||
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
hasAdmin={session?.has_admin ?? false}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onSuccess={(next) => {
|
||||
setSession(next)
|
||||
setLoginOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ItemEditor
|
||||
open={editingKey !== null}
|
||||
itemKey={editingKey}
|
||||
onClose={() => setEditingKey(null)}
|
||||
onSaved={() => catalog.reload()}
|
||||
/>
|
||||
|
||||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||||
<div className="site-background" aria-hidden="true" />
|
||||
|
||||
<Layout
|
||||
contained
|
||||
maxWidth={1380}
|
||||
gutter={20}
|
||||
siderWidth={240}
|
||||
gap={20}
|
||||
headerHeight={60}
|
||||
headerVariant="solid"
|
||||
siderBordered={false}
|
||||
contentPadding="none"
|
||||
contentOffset={88}
|
||||
height="screen"
|
||||
scrollable
|
||||
hideScrollbar
|
||||
contentId="content-scroll"
|
||||
className="app"
|
||||
header={
|
||||
<SiteHeader
|
||||
projectCount={projects.length}
|
||||
sampleProject={projects[0]?.name}
|
||||
live={live}
|
||||
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}
|
||||
onRefresh={() => refresh()}
|
||||
onLogin={() => setLoginOpen(true)}
|
||||
onLogout={handleLogout}
|
||||
onSettings={() => window.location.assign('/admin')}
|
||||
/>
|
||||
}
|
||||
sider={
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
categories={sidebarCategories}
|
||||
profile={settings.settings.profile}
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
activeCategoryId={viewCategoryId}
|
||||
activeGroupId={detailView?.groupId}
|
||||
homeActive={view.type === 'home'}
|
||||
onNavigate={navigate}
|
||||
onHome={goHome}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="content">
|
||||
{/* 滚动后淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
|
||||
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
|
||||
|
||||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
||||
|
||||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||||
|
||||
{status === 'error' && (
|
||||
<Notice
|
||||
tone="error"
|
||||
title="连不上 Stash 服务"
|
||||
action={{ label: '重试', onClick: refresh }}
|
||||
>
|
||||
{error}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
|
||||
{catalog.status === 'error' && (
|
||||
<Notice
|
||||
tone="error"
|
||||
title="内容库读取失败"
|
||||
action={{ label: '重试', onClick: catalog.reload }}
|
||||
>
|
||||
{catalog.error}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{detailView === undefined && itemView === undefined && (
|
||||
<>
|
||||
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
|
||||
|
||||
<HomeView
|
||||
categories={visibleCategories}
|
||||
sections={settings.settings.sections}
|
||||
onOpenDetail={openDetail}
|
||||
onOpenItem={openItem}
|
||||
/>
|
||||
|
||||
{searching && visibleCategories.length === 0 && (
|
||||
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
|
||||
换个关键字试试,或清空搜索查看全部。
|
||||
</Notice>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{detailView !== undefined && detailCategory !== undefined && (
|
||||
<DetailView
|
||||
category={detailCategory}
|
||||
groupId={detailView.groupId}
|
||||
onNavigate={navigateHref}
|
||||
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailView !== undefined && detailCategory === undefined && (
|
||||
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
||||
)}
|
||||
|
||||
{itemView !== undefined && renderItemView()}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<BackToTop visible={scrolled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Layout,
|
||||
Menu,
|
||||
Notice,
|
||||
SearchField,
|
||||
Space,
|
||||
Toaster,
|
||||
type MenuItem,
|
||||
@@ -17,7 +18,6 @@ import { ItemEditor } from './components/content/ItemEditor'
|
||||
import { PosterGrid } from './components/content/PosterGrid'
|
||||
import { SectionCard } from './components/content/SectionCard'
|
||||
import { SiteHeader } from './components/header/SiteHeader'
|
||||
import { SearchBar } from './components/search/SearchBar'
|
||||
import { LoginDialog } from './components/auth/LoginDialog'
|
||||
import { ProfileCard } from './components/sidebar/ProfileCard'
|
||||
import { ServicePanel } from './components/sidebar/ServicePanel'
|
||||
@@ -654,7 +654,15 @@ export default function AppNext() {
|
||||
}
|
||||
toolbar={
|
||||
<div className="search-toolbar">
|
||||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
||||
<SearchField
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
inputRef={searchRef}
|
||||
placeholder="搜索本地项目…"
|
||||
label="搜索本地项目"
|
||||
variant="card"
|
||||
hotkey="k"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
sider={
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Brand,
|
||||
Button,
|
||||
Card,
|
||||
FormField,
|
||||
@@ -202,16 +203,7 @@ export function AdminNext() {
|
||||
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
|
||||
<header className="admin-topbar">
|
||||
<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>
|
||||
<Brand href="/" name={brandName} mark={brandMark} logo={brandLogo} />
|
||||
|
||||
<div className="header-actions">
|
||||
<IconButton
|
||||
@@ -1806,17 +1798,12 @@ function HeaderPanel({
|
||||
<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 className="brand-preview">
|
||||
<Brand
|
||||
name={site.brand.name}
|
||||
mark={site.brand.mark}
|
||||
logo={site.brand.logo}
|
||||
/>
|
||||
</div>
|
||||
<FormField label="Logo 图片地址" htmlFor="site-logo" hint="留空则用右侧的首字圆形标记">
|
||||
<Input
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Icon, IconButton, Tag } from '@stash/ui'
|
||||
import { Brand, Icon, IconButton, Tag, Ticker } from '@stash/ui'
|
||||
import {
|
||||
fillHeaderText,
|
||||
type HeaderItem,
|
||||
@@ -9,8 +8,8 @@ import {
|
||||
|
||||
/**
|
||||
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
|
||||
* 品牌(logo / 文字)与操作图标是否出现,都来自「页头」Tab 的站点设置。
|
||||
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
|
||||
* 品牌与操作图标是否出现,都来自「页头」Tab 的站点设置。
|
||||
* 外壳(品牌、跑马灯)都用组件库,这里只拼内容。
|
||||
*/
|
||||
type SiteHeaderProps = {
|
||||
projectCount: number
|
||||
@@ -42,40 +41,6 @@ const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
|
||||
success: 'success',
|
||||
}
|
||||
|
||||
type InfoGroupProps = {
|
||||
items: HeaderItem[]
|
||||
projectCount: number
|
||||
sampleProject?: string
|
||||
live: boolean
|
||||
'aria-hidden'?: boolean
|
||||
}
|
||||
|
||||
/** 运行信息:同一份内容渲染三遍,轨道平移 1/3 形成无缝滚动 */
|
||||
function InfoGroup({
|
||||
items,
|
||||
projectCount,
|
||||
sampleProject,
|
||||
live,
|
||||
'aria-hidden': ariaHidden,
|
||||
}: InfoGroupProps) {
|
||||
const context = { count: projectCount, project: sampleProject, live }
|
||||
|
||||
return (
|
||||
<span className="header-ticker-group" aria-hidden={ariaHidden}>
|
||||
{items.map((item, index) => (
|
||||
<span className="header-ticker-item" key={`${index}-${item.text}`}>
|
||||
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
|
||||
<Tag tone={tagTone[item.tone]} shape="rounded">
|
||||
{item.tag}
|
||||
</Tag>
|
||||
)}
|
||||
{fillHeaderText(item.text, context)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteHeader({
|
||||
projectCount,
|
||||
sampleProject,
|
||||
@@ -92,79 +57,30 @@ export function SiteHeader({
|
||||
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])
|
||||
const context = { count: projectCount, project: sampleProject, live }
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Brand
|
||||
href="/"
|
||||
name={site.brand.name}
|
||||
mark={site.brand.mark}
|
||||
logo={site.brand.logo}
|
||||
/>
|
||||
|
||||
{headerEnabled && (
|
||||
<div className="header-ticker" role="status" aria-label="运行状态">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="header-ticker-track"
|
||||
style={{ animationDuration: `${headerSpeed}s` }}
|
||||
>
|
||||
<InfoGroup
|
||||
items={headerItems}
|
||||
projectCount={projectCount}
|
||||
sampleProject={sampleProject}
|
||||
live={live}
|
||||
/>
|
||||
<InfoGroup
|
||||
items={headerItems}
|
||||
projectCount={projectCount}
|
||||
sampleProject={sampleProject}
|
||||
live={live}
|
||||
// 后两份只用于视觉无缝,读屏忽略
|
||||
aria-hidden
|
||||
/>
|
||||
<InfoGroup
|
||||
items={headerItems}
|
||||
projectCount={projectCount}
|
||||
sampleProject={sampleProject}
|
||||
live={live}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Ticker speed={headerSpeed} label="运行状态">
|
||||
{headerItems.map((item, index) => (
|
||||
<span className="header-ticker-item" key={`${index}-${item.text}`}>
|
||||
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
|
||||
<Tag tone={tagTone[item.tone]} shape="rounded">
|
||||
{item.tag}
|
||||
</Tag>
|
||||
)}
|
||||
{fillHeaderText(item.text, context)}
|
||||
</span>
|
||||
))}
|
||||
</Ticker>
|
||||
)}
|
||||
|
||||
<div className="header-actions">
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Input } from '@stash/ui'
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 全局搜索框:吸在内容区顶部。
|
||||
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
|
||||
*/
|
||||
type SearchBarProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [inputRef])
|
||||
|
||||
return (
|
||||
<div className="search-row">
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
size="lg"
|
||||
icon="search_3_line"
|
||||
value={value}
|
||||
placeholder="搜索本地项目…"
|
||||
aria-label="搜索本地项目"
|
||||
clearable
|
||||
onClear={() => onChange('')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') onChange('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user