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:
2026-11-01 15:28:22 +08:00
parent 59fd61d832
commit 8a782099f5
7 changed files with 90 additions and 2554 deletions
+50 -159
View File
@@ -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;
}
-437
View File
@@ -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} />
</>
)
}
+10 -2
View File
@@ -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={
+8 -21
View File
@@ -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
+22 -106
View File
@@ -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>
)
}