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:
2026-10-21 17:59:08 +08:00
parent 92c510e959
commit e522a98f6f
48 changed files with 5113 additions and 273 deletions
+152
View File
@@ -0,0 +1,152 @@
import { useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/BackToTop'
import { SearchBar } from './components/SearchBar'
import { Sidebar } from './components/Sidebar'
import { SiteCard } from './components/SiteCard'
import { TopBar } from './components/TopBar'
import { MOCK_SECTIONS, type CatalogCard, type CatalogSection } from './catalog'
import { useProjects } from './useProjects'
import './App.css'
function initialOf(name: string): string {
const [first] = Array.from(name.trim())
return first ? first.toUpperCase() : '#'
}
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const [keyword, setKeyword] = useState('')
const searchRef = useRef<HTMLInputElement>(null)
// 真实项目放最前面,占位分类跟在后面
const sections = useMemo<CatalogSection[]>(() => {
const localCards: CatalogCard[] = projects.map((project) => ({
id: project.route_path,
title: project.name,
desc: project.route_path,
href: project.route_path,
initial: initialOf(project.name),
mono: true,
}))
return [{ id: 'local', title: '本地服务', cards: localCards }, ...MOCK_SECTIONS]
}, [projects])
const query = keyword.trim().toLowerCase()
const searching = query !== ''
// 搜索时过滤卡片,并丢掉没有命中的分区
const sectionsToRender = useMemo(() => {
if (!searching) return sections
return sections
.map((section) => ({
...section,
cards: section.cards.filter(
(card) =>
card.title.toLowerCase().includes(query) ||
card.desc.toLowerCase().includes(query),
),
}))
.filter((section) => section.cards.length > 0)
}, [sections, searching, query])
const noMatch = searching && sectionsToRender.length === 0
function renderLocalPlaceholder() {
if (status === 'error') {
return (
<div className="notice notice-error" role="alert">
<strong>连不上 Stash 服务</strong>
<p>{error}</p>
<p>
请确认后端已启动(默认 <code>127.0.0.1:8080</code>),开发时 Vite 会把{' '}
<code>/api</code> 代理到后端。
</p>
<button type="button" className="notice-action" onClick={refresh}>
重试
</button>
</div>
)
}
if (status === 'loading') {
return (
<div className="notice">
<strong>正在读取项目列表…</strong>
<p>首次加载会扫描一次托管目录。</p>
</div>
)
}
return (
<div className="notice">
<strong>这里还没有项目</strong>
<p>
在托管根目录下创建子目录并放入 <code>index.html</code>,刷新后即可在这里看到它。
</p>
</div>
)
}
function renderSection(section: CatalogSection) {
return (
<section className="section-group" id={section.id} key={section.id}>
<div className="section-heading">
<h2 className="section-title">{section.title}</h2>
<span className="section-badge">{section.cards.length}</span>
</div>
{section.cards.length > 0 ? (
<div className="grid">
{section.cards.map((card, index) => (
<SiteCard key={card.id} card={card} index={index} />
))}
</div>
) : (
renderLocalPlaceholder()
)}
</section>
)
}
return (
<div className="app">
<TopBar
projects={projects}
live={live}
onFocusSearch={() => searchRef.current?.focus()}
/>
<div className="search-row">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
</div>
<div className="layout">
<Sidebar
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
/>
<main className="content">
<div className="content-scroll" id="content-scroll">
<h1 className="sr-only">Stash 本地应用工作区</h1>
{sectionsToRender.map(renderSection)}
{noMatch && (
<div className="notice">
<strong>没有匹配「{keyword.trim()}」的内容</strong>
<p>换个关键字试试,或清空搜索查看全部。</p>
</div>
)}
</div>
</main>
</div>
<BackToTop />
</div>
)
}