From 8a782099f534df3496f721e9bd020ea6b7fe641c Mon Sep 17 00:00:00 2001 From: CNWei Date: Sun, 1 Nov 2026 15:28:22 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E7=BB=9F=E4=B8=80=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=AE=BD=E5=BA=A6=E4=B8=8E=E6=95=B0=E5=80=BC=E6=A0=87?= =?UTF-8?q?=E5=B0=BA=EF=BC=8C=E7=AB=99=E7=82=B9=E5=A4=96=E5=A3=B3=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20@stash/ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应风格审计的 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 --- stash-web/src/App.css | 209 +- stash-web/src/App.tsx | 437 ---- stash-web/src/AppNext.tsx | 12 +- stash-web/src/components/admin/AdminNext.tsx | 29 +- stash-web/src/components/admin/AdminPage.tsx | 1782 ----------------- .../src/components/header/SiteHeader.tsx | 128 +- stash-web/src/components/search/SearchBar.tsx | 47 - 7 files changed, 90 insertions(+), 2554 deletions(-) delete mode 100644 stash-web/src/App.tsx delete mode 100644 stash-web/src/components/admin/AdminPage.tsx delete mode 100644 stash-web/src/components/search/SearchBar.tsx diff --git a/stash-web/src/App.css b/stash-web/src/App.css index c4886fa..d6c5b62 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -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; } diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx deleted file mode 100644 index a5717df..0000000 --- a/stash-web/src/App.tsx +++ /dev/null @@ -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(null) - const [settings, setSettings] = useState({ - version: 0, - settings: defaultSettings(), - }) - const [loginOpen, setLoginOpen] = useState(false) - const [editingKey, setEditingKey] = useState(null) - const [scrolled, setScrolled] = useState(false) - const searchRef = useRef(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 - } - - if (itemCategory.kind === 'articles') { - const article = findArticleItem(itemCategory, itemView.itemId) - if (article === undefined) { - return - } - - return ( - setEditingKey(itemView.itemId)} - canEdit={session?.authenticated === true} - /> - ) - } - - if (detailModel === undefined) { - return - } - - return ( - openItem(itemCategory.id, itemId)} - onEdit={() => setEditingKey(itemView.itemId)} - canEdit={session?.authenticated === true} - /> - ) - } - - const handleLogout = () => { - void logoutRequest().then(() => { - setSession({ authenticated: false, has_admin: true }) - }) - } - - return ( - <> - - - {/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */} - setLoginOpen(false)} - onSuccess={(next) => { - setSession(next) - setLoginOpen(false) - }} - /> - - setEditingKey(null)} - onSaved={() => catalog.reload()} - /> - - {/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */} -