refactor: 合并为单 crate,前端改为内嵌 React 控制台
合并 stash-cli / stash-core 为根目录单 crate(lib + bin),新增 React + Vite + TS 控制台替换 Askama 模板。 build.rs 用 walkdir 把 stash-web/dist 同步到 $OUT_DIR/web,由 include_dir! 编译期内嵌,产出单文件二进制。 路由改为 / 与 /assets/** 走内嵌前端,其余交给 ServeDir,缺失路径返回内置 404。同步更新依赖、.gitignore 与 README。
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
export function BackToTop() {
|
||||
const handleClick = () => {
|
||||
// 桌面端滚动的是内容容器,窄屏滚动的是窗口
|
||||
document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="back-to-top"
|
||||
title="回到顶部"
|
||||
aria-label="回到顶部"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<line x1="5" y1="4" x2="19" y2="4" />
|
||||
<path d="M12 20V8M6 14l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PLACEHOLDER_PROFILE } from '../profile'
|
||||
|
||||
/** 登录后展示的用户资料卡(数据为占位,见 profile.ts)。 */
|
||||
export function ProfileCard() {
|
||||
const { name, role, bio, location } = PLACEHOLDER_PROFILE
|
||||
|
||||
return (
|
||||
<section className="profile-card card-box" aria-label="用户信息">
|
||||
<div className="profile-avatar" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="9" r="3.4" />
|
||||
<path d="M5.6 19.4a6.6 6.6 0 0 1 12.8 0" />
|
||||
</svg>
|
||||
<span className="profile-badge">✓</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-name">{name}</div>
|
||||
<span className="profile-role">{role}</span>
|
||||
<p className="profile-bio">{bio}</p>
|
||||
<div className="profile-location">IP属地:{location}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
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-bar card-box">
|
||||
<svg className="search-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="search-input"
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder="搜索本地项目…"
|
||||
aria-label="搜索本地项目"
|
||||
spellCheck={false}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') onChange('')
|
||||
}}
|
||||
/>
|
||||
|
||||
{value !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-clear"
|
||||
aria-label="清空搜索"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Project } from '../api'
|
||||
import { LinkIcon, RefreshIcon } from './icons'
|
||||
|
||||
type ServicePanelProps = {
|
||||
projects: Project[]
|
||||
live: boolean
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地服务面板:状态概览 + 操作入口。
|
||||
* 项目列表本身在右侧内容区以卡片呈现,这里不再展开。
|
||||
*/
|
||||
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
|
||||
const host = typeof window === 'undefined' ? '-' : window.location.host
|
||||
|
||||
return (
|
||||
<section className="service-panel card-box" aria-label="本地服务状态">
|
||||
<header className="service-head">
|
||||
<h2 className="service-title">本地服务</h2>
|
||||
<span className={`service-pill${live ? ' is-on' : ''}`}>
|
||||
<span className={`status-dot${live ? ' is-on' : ''}`} aria-hidden="true" />
|
||||
{live ? '已连接' : '未连接'}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl className="service-stats">
|
||||
<div>
|
||||
<dt>项目数</dt>
|
||||
<dd>{projects.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>访问地址</dt>
|
||||
<dd title={host}>{host}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="service-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="service-action"
|
||||
title="重新扫描项目"
|
||||
data-spinning={refreshing}
|
||||
disabled={refreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshIcon />
|
||||
<span>重新扫描</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
className="service-action"
|
||||
href="/api/projects"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="查看项目接口"
|
||||
>
|
||||
<LinkIcon />
|
||||
<span>查看接口</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
import type { Project } from '../api'
|
||||
import { MOCK_NAV_GROUPS, MOCK_NAV_PRIMARY } from '../catalog'
|
||||
import { ProfileCard } from './ProfileCard'
|
||||
import { ServicePanel } from './ServicePanel'
|
||||
|
||||
type SidebarProps = {
|
||||
projects: Project[]
|
||||
live: boolean
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ projects, live, refreshing, onRefresh }: SidebarProps) {
|
||||
const [openGroups, setOpenGroups] = useState<string[]>([])
|
||||
const [activeId, setActiveId] = useState('common')
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setOpenGroups((previous) =>
|
||||
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
|
||||
)
|
||||
}
|
||||
|
||||
const isOpen = (id: string) => openGroups.includes(id)
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<ProfileCard />
|
||||
|
||||
<ServicePanel
|
||||
projects={projects}
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
|
||||
<nav className="nav-card card-box" aria-label="站点分类">
|
||||
<ul className="nav-group">
|
||||
{MOCK_NAV_PRIMARY.map((link) => (
|
||||
<li className="nav-item" key={link.id}>
|
||||
<a
|
||||
className={`nav-link${activeId === link.id ? ' active' : ''}`}
|
||||
href={`#${link.id}`}
|
||||
onClick={() => setActiveId(link.id)}
|
||||
>
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{MOCK_NAV_GROUPS.map((group) => (
|
||||
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-link"
|
||||
aria-expanded={isOpen(group.id)}
|
||||
onClick={() => toggle(group.id)}
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
<span className="nav-arrow" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul className="submenu">
|
||||
{group.links.map((link) => (
|
||||
<li key={link.id}>
|
||||
<a
|
||||
className={`nav-link${activeId === link.id ? ' active' : ''}`}
|
||||
href={`#${link.id}`}
|
||||
onClick={() => setActiveId(link.id)}
|
||||
>
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { CatalogCard } from '../catalog'
|
||||
|
||||
type SiteCardProps = {
|
||||
card: CatalogCard
|
||||
index: number
|
||||
}
|
||||
|
||||
export function SiteCard({ card, index }: SiteCardProps) {
|
||||
return (
|
||||
<a
|
||||
className="card"
|
||||
href={card.href}
|
||||
title={card.title}
|
||||
// 轻微错峰入场,最多延迟 12 个卡片
|
||||
style={{ animationDelay: `${Math.min(index, 12) * 24}ms` }}
|
||||
>
|
||||
<span className="card-icon" aria-hidden="true">
|
||||
{card.initial}
|
||||
</span>
|
||||
<span className="card-body">
|
||||
<span className="card-title">{card.title}</span>
|
||||
<span className={`card-desc${card.mono ? ' is-mono' : ''}`}>{card.desc}</span>
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Project } from '../api'
|
||||
import { LoginIcon, SearchIcon } from './icons'
|
||||
|
||||
type TopBarProps = {
|
||||
projects: Project[]
|
||||
live: boolean
|
||||
onFocusSearch: () => void
|
||||
}
|
||||
|
||||
type TickerItem = {
|
||||
tag: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export function TopBar({ projects, live, onFocusSearch }: TopBarProps) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-inner">
|
||||
<a className="brand" href="/" aria-label="Stash 首页">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
S
|
||||
</span>
|
||||
<span className="brand-name">STASH</span>
|
||||
</a>
|
||||
|
||||
<Ticker projects={projects} live={live} />
|
||||
|
||||
<div className="top-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="搜索(Ctrl+K)"
|
||||
aria-label="聚焦搜索框"
|
||||
onClick={onFocusSearch}
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
|
||||
<button type="button" className="icon-button" title="登录" aria-label="登录">
|
||||
<LoginIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
/** 顶栏跑马灯:展示连接状态、项目数量与前几个项目名。 */
|
||||
function Ticker({ projects, live }: { projects: Project[]; live: boolean }) {
|
||||
const items = useMemo(() => buildTickerItems(projects, live), [projects, live])
|
||||
|
||||
// 复制一份实现无缝循环,动画位移为 -50%
|
||||
const loop = [...items, ...items]
|
||||
|
||||
return (
|
||||
<div className="ticker" aria-hidden="true">
|
||||
<div className="ticker-track">
|
||||
{loop.map((item, index) => (
|
||||
<span className="ticker-item" key={`${item.tag}-${item.text}-${index}`}>
|
||||
<span className="ticker-tag">{item.tag}</span>
|
||||
{item.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildTickerItems(projects: Project[], live: boolean): TickerItem[] {
|
||||
const items: TickerItem[] = [
|
||||
{ tag: '状态', text: live ? '实时同步已连接' : '等待服务连接' },
|
||||
{ tag: '项目', text: `共 ${projects.length} 个本地应用` },
|
||||
]
|
||||
|
||||
if (projects.length === 0) {
|
||||
items.push({
|
||||
tag: '提示',
|
||||
text: '把静态站点放进托管目录,并保留 index.html,就会出现在这里',
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
for (const project of projects.slice(0, 6)) {
|
||||
items.push({ tag: '本地', text: project.name })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** 统一的小图标集合:只负责路径,尺寸与颜色由使用处的 CSS 决定。 */
|
||||
|
||||
export function RefreshIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinkIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5" />
|
||||
<path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.6" />
|
||||
<path d="M4.8 20a7.2 7.2 0 0 1 14.4 0" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user