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
+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>
)
}