refactor(web): 前端改用 @stash/ui 组件库
- 新增本地依赖 @stash/ui(file:../../stash-ui),引入其 style.css 与设计令牌 - 页面骨架改用 Layout(contained + header/sider),搜索框改为吸顶 - 卡片/菜单/按钮/标签/头像/分页/空态/提示全部换成组件库组件 - 删除本地 iconfont 与 1400 行手写组件样式,App.css 只保留页面级样式 - 数据层、路由、过滤逻辑未改动;33 个 Rust 测试通过
This commit is contained in:
Generated
+24
@@ -8,6 +8,7 @@
|
|||||||
"name": "stash-web",
|
"name": "stash-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@stash/ui": "file:../../stash-ui",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1"
|
"react-dom": "^19.3.1"
|
||||||
},
|
},
|
||||||
@@ -21,6 +22,25 @@
|
|||||||
"vite": "^8.3.4"
|
"vite": "^8.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../stash-ui": {
|
||||||
|
"name": "@stash/ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
|
"oxlint": "^1.88.0",
|
||||||
|
"playwright-core": "^1.64.1",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.3.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=19",
|
||||||
|
"react-dom": ">=19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.154.0",
|
"version": "0.154.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.154.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.154.0.tgz",
|
||||||
@@ -616,6 +636,10 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stash/ui": {
|
||||||
|
"resolved": "../../stash-ui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.19.2",
|
"version": "24.19.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.19.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.19.2.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@stash/ui": "file:../../stash-ui",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1"
|
"react-dom": "^19.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+196
-1440
File diff suppressed because it is too large
Load Diff
+88
-116
@@ -1,3 +1,4 @@
|
|||||||
|
import { Empty, Layout, Notice } from '@stash/ui'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BackToTop } from './components/common/BackToTop'
|
import { BackToTop } from './components/common/BackToTop'
|
||||||
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
||||||
@@ -39,7 +40,6 @@ export default function App() {
|
|||||||
const { view, go, back } = useViewRoute()
|
const { view, go, back } = useViewRoute()
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const categories = useMemo(
|
const categories = useMemo(
|
||||||
() => withLocalCategory(projects, catalog.categories),
|
() => withLocalCategory(projects, catalog.categories),
|
||||||
@@ -103,20 +103,18 @@ export default function App() {
|
|||||||
return []
|
return []
|
||||||
}, [itemView, itemCategory])
|
}, [itemView, itemCategory])
|
||||||
|
|
||||||
// 切换视图时把内容区滚回顶部
|
// 切换视图时回到顶部(页面整体滚动)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({ top: 0 })
|
window.scrollTo({ top: 0 })
|
||||||
}, [view])
|
}, [view])
|
||||||
|
|
||||||
// 桌面端滚动作在内容容器里,窄屏滚的是窗口:
|
|
||||||
// 两处都要驱动「已滚动」状态(顶部渐隐层 + 返回顶部按钮共用)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
const inner = scrollRef.current?.scrollTop ?? 0
|
const next = window.scrollY > 4
|
||||||
const next = Math.max(inner, window.scrollY) > 4
|
|
||||||
setScrolled((current) => (current === next ? current : next))
|
setScrolled((current) => (current === next ? current : next))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sync()
|
||||||
window.addEventListener('scroll', sync, { passive: true })
|
window.addEventListener('scroll', sync, { passive: true })
|
||||||
return () => window.removeEventListener('scroll', sync)
|
return () => window.removeEventListener('scroll', sync)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -125,7 +123,6 @@ export default function App() {
|
|||||||
* 「打开这个分区」应该落到哪个视图。
|
* 「打开这个分区」应该落到哪个视图。
|
||||||
*
|
*
|
||||||
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
|
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
|
||||||
* 这样卡片上的「···」和分区标题都通向卡片里实际展示的那块内容。
|
|
||||||
*/
|
*/
|
||||||
const detailViewFor = (categoryId: string): View => {
|
const detailViewFor = (categoryId: string): View => {
|
||||||
const category = categories.find((item) => item.id === categoryId)
|
const category = categories.find((item) => item.id === categoryId)
|
||||||
@@ -203,21 +200,13 @@ export default function App() {
|
|||||||
function renderItemView() {
|
function renderItemView() {
|
||||||
if (itemView === undefined) return null
|
if (itemView === undefined) return null
|
||||||
if (itemCategory === undefined) {
|
if (itemCategory === undefined) {
|
||||||
return (
|
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
||||||
<p className="section-empty">
|
|
||||||
{dataPending ? '正在读取内容库…' : '分类不存在'}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemCategory.kind === 'articles') {
|
if (itemCategory.kind === 'articles') {
|
||||||
const article = findArticleItem(itemCategory, itemView.itemId)
|
const article = findArticleItem(itemCategory, itemView.itemId)
|
||||||
if (article === undefined) {
|
if (article === undefined) {
|
||||||
return (
|
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
||||||
<p className="section-empty">
|
|
||||||
{dataPending ? '正在读取内容库…' : '条目不存在'}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -230,11 +219,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (detailModel === undefined) {
|
if (detailModel === undefined) {
|
||||||
return (
|
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
||||||
<p className="section-empty">
|
|
||||||
{dataPending ? '正在读取内容库…' : '条目不存在'}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -249,111 +234,98 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<>
|
||||||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||||||
<div className="site-background" aria-hidden="true" />
|
<div className="site-background" aria-hidden="true" />
|
||||||
|
|
||||||
<SiteHeader
|
<Layout
|
||||||
projectCount={projects.length}
|
contained
|
||||||
sampleProject={projects[0]?.name}
|
maxWidth={1380}
|
||||||
live={live}
|
siderWidth={260}
|
||||||
onFocusSearch={() => searchRef.current?.focus()}
|
className="app"
|
||||||
/>
|
header={
|
||||||
|
<SiteHeader
|
||||||
|
projectCount={projects.length}
|
||||||
|
sampleProject={projects[0]?.name}
|
||||||
|
live={live}
|
||||||
|
onFocusSearch={() => searchRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sider={
|
||||||
|
<Sidebar
|
||||||
|
projects={projects}
|
||||||
|
categories={sidebarCategories}
|
||||||
|
live={live}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={refresh}
|
||||||
|
activeCategoryId={viewCategoryId}
|
||||||
|
activeGroupId={detailView?.groupId}
|
||||||
|
onNavigate={navigate}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="content">
|
||||||
|
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
||||||
|
|
||||||
<div className="search-row">
|
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||||||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="layout">
|
{status === 'error' && (
|
||||||
<Sidebar
|
<Notice
|
||||||
projects={projects}
|
tone="error"
|
||||||
categories={sidebarCategories}
|
title="连不上 Stash 服务"
|
||||||
live={live}
|
action={{ label: '重试', onClick: refresh }}
|
||||||
refreshing={refreshing}
|
>
|
||||||
onRefresh={refresh}
|
{error}
|
||||||
activeCategoryId={viewCategoryId}
|
</Notice>
|
||||||
activeGroupId={detailView?.groupId}
|
)}
|
||||||
onNavigate={navigate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="content">
|
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
|
||||||
{/* 滚动后才淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
|
{catalog.status === 'error' && (
|
||||||
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
|
<Notice
|
||||||
|
tone="error"
|
||||||
|
title="内容库读取失败"
|
||||||
|
action={{ label: '重试', onClick: catalog.reload }}
|
||||||
|
>
|
||||||
|
{catalog.error}
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
{detailView === undefined && itemView === undefined && (
|
||||||
className="content-scroll"
|
<>
|
||||||
id="content-scroll"
|
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={(event) => {
|
|
||||||
const next = event.currentTarget.scrollTop > 4
|
|
||||||
setScrolled((current) => (current === next ? current : next))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
|
||||||
|
|
||||||
{status === 'error' && (
|
<HomeView
|
||||||
<div className="notice notice-error" role="alert">
|
categories={visibleCategories}
|
||||||
<strong>连不上 Stash 服务</strong>
|
onOpenDetail={openDetail}
|
||||||
<p>{error}</p>
|
onOpenItem={openItem}
|
||||||
<button type="button" className="notice-action" onClick={refresh}>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
|
|
||||||
{catalog.status === 'error' && (
|
|
||||||
<div className="notice notice-error" role="alert">
|
|
||||||
<strong>内容库读取失败</strong>
|
|
||||||
<p>{catalog.error}</p>
|
|
||||||
<button type="button" className="notice-action" onClick={catalog.reload}>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detailView === undefined && itemView === undefined && (
|
|
||||||
<>
|
|
||||||
{catalog.status === 'loading' && (
|
|
||||||
<p className="section-empty">正在读取内容库…</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<HomeView
|
|
||||||
categories={visibleCategories}
|
|
||||||
onOpenDetail={openDetail}
|
|
||||||
onOpenItem={openItem}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{searching && visibleCategories.length === 0 && (
|
|
||||||
<div className="notice">
|
|
||||||
<strong>没有匹配「{keyword.trim()}」的内容</strong>
|
|
||||||
<p>换个关键字试试,或清空搜索查看全部。</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detailView !== undefined && detailCategory !== undefined && (
|
|
||||||
<DetailView
|
|
||||||
category={detailCategory}
|
|
||||||
groupId={detailView.groupId}
|
|
||||||
onBack={goHome}
|
|
||||||
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{detailView !== undefined && detailCategory === undefined && (
|
{searching && visibleCategories.length === 0 && (
|
||||||
<p className="section-empty">
|
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
|
||||||
{dataPending ? '正在读取内容库…' : '分类不存在'}
|
换个关键字试试,或清空搜索查看全部。
|
||||||
</p>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{itemView !== undefined && renderItemView()}
|
{detailView !== undefined && detailCategory !== undefined && (
|
||||||
</div>
|
<DetailView
|
||||||
</main>
|
category={detailCategory}
|
||||||
</div>
|
groupId={detailView.groupId}
|
||||||
|
onBack={goHome}
|
||||||
|
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailView !== undefined && detailCategory === undefined && (
|
||||||
|
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{itemView !== undefined && renderItemView()}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
<BackToTop visible={scrolled} />
|
<BackToTop visible={scrolled} />
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,33 +1,14 @@
|
|||||||
import { ArrowUpIcon } from './Icon'
|
import { ScrollTop } from '@stash/ui'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回到顶部:复刻 Alright 主题的 .scroll-toolbar .totop-button。
|
* 回到顶部:直接用组件库的 ScrollTop。
|
||||||
*
|
* 页面整体滚动(没有内部滚动容器),所以不传 targetId。
|
||||||
* - 42px 圆形、浅灰底,固定在右侧(bottom: 50%)
|
|
||||||
* - 默认隐藏,只有内容区滚动后才淡入,回到顶部后再淡出
|
|
||||||
*/
|
*/
|
||||||
type BackToTopProps = {
|
type BackToTopProps = {
|
||||||
/** 内容区是否已滚动(由 App 传入) */
|
/** 页面是否已滚动(由 App 传入) */
|
||||||
visible: boolean
|
visible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackToTop({ visible }: BackToTopProps) {
|
export function BackToTop({ visible }: BackToTopProps) {
|
||||||
const handleClick = () => {
|
return <ScrollTop visible={visible} />
|
||||||
// 桌面端滚动的是内容容器,窄屏滚动的是窗口,两处都滚一遍
|
|
||||||
document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`back-to-top${visible ? ' is-visible' : ''}`}
|
|
||||||
title="回到顶部"
|
|
||||||
aria-label="回到顶部"
|
|
||||||
tabIndex={visible ? 0 : -1}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<ArrowUpIcon />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { Badge } from '@stash/ui'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数量角标:贴在分区标题右上角的小红标。
|
* 数量角标:贴在分区标题旁边的小红标。
|
||||||
* 纯展示组件,只接收数字,宽度由内容撑开(两位数不会变形)。
|
* 组件库的 Badge 已经处理了两位数和 99+ 的宽度,这里只做语义封装。
|
||||||
*/
|
*/
|
||||||
type CountBadgeProps = {
|
type CountBadgeProps = {
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CountBadge({ count }: CountBadgeProps) {
|
export function CountBadge({ count }: CountBadgeProps) {
|
||||||
return <span className="count-badge">{count}</span>
|
return <Badge count={count} tone="danger" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,57 @@
|
|||||||
/**
|
import { Icon } from '@stash/ui'
|
||||||
* 通用图标组件。
|
|
||||||
*
|
|
||||||
* 全部使用 Alright 主题的 iconfont 线性图标(见 src/iconfont.css),
|
|
||||||
* 尺寸与颜色由使用处的 CSS 控制(font-size / color),
|
|
||||||
* 因此同一套图标可以复用在按钮、统计条、导航等不同位置。
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语义图标:stash 内部一直按用途引用(SearchIcon / RefreshIcon…),
|
||||||
|
* 这里统一映射到 @stash/ui 的 iconfont 字形,避免各页面记字形名。
|
||||||
|
*/
|
||||||
type IconProps = {
|
type IconProps = {
|
||||||
/** 附加类名,便于局部覆盖字号或颜色 */
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = (name: string, className?: string) =>
|
|
||||||
`iconfont icon-${name}${className !== undefined ? ` ${className}` : ''}`
|
|
||||||
|
|
||||||
export function SearchIcon({ className }: IconProps) {
|
export function SearchIcon({ className }: IconProps) {
|
||||||
return <i className={base('search', className)} aria-hidden="true" />
|
return <Icon name="search_3_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserIcon({ className }: IconProps) {
|
export function UserIcon({ className }: IconProps) {
|
||||||
return <i className={base('user', className)} aria-hidden="true" />
|
return <Icon name="user_3_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RefreshIcon({ className }: IconProps) {
|
export function RefreshIcon({ className }: IconProps) {
|
||||||
return <i className={base('refresh', className)} aria-hidden="true" />
|
return <Icon name="refresh_3_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LinkIcon({ className }: IconProps) {
|
export function LinkIcon({ className }: IconProps) {
|
||||||
return <i className={base('link', className)} aria-hidden="true" />
|
return <Icon name="link_3_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExternalIcon({ className }: IconProps) {
|
export function ExternalIcon({ className }: IconProps) {
|
||||||
return <i className={base('external', className)} aria-hidden="true" />
|
return <Icon name="a-external_link_line1" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EyeIcon({ className }: IconProps) {
|
export function EyeIcon({ className }: IconProps) {
|
||||||
return <i className={base('eye', className)} aria-hidden="true" />
|
return <Icon name="eye_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThumbUpIcon({ className }: IconProps) {
|
export function ThumbUpIcon({ className }: IconProps) {
|
||||||
return <i className={base('thumb-up', className)} aria-hidden="true" />
|
return <Icon name="thumb_up_2_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareIcon({ className }: IconProps) {
|
export function ShareIcon({ className }: IconProps) {
|
||||||
return <i className={base('share', className)} aria-hidden="true" />
|
return <Icon name="share_3_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CloseIcon({ className }: IconProps) {
|
export function CloseIcon({ className }: IconProps) {
|
||||||
return <i className={base('close', className)} aria-hidden="true" />
|
return <Icon name="close_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MoreIcon({ className }: IconProps) {
|
export function MoreIcon({ className }: IconProps) {
|
||||||
return <i className={base('more', className)} aria-hidden="true" />
|
return <Icon name="more_1_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArrowUpIcon({ className }: IconProps) {
|
export function ArrowUpIcon({ className }: IconProps) {
|
||||||
return <i className={base('arrow-up', className)} aria-hidden="true" />
|
return <Icon name="arrow_up_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArrowDownIcon({ className }: IconProps) {
|
export function ArrowDownIcon({ className }: IconProps) {
|
||||||
return <i className={base('arrow-down', className)} aria-hidden="true" />
|
return <Icon name="arrow_down_line" className={className} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
|
import { Avatar, ItemBadge, Space, Text } from '@stash/ui'
|
||||||
import type { AppItem } from '../../data/types'
|
import type { AppItem } from '../../data/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用卡片:逐项复刻 Alright 主题「推荐作者」条目的结构。
|
* 应用卡片:头像 + 名称(热门带 HOT 角标)+ 单行描述。
|
||||||
*
|
* 点击进入条目详情;按住修饰键时保持浏览器默认行为(新标签打开原始链接)。
|
||||||
* 对应主题的:
|
|
||||||
* .flex-avatar > img.avatar-48 + span.avatar-badge.badge_5
|
|
||||||
* .item-content > h3.font-title.text-md(名字 + span.item-badge.hot)
|
|
||||||
* + div.text-xs.text-muted.h-1x(单行描述)
|
|
||||||
*
|
|
||||||
* 热门条目才会同时出现头像角标和 HOT 徽标,普通条目两者都没有。
|
|
||||||
*/
|
*/
|
||||||
type AppCardProps = {
|
type AppCardProps = {
|
||||||
item: AppItem
|
item: AppItem
|
||||||
@@ -28,22 +23,25 @@ export function AppCard({ item, onOpen }: AppCardProps) {
|
|||||||
onOpen(item.id)
|
onOpen(item.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="app-card-avatar" aria-hidden="true">
|
<Avatar
|
||||||
{item.cover !== undefined ? (
|
size="md"
|
||||||
<img src={item.cover} alt="" loading="lazy" />
|
shape="square"
|
||||||
) : (
|
src={item.cover}
|
||||||
<span className="app-card-letter">{item.icon}</span>
|
fallback={item.icon}
|
||||||
)}
|
verified={item.hot === true}
|
||||||
{item.hot === true && <span className="app-card-verify" />}
|
/>
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="app-card-info">
|
<div className="app-card-info">
|
||||||
<span className="app-card-title">
|
<Space gap="xs">
|
||||||
<span className="app-card-name">{item.name}</span>
|
<Text strong truncate className="app-card-name">
|
||||||
{item.hot === true && <span className="app-card-badge" />}
|
{item.name}
|
||||||
</span>
|
</Text>
|
||||||
<span className="app-card-desc">{item.desc}</span>
|
{item.hot === true && <ItemBadge tone="hot">HOT</ItemBadge>}
|
||||||
</span>
|
</Space>
|
||||||
|
<Text size="xs" tone="muted" truncate>
|
||||||
|
{item.desc}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
|
import { Grid } from '@stash/ui'
|
||||||
import type { AppItem } from '../../data/types'
|
import type { AppItem } from '../../data/types'
|
||||||
import { AppCard } from './AppCard'
|
import { AppCard } from './AppCard'
|
||||||
|
|
||||||
/**
|
/** 应用网格:默认 4 列,平板 2 列,手机 2 列(组件库 Grid 负责断点) */
|
||||||
* 应用网格:默认 4 列,窄屏退化为 2 列。
|
|
||||||
* 首页预览与明细页共用,靠 modifier 调整间距。
|
|
||||||
*/
|
|
||||||
type AppGridProps = {
|
type AppGridProps = {
|
||||||
items: AppItem[]
|
items: AppItem[]
|
||||||
/** 明细页使用更宽松的间距 */
|
/** 明细页使用更宽松的间距 */
|
||||||
@@ -15,10 +13,10 @@ type AppGridProps = {
|
|||||||
|
|
||||||
export function AppGrid({ items, variant = 'home', onOpen }: AppGridProps) {
|
export function AppGrid({ items, variant = 'home', onOpen }: AppGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`app-grid${variant === 'detail' ? ' app-grid--detail' : ''}`}>
|
<Grid columns={4} tabletColumns={2} mobileColumns={2} gap={variant === 'detail' ? 'md' : 'sm'}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<AppCard key={item.id} item={item} onOpen={onOpen} />
|
<AppCard key={item.id} item={item} onOpen={onOpen} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</Grid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Paragraph, Title } from '@stash/ui'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,7 +11,6 @@ import type { ReactNode } from 'react'
|
|||||||
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
|
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
|
||||||
*
|
*
|
||||||
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
|
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
|
||||||
* 需要完整语法时再换 react-markdown,渲染层已经隔离在这个组件里。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Block =
|
type Block =
|
||||||
@@ -104,9 +104,9 @@ export function ArticleBody({ markdown }: ArticleBodyProps) {
|
|||||||
{parseBlocks(markdown).map((block, index) => {
|
{parseBlocks(markdown).map((block, index) => {
|
||||||
if (block.kind === 'heading') {
|
if (block.kind === 'heading') {
|
||||||
return (
|
return (
|
||||||
<h3 className="article-body-heading" key={index}>
|
<Title level={2} as="h3" key={index}>
|
||||||
{renderInline(block.text)}
|
{renderInline(block.text)}
|
||||||
</h3>
|
</Title>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +123,9 @@ export function ArticleBody({ markdown }: ArticleBodyProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className="article-body-text" key={index}>
|
<Paragraph tone="main" key={index}>
|
||||||
{renderInline(block.text)}
|
{renderInline(block.text)}
|
||||||
</p>
|
</Paragraph>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,24 +12,17 @@ type ArticleCardProps = {
|
|||||||
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
|
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<img className="article-cover" src={item.cover} alt={item.title} loading="lazy" />
|
<img className="cover cover--article" src={item.cover} alt={item.title} loading="lazy" />
|
||||||
<span className="article-caption">
|
<span className="cover-caption">{item.title}</span>
|
||||||
<span className="article-title">{item.title}</span>
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (onOpen === undefined) {
|
if (onOpen === undefined) {
|
||||||
return <figure className="article-card">{content}</figure>
|
return <figure className="cover-card">{content}</figure>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button type="button" className="cover-card" title={item.title} onClick={() => onOpen(item.id)}>
|
||||||
type="button"
|
|
||||||
className="article-card"
|
|
||||||
title={item.title}
|
|
||||||
onClick={() => onOpen(item.id)}
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Button, Card, Paragraph, Space, Text, Title } from '@stash/ui'
|
||||||
import type { ArticleItem } from '../../data/types'
|
import type { ArticleItem } from '../../data/types'
|
||||||
import { ArticleBody } from './ArticleBody'
|
import { ArticleBody } from './ArticleBody'
|
||||||
|
|
||||||
@@ -19,34 +20,49 @@ export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDet
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="detail-view article-view">
|
<div className="detail-view article-view">
|
||||||
<div className="detail-nav card-box">
|
<Card padding="sm">
|
||||||
<button type="button" className="detail-back" onClick={onBack}>
|
<Space justify="between" wrap>
|
||||||
返回
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||||
</button>
|
返回
|
||||||
<span className="detail-breadcrumb">{categoryTitle} / 文章</span>
|
</Button>
|
||||||
</div>
|
<Text size="sm" tone="sub">
|
||||||
|
{categoryTitle} / 文章
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<header className="article-head">
|
<header className="article-head">
|
||||||
<h2 className="article-heading">
|
<Title level={2}>
|
||||||
{article.title}
|
{article.title}
|
||||||
<button type="button" className="item-edit" disabled title="编辑功能待接入">
|
<Button variant="ghost" size="sm" disabled className="item-edit">
|
||||||
[编辑]
|
编辑
|
||||||
</button>
|
</Button>
|
||||||
</h2>
|
</Title>
|
||||||
|
|
||||||
<div className="article-meta">
|
<Space gap="sm" wrap>
|
||||||
{meta.map((text, index) => (
|
{meta.map((text, index) => (
|
||||||
<span className="article-meta-item" key={text}>
|
<Space gap="sm" key={text}>
|
||||||
{index > 0 && <i className="article-meta-dot" aria-hidden="true" />}
|
{index > 0 && (
|
||||||
{text}
|
<span className="article-meta-dot" aria-hidden="true">
|
||||||
</span>
|
·
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Space>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<article className="article-body card-box">
|
<Card padding="lg">
|
||||||
<ArticleBody markdown={article.body} />
|
<div className="article-body">
|
||||||
</article>
|
<Paragraph tone="main" className="article-lead">
|
||||||
|
{article.desc}
|
||||||
|
</Paragraph>
|
||||||
|
<ArticleBody markdown={article.body} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Grid } from '@stash/ui'
|
||||||
import type { ArticleItem } from '../../data/types'
|
import type { ArticleItem } from '../../data/types'
|
||||||
import { ArticleCard } from './ArticleCard'
|
import { ArticleCard } from './ArticleCard'
|
||||||
|
|
||||||
/** 图文网格:首页 4 列,窄屏 2 列 */
|
/** 图文网格:桌面 4 列,平板 2 列,手机 1 列 */
|
||||||
type ArticleGridProps = {
|
type ArticleGridProps = {
|
||||||
items: ArticleItem[]
|
items: ArticleItem[]
|
||||||
/** 点击卡片打开文章详情 */
|
/** 点击卡片打开文章详情 */
|
||||||
@@ -10,10 +11,10 @@ type ArticleGridProps = {
|
|||||||
|
|
||||||
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
|
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="article-grid">
|
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="sm">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
|
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</Grid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Card, Space, Text } from '@stash/ui'
|
||||||
import type { ArticleItem } from '../../data/types'
|
import type { ArticleItem } from '../../data/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,28 +11,32 @@ type CesCardProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CesCard({ item, onOpen }: CesCardProps) {
|
export function CesCard({ item, onOpen }: CesCardProps) {
|
||||||
const content = (
|
const body = (
|
||||||
<>
|
<Space direction="column" gap="xs" align="stretch">
|
||||||
<img className="ces-cover" src={item.cover} alt={item.title} loading="lazy" />
|
<Text strong truncate>
|
||||||
<span className="ces-body">
|
{item.title}
|
||||||
<span className="ces-title">{item.title}</span>
|
</Text>
|
||||||
<span className="ces-desc">{item.desc}</span>
|
<Text size="xs" tone="muted" truncate>
|
||||||
</span>
|
{item.desc}
|
||||||
</>
|
</Text>
|
||||||
|
</Space>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (onOpen === undefined) {
|
|
||||||
return <article className="ces-card">{content}</article>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Card padding="none" hoverable className="ces-card">
|
||||||
type="button"
|
<img className="cover cover--article" src={item.cover} alt={item.title} loading="lazy" />
|
||||||
className="ces-card"
|
{onOpen === undefined ? (
|
||||||
title={item.title}
|
<div className="ces-body">{body}</div>
|
||||||
onClick={() => onOpen(item.id)}
|
) : (
|
||||||
>
|
<button
|
||||||
{content}
|
type="button"
|
||||||
</button>
|
className="ces-body ces-body--button"
|
||||||
|
title={item.title}
|
||||||
|
onClick={() => onOpen(item.id)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Grid } from '@stash/ui'
|
||||||
import type { ArticleItem } from '../../data/types'
|
import type { ArticleItem } from '../../data/types'
|
||||||
import { CesCard } from './CesCard'
|
import { CesCard } from './CesCard'
|
||||||
|
|
||||||
/** 明细页图文网格:4 列白卡,窄屏 2 列 */
|
/** 明细页图文网格:桌面 4 列,平板 2 列,手机 1 列 */
|
||||||
type CesCardGridProps = {
|
type CesCardGridProps = {
|
||||||
items: ArticleItem[]
|
items: ArticleItem[]
|
||||||
/** 点击卡片打开文章详情 */
|
/** 点击卡片打开文章详情 */
|
||||||
@@ -10,10 +11,10 @@ type CesCardGridProps = {
|
|||||||
|
|
||||||
export function CesCardGrid({ items, onOpen }: CesCardGridProps) {
|
export function CesCardGrid({ items, onOpen }: CesCardGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="ces-grid">
|
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="sm">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<CesCard key={item.id} item={item} onOpen={onOpen} />
|
<CesCard key={item.id} item={item} onOpen={onOpen} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</Grid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge, Breadcrumb, Button, Card, Empty, Space, Title } from '@stash/ui'
|
||||||
import { categoryCount, type Category } from '../../data/types'
|
import { categoryCount, type Category } from '../../data/types'
|
||||||
import { AppGrid } from './AppGrid'
|
import { AppGrid } from './AppGrid'
|
||||||
import { CesCardGrid } from './CesCardGrid'
|
import { CesCardGrid } from './CesCardGrid'
|
||||||
@@ -28,10 +29,14 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
|
|||||||
|
|
||||||
const title = activeGroup?.name ?? category.title
|
const title = activeGroup?.name ?? category.title
|
||||||
const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
|
const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
|
||||||
const breadcrumb =
|
const crumbs =
|
||||||
activeGroup !== undefined
|
activeGroup !== undefined
|
||||||
? `首页 / ${category.title} / ${activeGroup.name}`
|
? [
|
||||||
: `首页 / ${category.title}`
|
{ label: '首页', href: '/' },
|
||||||
|
{ label: category.title },
|
||||||
|
{ label: activeGroup.name },
|
||||||
|
]
|
||||||
|
: [{ label: '首页', href: '/' }, { label: category.title }]
|
||||||
|
|
||||||
function renderBody() {
|
function renderBody() {
|
||||||
if (category.kind === 'posters') {
|
if (category.kind === 'posters') {
|
||||||
@@ -51,33 +56,35 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (category.groups.length === 0) {
|
if (category.groups.length === 0) {
|
||||||
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
|
return <Empty text={category.emptyText ?? '暂无内容'} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Space direction="column" gap="lg" align="stretch">
|
||||||
{category.groups.map((group) => (
|
{category.groups.map((group) => (
|
||||||
<GroupBlock key={group.id} group={group} onOpen={onOpenItem} />
|
<GroupBlock key={group.id} group={group} onOpen={onOpenItem} />
|
||||||
))}
|
))}
|
||||||
</>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="detail-view">
|
<div className="detail-view">
|
||||||
<div className="detail-nav card-box">
|
<Card padding="sm">
|
||||||
<button type="button" className="detail-back" onClick={onBack}>
|
<Space justify="between" wrap>
|
||||||
返回首页
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||||
</button>
|
返回首页
|
||||||
<span className="detail-breadcrumb">{breadcrumb}</span>
|
</Button>
|
||||||
</div>
|
<Breadcrumb items={crumbs} />
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="detail-hero">
|
<div className="detail-hero">
|
||||||
<span className="detail-hero-icon" aria-hidden="true">
|
<span className="detail-hero-icon" aria-hidden="true">
|
||||||
{category.icon}
|
{category.icon}
|
||||||
</span>
|
</span>
|
||||||
<h2 className="detail-hero-title">{title}</h2>
|
<Title level={2}>{title}</Title>
|
||||||
<span className="detail-hero-count">[{count}]</span>
|
<Badge count={count} tone="neutral" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Space, Title } from '@stash/ui'
|
||||||
import type { AppGroup } from '../../data/types'
|
import type { AppGroup } from '../../data/types'
|
||||||
import { AppGrid } from './AppGrid'
|
import { AppGrid } from './AppGrid'
|
||||||
|
|
||||||
@@ -12,9 +13,11 @@ type GroupBlockProps = {
|
|||||||
|
|
||||||
export function GroupBlock({ group, onOpen }: GroupBlockProps) {
|
export function GroupBlock({ group, onOpen }: GroupBlockProps) {
|
||||||
return (
|
return (
|
||||||
<section className="group-block">
|
<Space direction="column" gap="sm" align="stretch" className="group-block">
|
||||||
<h3 className="group-title">{group.name}</h3>
|
<Title level={4} as="h3">
|
||||||
|
{group.name}
|
||||||
|
</Title>
|
||||||
<AppGrid items={group.items} variant="detail" onOpen={onOpen} />
|
<AppGrid items={group.items} variant="detail" onOpen={onOpen} />
|
||||||
</section>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
|
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
|
||||||
import { AppGrid } from './AppGrid'
|
import { AppGrid } from './AppGrid'
|
||||||
import { ArticleGrid } from './ArticleGrid'
|
import { ArticleGrid } from './ArticleGrid'
|
||||||
|
import { Empty } from '@stash/ui'
|
||||||
import { PosterGrid } from './PosterGrid'
|
import { PosterGrid } from './PosterGrid'
|
||||||
import { SectionCard } from './SectionCard'
|
import { SectionCard } from './SectionCard'
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ function renderPreview(
|
|||||||
const items = source.slice(0, limit)
|
const items = source.slice(0, limit)
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
|
return <Empty text={category.emptyText ?? '暂无内容'} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
|
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { Button, Card, Icon, IconButton, Paragraph, Space, Tag, Text, Title } from '@stash/ui'
|
||||||
import type { ItemDetailModel } from '../../data/detail'
|
import type { ItemDetailModel } from '../../data/detail'
|
||||||
import type { RelatedItem } from '../../data/types'
|
import type { RelatedItem } from '../../data/types'
|
||||||
import { ExternalIcon, EyeIcon, ShareIcon, ThumbUpIcon } from '../common/Icon'
|
|
||||||
import { RelatedList } from './RelatedList'
|
import { RelatedList } from './RelatedList'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,20 +27,24 @@ export function ItemDetailView({
|
|||||||
}: ItemDetailViewProps) {
|
}: ItemDetailViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="detail-view item-view">
|
<div className="detail-view item-view">
|
||||||
<div className="detail-nav card-box">
|
<Card padding="sm">
|
||||||
<button type="button" className="detail-back" onClick={onBack}>
|
<Space justify="between" wrap>
|
||||||
返回
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||||
</button>
|
返回
|
||||||
<span className="detail-breadcrumb">{breadcrumb}</span>
|
</Button>
|
||||||
</div>
|
<Text size="sm" tone="sub">
|
||||||
|
{breadcrumb}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<article className="item-card card-box">
|
<Card padding="lg">
|
||||||
<h2 className="item-title">
|
<Title level={2}>
|
||||||
{model.title}
|
{model.title}
|
||||||
<button type="button" className="item-edit" disabled title="编辑功能待接入">
|
<Button variant="ghost" size="sm" disabled className="item-edit">
|
||||||
[编辑]
|
编辑
|
||||||
</button>
|
</Button>
|
||||||
</h2>
|
</Title>
|
||||||
|
|
||||||
<div className={`item-cover item-cover--${model.shape}`}>
|
<div className={`item-cover item-cover--${model.shape}`}>
|
||||||
{model.cover !== undefined ? (
|
{model.cover !== undefined ? (
|
||||||
@@ -51,68 +55,62 @@ export function ItemDetailView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{model.tags.length > 0 && (
|
{model.tags.length > 0 && (
|
||||||
<div className="item-tags">
|
<Space gap="sm" wrap>
|
||||||
{model.tags.map((tag) => (
|
{model.tags.map((tag) => (
|
||||||
<span className="item-tag" key={tag}>
|
<Tag key={tag} shape="rounded">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{model.comment !== undefined && (
|
{model.comment !== undefined && (
|
||||||
<div className="item-quote">
|
<Paragraph tone="secondary" className="item-quote">
|
||||||
<span className="item-quote-mark" aria-hidden="true">
|
{model.comment}
|
||||||
“
|
</Paragraph>
|
||||||
</span>
|
|
||||||
<p className="item-quote-text">{model.comment}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{model.paragraphs.map((paragraph, index) => (
|
{model.paragraphs.map((paragraph, index) => (
|
||||||
<p className="item-desc" key={`${index}-${paragraph.slice(0, 8)}`}>
|
<Paragraph key={`${index}-${paragraph.slice(0, 8)}`} tone="main">
|
||||||
{paragraph}
|
{paragraph}
|
||||||
</p>
|
</Paragraph>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="item-actions">
|
<Space justify="between" wrap className="item-actions">
|
||||||
<span className="item-stat" title="浏览量">
|
<Space gap="lg">
|
||||||
<EyeIcon />
|
<Space gap="xs" className="item-stat">
|
||||||
<em>{model.views}</em>
|
<Icon name="eye_line" />
|
||||||
</span>
|
<Text size="sm" tone="sub">
|
||||||
|
{model.views}
|
||||||
<span className="item-stat" title="点赞数">
|
</Text>
|
||||||
<ThumbUpIcon />
|
</Space>
|
||||||
<em>{model.likes}</em>
|
<Space gap="xs" className="item-stat">
|
||||||
</span>
|
<Icon name="thumb_up_2_line" />
|
||||||
|
<Text size="sm" tone="sub">
|
||||||
<button
|
{model.likes}
|
||||||
type="button"
|
</Text>
|
||||||
className="item-stat item-stat--icon"
|
</Space>
|
||||||
title="分享(待接入)"
|
<IconButton label="分享(待接入)" size="xs" icon={<Icon name="share_3_line" />} />
|
||||||
aria-label="分享"
|
</Space>
|
||||||
>
|
|
||||||
<ShareIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
|
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
|
||||||
{model.href === '#' ? (
|
{model.href === '#' ? (
|
||||||
<button type="button" className="item-jump" disabled title="暂无跳转地址">
|
<Button size="sm" disabled>
|
||||||
跳转到
|
跳转到
|
||||||
<ExternalIcon />
|
<Icon name="a-external_link_line1" />
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<a className="item-jump" href={model.href} target="_blank" rel="noreferrer">
|
<a className="link-button" href={model.href} target="_blank" rel="noreferrer">
|
||||||
跳转到
|
跳转到
|
||||||
<ExternalIcon />
|
<Icon name="a-external_link_line1" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Space>
|
||||||
|
|
||||||
{model.banner !== undefined && (
|
{model.banner !== undefined && (
|
||||||
<img className="item-banner" src={model.banner} alt="" loading="lazy" />
|
<img className="item-banner" src={model.banner} alt="" loading="lazy" />
|
||||||
)}
|
)}
|
||||||
</article>
|
</Card>
|
||||||
|
|
||||||
<RelatedList items={related} onOpen={onOpenItem} />
|
<RelatedList items={related} onOpen={onOpenItem} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,24 +12,17 @@ type PosterCardProps = {
|
|||||||
export function PosterCard({ item, onOpen }: PosterCardProps) {
|
export function PosterCard({ item, onOpen }: PosterCardProps) {
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<img className="poster-cover" src={item.cover} alt={item.title} loading="lazy" />
|
<img className="cover cover--poster" src={item.cover} alt={item.title} loading="lazy" />
|
||||||
<span className="poster-caption">
|
<span className="cover-caption">{item.title}</span>
|
||||||
<span className="poster-title">{item.title}</span>
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (onOpen === undefined) {
|
if (onOpen === undefined) {
|
||||||
return <figure className="poster-card">{content}</figure>
|
return <figure className="cover-card">{content}</figure>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button type="button" className="cover-card" title={item.title} onClick={() => onOpen(item.id)}>
|
||||||
type="button"
|
|
||||||
className="poster-card"
|
|
||||||
title={item.title}
|
|
||||||
onClick={() => onOpen(item.id)}
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Grid } from '@stash/ui'
|
||||||
import type { PosterItem } from '../../data/types'
|
import type { PosterItem } from '../../data/types'
|
||||||
import { PosterCard } from './PosterCard'
|
import { PosterCard } from './PosterCard'
|
||||||
|
|
||||||
/** 海报网格:首页 6 列,明细页同样 6 列但间距更大 */
|
/** 海报网格:桌面 6 列,平板 4 列,手机 3 列 */
|
||||||
type PosterGridProps = {
|
type PosterGridProps = {
|
||||||
items: PosterItem[]
|
items: PosterItem[]
|
||||||
variant?: 'home' | 'detail'
|
variant?: 'home' | 'detail'
|
||||||
@@ -9,12 +10,12 @@ type PosterGridProps = {
|
|||||||
onOpen?: (itemId: string) => void
|
onOpen?: (itemId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PosterGrid({ items, variant = 'home', onOpen }: PosterGridProps) {
|
export function PosterGrid({ items, onOpen }: PosterGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`poster-grid${variant === 'detail' ? ' poster-grid--detail' : ''}`}>
|
<Grid columns={6} tabletColumns={4} mobileColumns={3} gap="sm">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<PosterCard key={item.id} item={item} onOpen={onOpen} />
|
<PosterCard key={item.id} item={item} onOpen={onOpen} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</Grid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Avatar, Card, Grid, Space, Text, Title } from '@stash/ui'
|
||||||
import type { RelatedItem } from '../../data/types'
|
import type { RelatedItem } from '../../data/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,33 +14,31 @@ export function RelatedList({ items, onOpen }: RelatedListProps) {
|
|||||||
if (items.length === 0) return null
|
if (items.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="related-card card-box" aria-label="推荐阅读">
|
<Card padding="lg" className="related-card">
|
||||||
<h3 className="related-title">
|
<Title level={4} as="h3">
|
||||||
<span className="related-title-icon" aria-hidden="true">
|
|
||||||
📚
|
|
||||||
</span>
|
|
||||||
推荐阅读
|
推荐阅读
|
||||||
</h3>
|
</Title>
|
||||||
|
|
||||||
<ul className="related-grid">
|
<Grid columns={2} tabletColumns={2} mobileColumns={1} gap="sm">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={item.id}>
|
<button
|
||||||
<button type="button" className="related-item" onClick={() => onOpen(item.id)}>
|
key={item.id}
|
||||||
<span className="related-avatar" aria-hidden="true">
|
type="button"
|
||||||
{item.cover !== undefined ? (
|
className="related-item"
|
||||||
<img src={item.cover} alt="" loading="lazy" />
|
onClick={() => onOpen(item.id)}
|
||||||
) : (
|
>
|
||||||
(item.icon ?? '•')
|
<Avatar size="sm" shape="square" src={item.cover} fallback={item.icon ?? '•'} />
|
||||||
)}
|
<Space direction="column" gap="xs" align="start" className="related-info">
|
||||||
</span>
|
<Text size="sm" strong truncate>
|
||||||
<span className="related-info">
|
{item.name}
|
||||||
<span className="related-name">{item.name}</span>
|
</Text>
|
||||||
<span className="related-desc">{item.desc}</span>
|
<Text size="xs" tone="muted" truncate>
|
||||||
</span>
|
{item.desc}
|
||||||
</button>
|
</Text>
|
||||||
</li>
|
</Space>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Grid>
|
||||||
</section>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
import { Badge, Card, Icon, IconButton, Space, Title } from '@stash/ui'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { CountBadge } from '../common/CountBadge'
|
|
||||||
import { MoreIcon } from '../common/Icon'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页分区卡片:白底圆角容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
|
* 首页分区卡片:白底容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
|
||||||
* 内容由调用方传入,因此同一容器可以承载应用网格、海报网格或图文网格。
|
* 内容由调用方传入,因此同一容器可以承载应用网格、海报网格或图文网格。
|
||||||
*/
|
*/
|
||||||
type SectionCardProps = {
|
type SectionCardProps = {
|
||||||
@@ -18,30 +17,27 @@ type SectionCardProps = {
|
|||||||
|
|
||||||
export function SectionCard({ title, count, barColor, onMore, children }: SectionCardProps) {
|
export function SectionCard({ title, count, barColor, onMore, children }: SectionCardProps) {
|
||||||
return (
|
return (
|
||||||
<section className="section-card">
|
<Card padding="lg" className="section-card">
|
||||||
<header className="section-header">
|
<div className="section-header">
|
||||||
<div className="section-title-wrapper">
|
<Space gap="sm">
|
||||||
<span className="section-bar" style={{ backgroundColor: barColor }} aria-hidden="true" />
|
<span className="section-bar" style={{ backgroundColor: barColor }} aria-hidden="true" />
|
||||||
<span className="section-title-box">
|
<Title level={3} as="h2">
|
||||||
<h2 className="section-title">{title}</h2>
|
{title}
|
||||||
<CountBadge count={count} />
|
</Title>
|
||||||
</span>
|
<Badge count={count} tone="danger" />
|
||||||
</div>
|
</Space>
|
||||||
|
|
||||||
{onMore !== undefined && (
|
{onMore !== undefined && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="xs"
|
||||||
className="section-more"
|
label={`查看${title}全部`}
|
||||||
title={`查看${title}全部`}
|
icon={<Icon name="more_1_line" />}
|
||||||
aria-label={`查看${title}全部`}
|
|
||||||
onClick={onMore}
|
onClick={onMore}
|
||||||
>
|
/>
|
||||||
<MoreIcon />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { Space, Tag, Text } from '@stash/ui'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 顶栏中间的一条信息,形如「[标签] 文本」。
|
* 顶栏中间的一条信息,形如「[标签] 文本」。
|
||||||
* 标签带浅色底,用于区分「本地 / 状态」这类类型前缀。
|
* 标签用组件库的 Tag(success / primary 两种语气)。
|
||||||
*/
|
*/
|
||||||
type HeaderInfoItemProps = {
|
type HeaderInfoItemProps = {
|
||||||
/** 可选的前缀标签 */
|
/** 可选的前缀标签 */
|
||||||
@@ -14,11 +15,15 @@ type HeaderInfoItemProps = {
|
|||||||
|
|
||||||
export function HeaderInfoItem({ tag, tone = 'blue', children }: HeaderInfoItemProps) {
|
export function HeaderInfoItem({ tag, tone = 'blue', children }: HeaderInfoItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className="header-info-item">
|
<Space gap="sm">
|
||||||
{tag !== undefined && (
|
{tag !== undefined && (
|
||||||
<span className={`header-info-tag${tone === 'green' ? ' is-green' : ''}`}>{tag}</span>
|
<Tag tone={tone === 'green' ? 'success' : 'primary'} shape="rounded">
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
)}
|
)}
|
||||||
<span className="header-info-text">{children}</span>
|
<Text size="sm" tone="sub">
|
||||||
</div>
|
{children}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { SearchIcon, UserIcon } from '../common/Icon'
|
import { Icon, IconButton, Space } from '@stash/ui'
|
||||||
import { HeaderInfoItem } from './HeaderInfoItem'
|
import { HeaderInfoItem } from './HeaderInfoItem'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 顶部栏:左品牌、中间运行信息、右侧操作图标。
|
* 顶部栏:左品牌、中间运行信息、右侧操作图标。
|
||||||
* 信息区展示「共 N 个本地应用 / 第一个项目 / 连接状态」,
|
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
|
||||||
* 对应模板里的 header-center-info。
|
|
||||||
*/
|
*/
|
||||||
type SiteHeaderProps = {
|
type SiteHeaderProps = {
|
||||||
projectCount: number
|
projectCount: number
|
||||||
@@ -16,43 +15,35 @@ type SiteHeaderProps = {
|
|||||||
|
|
||||||
export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) {
|
export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="site-header">
|
<div className="site-header-inner">
|
||||||
<div className="layout-container site-header-inner">
|
<a className="site-brand" href="/" aria-label="Stash 首页">
|
||||||
<a className="site-brand" href="/" aria-label="Stash 首页">
|
<span className="site-brand-mark" aria-hidden="true">
|
||||||
<span className="site-brand-mark" aria-hidden="true">
|
S
|
||||||
S
|
</span>
|
||||||
</span>
|
<span className="site-brand-name">STASH</span>
|
||||||
<span className="site-brand-name">STASH</span>
|
</a>
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="header-info">
|
<Space gap="lg" className="header-info">
|
||||||
<HeaderInfoItem>共 {projectCount} 个本地应用</HeaderInfoItem>
|
<HeaderInfoItem>共 {projectCount} 个本地应用</HeaderInfoItem>
|
||||||
|
|
||||||
{sampleProject !== undefined && (
|
{sampleProject !== undefined && (
|
||||||
<HeaderInfoItem tag="本地">{sampleProject}</HeaderInfoItem>
|
<HeaderInfoItem tag="本地">{sampleProject}</HeaderInfoItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HeaderInfoItem tag="状态" tone={live ? 'green' : 'blue'}>
|
<HeaderInfoItem tag="状态" tone={live ? 'green' : 'blue'}>
|
||||||
{live ? '实时同步已连接' : '等待服务连接'}
|
{live ? '实时同步已连接' : '等待服务连接'}
|
||||||
</HeaderInfoItem>
|
</HeaderInfoItem>
|
||||||
</div>
|
</Space>
|
||||||
|
|
||||||
<div className="header-actions">
|
<Space gap="sm" className="header-actions">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
label="聚焦搜索框(Ctrl+K)"
|
||||||
className="header-icon-button"
|
icon={<Icon name="search_3_line" />}
|
||||||
title="搜索(Ctrl+K)"
|
onClick={onFocusSearch}
|
||||||
aria-label="聚焦搜索框"
|
/>
|
||||||
onClick={onFocusSearch}
|
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
|
||||||
>
|
<IconButton label="登录" icon={<Icon name="user_3_line" />} />
|
||||||
<SearchIcon />
|
</Space>
|
||||||
</button>
|
</div>
|
||||||
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
|
|
||||||
<button type="button" className="header-icon-button" title="登录" aria-label="登录">
|
|
||||||
<UserIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Input } from '@stash/ui'
|
||||||
import { useEffect, type RefObject } from 'react'
|
import { useEffect, type RefObject } from 'react'
|
||||||
import { CloseIcon, SearchIcon } from '../common/Icon'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局搜索框:固定悬浮在顶栏下方居中。
|
* 全局搜索框:吸在内容区顶部。
|
||||||
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
|
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
|
||||||
*/
|
*/
|
||||||
type SearchBarProps = {
|
type SearchBarProps = {
|
||||||
@@ -26,33 +26,22 @@ export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
|
|||||||
}, [inputRef])
|
}, [inputRef])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="search-bar card-box">
|
<div className="search-row">
|
||||||
<SearchIcon className="search-icon" />
|
<Input
|
||||||
|
inputRef={inputRef}
|
||||||
<input
|
icon="search_3_line"
|
||||||
ref={inputRef}
|
|
||||||
className="search-input"
|
|
||||||
type="text"
|
|
||||||
value={value}
|
value={value}
|
||||||
placeholder="搜索本地项目…"
|
placeholder="搜索本地项目…"
|
||||||
aria-label="搜索本地项目"
|
aria-label="搜索本地项目"
|
||||||
|
clearable
|
||||||
|
onClear={() => onChange('')}
|
||||||
|
shortcut="Ctrl K"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Escape') onChange('')
|
if (event.key === 'Escape') onChange('')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{value !== '' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="search-clear"
|
|
||||||
aria-label="清空搜索"
|
|
||||||
onClick={() => onChange('')}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { Card, Menu, type MenuItem } from '@stash/ui'
|
||||||
import type { NavTarget } from '../../data/nav'
|
import type { NavTarget } from '../../data/nav'
|
||||||
import type { Category } from '../../data/types'
|
import type { Category } from '../../data/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分类导航树:直接由内容树派生,不再读独立的导航数据。
|
* 分类导航树:由内容树派生。
|
||||||
*
|
*
|
||||||
* - 有分组的分类(apps)渲染成可折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
|
* - 有分组的分类(apps)是折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
|
||||||
* - 没有分组、或分组全被隐藏的分类,直接是链接。
|
* - 没有分组、或分组全被隐藏的分类,就是一个普通菜单项。
|
||||||
|
*
|
||||||
|
* 折叠状态由组件库的 Menu 内部维护;这里只负责把内容树翻译成 items。
|
||||||
*/
|
*/
|
||||||
type CategoryNavProps = {
|
type CategoryNavProps = {
|
||||||
/** 内容目录;顺序即侧栏顺序 */
|
/** 内容目录;顺序即侧栏顺序 */
|
||||||
@@ -17,82 +19,54 @@ type CategoryNavProps = {
|
|||||||
onNavigate: (target: NavTarget) => void
|
onNavigate: (target: NavTarget) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categoryKey = (id: string) => `cat:${id}`
|
||||||
|
const groupKey = (categoryId: string, groupId: string) => `group:${categoryId}:${groupId}`
|
||||||
|
|
||||||
export function CategoryNav({
|
export function CategoryNav({
|
||||||
categories,
|
categories,
|
||||||
activeCategoryId,
|
activeCategoryId,
|
||||||
activeGroupId,
|
activeGroupId,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: CategoryNavProps) {
|
}: CategoryNavProps) {
|
||||||
const [openGroups, setOpenGroups] = useState<string[]>([])
|
const items: MenuItem[] = categories.map((category) => {
|
||||||
|
const groups =
|
||||||
|
category.kind === 'apps'
|
||||||
|
? category.groups.filter((group) => group.showInSidebar !== false)
|
||||||
|
: []
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
return {
|
||||||
setOpenGroups((previous) =>
|
key: categoryKey(category.id),
|
||||||
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
|
label: category.title,
|
||||||
)
|
children:
|
||||||
|
groups.length > 0
|
||||||
|
? groups.map((group) => ({
|
||||||
|
key: groupKey(category.id, group.id),
|
||||||
|
label: group.name,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeKey =
|
||||||
|
activeCategoryId === undefined
|
||||||
|
? undefined
|
||||||
|
: activeGroupId !== undefined
|
||||||
|
? groupKey(activeCategoryId, activeGroupId)
|
||||||
|
: categoryKey(activeCategoryId)
|
||||||
|
|
||||||
|
const handleSelect = (key: string) => {
|
||||||
|
if (key.startsWith('group:')) {
|
||||||
|
const [, categoryId, groupId] = key.split(':')
|
||||||
|
onNavigate({ categoryId, groupId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onNavigate({ categoryId: key.slice('cat:'.length) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOpen = (id: string) => openGroups.includes(id)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="nav-card card-box" aria-label="站点分类">
|
<Card padding="sm" className="nav-card">
|
||||||
<ul className="nav-group">
|
<Menu items={items} activeKey={activeKey} onSelect={handleSelect} />
|
||||||
{categories.map((category) => {
|
</Card>
|
||||||
// 只显示允许出现在侧栏的分组
|
|
||||||
const groups =
|
|
||||||
category.kind === 'apps'
|
|
||||||
? category.groups.filter((group) => group.showInSidebar !== false)
|
|
||||||
: []
|
|
||||||
|
|
||||||
// 没有分组的分类:整行就是一个链接
|
|
||||||
if (groups.length === 0) {
|
|
||||||
return (
|
|
||||||
<li className="nav-item" key={category.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`nav-link${
|
|
||||||
activeCategoryId === category.id && activeGroupId === undefined ? ' active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => onNavigate({ categoryId: category.id })}
|
|
||||||
>
|
|
||||||
<span>{category.title}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 有分组:分区可折叠,分组是真正的链接
|
|
||||||
return (
|
|
||||||
<li className={`nav-item${isOpen(category.id) ? ' open' : ''}`} key={category.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="nav-link has-children"
|
|
||||||
aria-expanded={isOpen(category.id)}
|
|
||||||
onClick={() => toggle(category.id)}
|
|
||||||
>
|
|
||||||
<span>{category.title}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
|
|
||||||
也不会在过渡期间出现滚动条 */}
|
|
||||||
<div className={`submenu-wrap${isOpen(category.id) ? ' is-open' : ''}`}>
|
|
||||||
<ul className="submenu">
|
|
||||||
{groups.map((group) => (
|
|
||||||
<li key={group.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`nav-link${activeGroupId === group.id ? ' active' : ''}`}
|
|
||||||
onClick={() => onNavigate({ categoryId: category.id, groupId: group.id })}
|
|
||||||
>
|
|
||||||
<span>{group.name}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Avatar, Card, Paragraph, Tag, Text, Title } from '@stash/ui'
|
||||||
import { PLACEHOLDER_PROFILE } from '../../profile'
|
import { PLACEHOLDER_PROFILE } from '../../profile'
|
||||||
import { UserIcon } from '../common/Icon'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
|
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
|
||||||
@@ -9,16 +9,14 @@ export function ProfileCard() {
|
|||||||
const { name, role, bio, location } = PLACEHOLDER_PROFILE
|
const { name, role, bio, location } = PLACEHOLDER_PROFILE
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="profile-card card-box" aria-label="用户信息">
|
<Card padding="lg" className="profile-card">
|
||||||
<div className="profile-avatar" aria-hidden="true">
|
<Avatar size="lg" fallback="S" verified />
|
||||||
<UserIcon />
|
<Title level={4}>{name}</Title>
|
||||||
<span className="profile-badge">✓</span>
|
{!name.includes(role) && <Tag shape="rounded">{role}</Tag>}
|
||||||
</div>
|
<Paragraph>{bio}</Paragraph>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
<div className="profile-name">{name}</div>
|
IP 属地:{location}
|
||||||
<span className="profile-role">{role}</span>
|
</Text>
|
||||||
<p className="profile-bio">{bio}</p>
|
</Card>
|
||||||
<div className="profile-location">IP属地:{location}</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Button, Card, Divider, Icon, Space, Tag, Text, Title } from '@stash/ui'
|
||||||
import type { Project } from '../../api'
|
import type { Project } from '../../api'
|
||||||
import { LinkIcon, RefreshIcon } from '../common/Icon'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
|
* 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
|
||||||
@@ -16,50 +16,52 @@ export function ServicePanel({ projects, live, refreshing, onRefresh }: ServiceP
|
|||||||
const host = typeof window === 'undefined' ? '-' : window.location.host
|
const host = typeof window === 'undefined' ? '-' : window.location.host
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="service-panel card-box" aria-label="本地服务状态">
|
<Card padding="md" className="service-panel">
|
||||||
<header className="service-head">
|
<Space justify="between">
|
||||||
<h2 className="service-title">本地服务</h2>
|
<Title level={4} as="h2">
|
||||||
<span className={`service-pill${live ? ' is-on' : ''}`}>
|
本地服务
|
||||||
<span className={`status-dot${live ? ' is-on' : ''}`} aria-hidden="true" />
|
</Title>
|
||||||
|
<Tag tone={live ? 'success' : 'neutral'} dot>
|
||||||
{live ? '已连接' : '未连接'}
|
{live ? '已连接' : '未连接'}
|
||||||
</span>
|
</Tag>
|
||||||
</header>
|
</Space>
|
||||||
|
|
||||||
<dl className="service-stats">
|
<Divider spacing="sm" />
|
||||||
<div>
|
|
||||||
<dt>项目数</dt>
|
|
||||||
<dd>{projects.length}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>访问地址</dt>
|
|
||||||
<dd title={host}>{host}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="service-actions">
|
<div className="service-stats">
|
||||||
<button
|
<Space justify="between">
|
||||||
type="button"
|
<Text size="sm" tone="sub">
|
||||||
className="service-action"
|
项目数
|
||||||
title="重新扫描项目"
|
</Text>
|
||||||
data-spinning={refreshing}
|
<Text strong>{projects.length}</Text>
|
||||||
disabled={refreshing}
|
</Space>
|
||||||
onClick={onRefresh}
|
<Space justify="between">
|
||||||
>
|
<Text size="sm" tone="sub">
|
||||||
<RefreshIcon />
|
访问地址
|
||||||
<span>重新扫描</span>
|
</Text>
|
||||||
</button>
|
<Text size="sm" truncate title={host} className="service-host">
|
||||||
|
{host}
|
||||||
<a
|
</Text>
|
||||||
className="service-action"
|
</Space>
|
||||||
href="/api/projects"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
title="查看项目接口"
|
|
||||||
>
|
|
||||||
<LinkIcon />
|
|
||||||
<span>查看接口</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<Space gap="sm" wrap>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
loading={refreshing}
|
||||||
|
icon={<Icon name="refresh_3_line" />}
|
||||||
|
onClick={onRefresh}
|
||||||
|
title="重新扫描项目"
|
||||||
|
>
|
||||||
|
重新扫描
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<a className="link-button" href="/api/projects" target="_blank" rel="noreferrer">
|
||||||
|
<Icon name="link_3_line" />
|
||||||
|
查看接口
|
||||||
|
</a>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function Sidebar({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<div className="sidebar">
|
||||||
<ProfileCard />
|
<ProfileCard />
|
||||||
|
|
||||||
<ServicePanel
|
<ServicePanel
|
||||||
@@ -48,6 +48,6 @@ export function Sidebar({
|
|||||||
activeGroupId={activeGroupId}
|
activeGroupId={activeGroupId}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* 图标字体:取自 Alright 主题自带的 iconfont(Project id 4809463)。
|
|
||||||
* 字体文件放在 public/fonts/iconfont.woff2,这里只保留项目用到的字形,
|
|
||||||
* 避免把整份 500 多个图标的样式表打进包里。
|
|
||||||
*/
|
|
||||||
@font-face {
|
|
||||||
font-family: 'iconfont';
|
|
||||||
src: url('./assets/iconfont.woff2') format('woff2');
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
display: inline-block;
|
|
||||||
font-family: 'iconfont' !important;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: normal;
|
|
||||||
line-height: 1;
|
|
||||||
text-rendering: auto;
|
|
||||||
speak: none;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-search::before {
|
|
||||||
content: '\e942';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-user::before {
|
|
||||||
content: '\e966';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-refresh::before {
|
|
||||||
content: '\e93d';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-link::before {
|
|
||||||
content: '\ea37';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-external::before {
|
|
||||||
content: '\e8be';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-eye::before {
|
|
||||||
content: '\e8fd';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-thumb-up::before {
|
|
||||||
content: '\e957';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-share::before {
|
|
||||||
content: '\e946';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-close::before {
|
|
||||||
content: '\e8e4';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-more::before {
|
|
||||||
content: '\e91f';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-arrow-up::before {
|
|
||||||
content: '\e8c1';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-arrow-left::before {
|
|
||||||
content: '\e8b5';
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-arrow-down::before {
|
|
||||||
content: '\e8b7';
|
|
||||||
}
|
|
||||||
+16
-64
@@ -1,44 +1,11 @@
|
|||||||
|
/* 页面级基础:字号 / 字体 / 重置。颜色与间距全部来自 @stash/ui 的令牌。 */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ---------- 颜色:取自 Alright 主题的设计变量 ---------- */
|
font-family: var(--su-font-sans);
|
||||||
--bg-color: #f7f8fa;
|
font-size: var(--su-font-size-md);
|
||||||
--card-bg: #ffffff;
|
line-height: var(--su-line-height-md);
|
||||||
--primary-color: #3858f6;
|
color: var(--su-text-main);
|
||||||
--primary-soft: rgba(56, 88, 246, 0.1);
|
background-color: var(--su-bg-page);
|
||||||
--text-main: #1d2129;
|
|
||||||
--text-sub: #4e5969;
|
|
||||||
--text-muted: #86909c;
|
|
||||||
--border-color: #f6f6f6;
|
|
||||||
--border-strong: #eeeeee;
|
|
||||||
--danger: #ff3e3e;
|
|
||||||
--orange: #ffba3a;
|
|
||||||
--green: #4abd86;
|
|
||||||
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-main: 12px;
|
|
||||||
--radius-lg: 24px;
|
|
||||||
|
|
||||||
/* 主题的阴影很轻:0 6px 16px rgba(27,26,49,2%) */
|
|
||||||
--shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
|
|
||||||
--shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
|
|
||||||
|
|
||||||
--sidebar-width: 240px;
|
|
||||||
--top-nav-height: 60px;
|
|
||||||
/* 内容区最大宽度,宽屏时整体居中 */
|
|
||||||
--layout-max-width: 1380px;
|
|
||||||
/* 内容列顶部让位高度(给悬浮搜索框),侧栏首卡与内容首卡都按它对齐 */
|
|
||||||
--content-top-offset: 52px;
|
|
||||||
|
|
||||||
/* 主题正文用霞鹜文楷(与演示站同一份 webfont),缺失时回退系统字体 */
|
|
||||||
--font-sans:
|
|
||||||
'LXGW WenKai', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
||||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif;
|
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
|
|
||||||
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-main);
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -53,15 +20,7 @@
|
|||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
/* 桌面端锁死视口,滚动交给右侧内容区 */
|
|
||||||
body {
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -76,20 +35,13 @@ button {
|
|||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 键盘焦点环由组件库统一提供;页面只补“可聚焦容器”的兜底 */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--primary-color);
|
outline: var(--su-focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: var(--su-focus-ring-offset);
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
height: auto;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './iconfont.css'
|
import '@stash/ui/style.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const BACKEND = 'http://127.0.0.1:8080'
|
|||||||
// 前后端分离开发:dev / preview 都把 /api 透传给本地 Rust 服务
|
// 前后端分离开发:dev / preview 都把 /api 透传给本地 Rust 服务
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
// @stash/ui 是本地 file 依赖(软链),去重 React,避免加载两份运行时
|
||||||
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user