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
+939
View File
@@ -0,0 +1,939 @@
/* ============ 基础 ============ */
.app {
height: 100%;
display: flex;
flex-direction: column;
}
.card-box {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-main);
box-shadow: var(--shadow-card);
}
/* 仅屏幕阅读器可见 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ============ 顶栏 ============ */
.topbar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: var(--top-nav-height);
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
z-index: 200;
}
/* 顶栏内容与下方 .layout 使用同一套宽度与左右内边距,
这样 logo、右侧图标才能和侧栏、内容区严格对齐 */
.topbar-inner {
width: 100%;
max-width: var(--layout-max-width);
height: 100%;
margin: 0 auto;
padding: 0 24px 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
width: 180px;
flex-shrink: 0;
font-size: 18px;
font-weight: 800;
color: #0f172a;
}
.brand-mark {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #0f172a;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
}
.brand-name {
letter-spacing: 0.04em;
}
/* 跑马灯 */
.ticker {
flex: 1;
max-width: 560px;
height: 36px;
margin: 0 20px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
-webkit-mask-image: linear-gradient(
to right,
transparent,
#000 10%,
#000 90%,
transparent
);
mask-image: linear-gradient(
to right,
transparent,
#000 10%,
#000 90%,
transparent
);
}
.ticker-track {
display: flex;
align-items: center;
gap: 40px;
white-space: nowrap;
animation: ticker 26s linear infinite;
}
.ticker:hover .ticker-track {
animation-play-state: paused;
}
.ticker-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13.5px;
color: var(--text-sub);
}
.ticker-tag {
padding: 2px 6px;
border-radius: 4px;
background-color: var(--primary-soft);
color: var(--primary-color);
font-size: 11px;
font-weight: 600;
}
@keyframes ticker {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
/* 顶栏操作 */
.top-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.icon-button {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
color: #475569;
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
}
.icon-button:hover {
background-color: #f1f5f9;
color: var(--primary-color);
}
.icon-button svg {
width: 19px;
height: 19px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon-button[data-spinning='true'] svg,
.service-action[data-spinning='true'] svg {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ============ 搜索框 ============ */
.search-row {
position: fixed;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(480px, calc(100vw - 48px));
height: 44px;
z-index: 100;
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
padding: 0 12px;
}
.search-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--text-sub);
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
}
.search-input {
flex: 1;
min-width: 0;
height: 36px;
border: none;
outline: none;
background: transparent;
font-size: 13.5px;
color: var(--text-main);
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-clear {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
}
.search-clear:hover {
background-color: #f1f5f9;
color: var(--text-main);
}
.search-clear svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 2.4;
stroke-linecap: round;
}
/* ============ 双列布局 ============ */
.layout {
width: 100%;
max-width: var(--layout-max-width);
/* 宽屏时整体居中,避免内容全部贴左 */
margin: var(--top-nav-height) auto 0;
height: calc(100vh - var(--top-nav-height));
padding: 36px 24px 0 20px;
display: grid;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
gap: 20px;
align-items: stretch;
}
/* 左侧栏 */
.sidebar {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
margin-top: 68px;
}
/* 用户资料模块(数据占位,见 profile.ts) */
.profile-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 18px 14px 14px;
text-align: center;
flex-shrink: 0;
}
.profile-avatar {
position: relative;
width: 64px;
height: 64px;
margin-bottom: 4px;
border-radius: 50%;
display: grid;
place-items: center;
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1d4ed8;
}
.profile-avatar svg {
width: 34px;
height: 34px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
}
.profile-badge {
position: absolute;
right: -2px;
bottom: -2px;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--card-bg);
background-color: #f59e0b;
color: #fff;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 700;
}
.profile-name {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
line-height: 1.2;
}
.profile-role {
padding: 1px 8px;
border: 1px solid var(--border-color);
border-radius: 999px;
font-size: 11px;
color: var(--text-sub);
}
.profile-bio {
margin: 2px 0 0;
font-size: 11.5px;
line-height: 1.55;
color: var(--text-sub);
}
.profile-location {
margin-top: 2px;
font-size: 11px;
color: var(--text-muted);
}
.nav-card {
padding: 8px;
flex-shrink: 0;
}
.nav-group {
list-style: none;
}
.nav-item {
margin-bottom: 4px;
}
.nav-item:last-child {
margin-bottom: 0;
}
.nav-link {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 12px;
border-radius: 8px;
font-size: 13.5px;
font-weight: 500;
color: var(--text-sub);
cursor: pointer;
text-align: left;
transition:
background-color 0.2s,
color 0.2s;
}
.nav-link:hover,
.nav-link.active {
background-color: var(--primary-soft);
color: var(--primary-color);
}
.nav-count {
padding: 1px 6px;
border-radius: 10px;
background-color: #f1f5f9;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
}
.nav-trailing {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.nav-link.active .nav-count,
.nav-link:hover .nav-count {
background-color: #fff;
color: var(--primary-color);
}
.nav-arrow {
color: var(--text-muted);
font-weight: 700;
line-height: 1;
transition: transform 0.3s;
}
.nav-item.open > .nav-link .nav-arrow {
transform: rotate(90deg);
}
.submenu {
list-style: none;
padding-left: 12px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0, 1, 0, 1);
}
.nav-item.open > .submenu {
max-height: 320px;
overflow-y: auto;
transition: max-height 0.3s ease-in-out;
scrollbar-width: thin;
}
.submenu .nav-link {
padding: 7px 12px;
font-size: 12.5px;
}
.submenu .nav-link > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-empty {
color: var(--text-muted);
cursor: default;
font-size: 12.5px;
padding: 7px 12px;
display: block;
}
/* 本地服务面板:独立模块,不与导航项混排 */
.service-panel {
padding: 12px 14px;
flex-shrink: 0;
}
.service-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.service-title {
font-size: 13px;
font-weight: 600;
color: var(--text-main);
}
.service-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
background-color: #f1f5f9;
font-size: 11px;
color: var(--text-sub);
}
.service-pill.is-on {
background-color: rgba(34, 197, 94, 0.12);
color: #15803d;
}
.service-stats {
display: grid;
gap: 6px;
margin-top: 10px;
}
.service-stats > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.service-stats dt {
flex-shrink: 0;
color: var(--text-sub);
}
.service-stats dd {
margin: 0;
overflow: hidden;
font-family: var(--font-mono);
font-size: 11.5px;
font-weight: 600;
color: var(--text-main);
text-overflow: ellipsis;
white-space: nowrap;
}
/* 面板底部操作:重新扫描 / 查看接口 */
.service-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.service-action {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--card-bg);
color: var(--text-sub);
font-size: 12px;
white-space: nowrap;
cursor: pointer;
transition:
border-color 0.2s,
color 0.2s,
background-color 0.2s;
}
.service-action:not(:disabled):hover {
border-color: var(--primary-color);
background-color: var(--primary-soft);
color: var(--primary-color);
}
.service-action:disabled {
opacity: 0.6;
cursor: default;
}
.service-action svg {
width: 13px;
height: 13px;
flex-shrink: 0;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #cbd5e1;
flex-shrink: 0;
transition:
background-color 0.2s,
box-shadow 0.2s;
}
.status-dot.is-on {
background-color: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
}
/* 右侧内容 */
.content {
display: flex;
flex-direction: column;
min-height: 0;
padding-top: 52px;
}
.content-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
/* 顶部留出渐隐区,避免第一个分区标题被 mask 吃掉 */
padding-top: 24px;
padding-right: 4px;
padding-bottom: 40px;
scroll-behavior: smooth;
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
#000 22px,
#000 100%
);
mask-image: linear-gradient(to bottom, transparent 0, #000 22px, #000 100%);
-ms-overflow-style: none;
scrollbar-width: none;
}
.content-scroll::-webkit-scrollbar {
display: none;
width: 0;
}
.section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-title {
display: flex;
align-items: center;
font-size: 15px;
font-weight: 600;
color: var(--text-main);
line-height: 1;
}
.section-title::before {
content: '';
width: 4px;
height: 15px;
margin-right: 8px;
border-radius: 2px;
background-color: var(--primary-color);
}
/* 右上角数量角标:粉色渐变胶囊 */
.section-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 20px;
padding: 0 9px;
border-radius: 999px;
background-image: linear-gradient(90deg, #ff8a8a 0%, #f42a63 100%);
box-shadow: 0 2px 6px rgba(244, 42, 99, 0.28);
color: #fff;
font-size: 12px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.02em;
}
.section-group + .section-group {
margin-top: 28px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.card {
display: flex;
align-items: center;
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: 10px;
background-color: var(--card-bg);
box-shadow: var(--shadow-card);
transition:
transform 0.2s,
box-shadow 0.2s,
border-color 0.2s;
animation: card-in 0.28s ease backwards;
}
.card:hover {
transform: translateY(-2px);
border-color: rgba(59, 130, 246, 0.35);
box-shadow: var(--shadow-hover);
}
.card-icon {
width: 36px;
height: 36px;
margin-right: 12px;
border-radius: 8px;
background-color: #f1f5f9;
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.card-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
margin-top: 2px;
font-size: 11.5px;
color: var(--text-sub);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 路径类描述用等宽字体,普通文案保持无衬线 */
.card-desc.is-mono {
font-family: var(--font-mono);
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 空状态 / 错误 */
.notice {
display: flex;
flex-direction: column;
gap: 6px;
padding: 36px 24px;
border: 1px dashed var(--border-color);
border-radius: var(--radius-main);
background-color: rgba(255, 255, 255, 0.6);
color: var(--text-sub);
font-size: 13px;
}
.notice strong {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
}
.notice code {
padding: 1px 5px;
border-radius: 5px;
background-color: #eef2f7;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-main);
}
.notice-error {
border-style: solid;
border-color: #f3c6c6;
background-color: #fff7f7;
}
.notice-error strong {
color: #b42318;
}
.notice-action {
align-self: flex-start;
margin-top: 8px;
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--card-bg);
font-size: 13px;
cursor: pointer;
transition:
border-color 0.2s,
color 0.2s;
}
.notice-action:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
/* 回到顶部 */
.back-to-top {
position: fixed;
right: 30px;
bottom: 30px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #e2e8f0;
color: #1e293b;
box-shadow: var(--shadow-card);
cursor: pointer;
z-index: 90;
transition:
background-color 0.2s,
transform 0.2s;
}
.back-to-top:hover {
background-color: #cbd5e1;
transform: translateY(-2px);
}
.back-to-top svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ============ 响应式 ============ */
@media (max-width: 1100px) {
:root {
--sidebar-width: 200px;
}
}
@media (max-width: 900px) {
.app {
height: auto;
min-height: 100vh;
}
.topbar {
position: sticky;
}
.topbar-inner {
padding: 0 16px;
}
.brand {
width: auto;
}
.ticker {
display: none;
}
.search-row {
position: static;
top: auto;
left: auto;
transform: none;
width: auto;
height: 44px;
margin: 16px 16px 0;
}
.layout {
margin-top: 0;
height: auto;
padding: 16px;
grid-template-columns: minmax(0, 1fr);
}
.sidebar {
display: none;
}
.content {
padding-top: 0;
}
.content-scroll {
overflow: visible;
-webkit-mask-image: none;
mask-image: none;
}
.back-to-top {
right: 20px;
bottom: 20px;
}
}
+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>
)
}
+69
View File
@@ -0,0 +1,69 @@
export type Project = {
name: string
route_path: string
}
type ProjectsPayload = {
projects?: Project[]
}
const PROJECTS_ENDPOINT = '/api/projects'
const EVENTS_ENDPOINT = '/api/projects/events'
/** 读取服务端扫描出的项目列表。 */
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
const response = await fetch(PROJECTS_ENDPOINT, {
signal,
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`请求 ${PROJECTS_ENDPOINT} 失败:HTTP ${response.status}`)
}
const payload = (await response.json()) as ProjectsPayload
return Array.isArray(payload.projects) ? payload.projects : []
}
export type ProjectEventsHandlers = {
onProjects: (projects: Project[]) => void
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
onConnectionChange?: (connected: boolean) => void
}
/**
* 订阅服务端的项目快照推送。
* 浏览器不支持 SSE 时静默降级为空清理函数,页面仍可用手动刷新。
*/
export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
if (typeof EventSource === 'undefined') {
return () => {}
}
const source = new EventSource(EVENTS_ENDPOINT)
const handleProjects = (event: Event) => {
try {
const payload = JSON.parse((event as MessageEvent<string>).data) as ProjectsPayload
if (Array.isArray(payload.projects)) {
handlers.onProjects(payload.projects)
}
} catch {
// 单条事件格式异常时保留旧数据,等待下一次推送
}
}
const handleOpen = () => handlers.onConnectionChange?.(true)
const handleError = () => handlers.onConnectionChange?.(false)
source.addEventListener('projects', handleProjects)
source.addEventListener('open', handleOpen)
source.addEventListener('error', handleError)
return () => {
source.removeEventListener('projects', handleProjects)
source.removeEventListener('open', handleOpen)
source.removeEventListener('error', handleError)
source.close()
}
}
+130
View File
@@ -0,0 +1,130 @@
/**
* 占位数据:沿用 index9 设计稿里的分类与卡片。
*
* 后端目前只提供扁平的项目列表,没有分类概念,
* 所以这里的内容仅用于撑起界面结构,等接口支持分类后再替换。
*/
export type CatalogCard = {
id: string
title: string
desc: string
href: string
/** 卡片左侧的字符标记 */
initial: string
/** desc 是路径时用等宽字体 */
mono?: boolean
}
export type CatalogSection = {
id: string
title: string
cards: CatalogCard[]
}
export type NavLink = {
id: string
label: string
}
export type NavGroup = {
id: string
label: string
links: NavLink[]
}
/** 侧栏顶层链接 */
export const MOCK_NAV_PRIMARY: NavLink[] = [{ id: 'common', label: '常用推荐' }]
/** 侧栏可折叠分组 */
export const MOCK_NAV_GROUPS: NavGroup[] = [
{
id: 'assets',
label: '素材资源',
links: [
{ id: 'design', label: 'UI 设计资源' },
{ id: 'icon', label: '矢量图标' },
{ id: 'photo', label: '免费壁纸' },
],
},
{
id: 'tools',
label: '开发工具',
links: [
{ id: 'dev', label: '前端调试工具' },
{ id: 'code', label: '在线编辑器' },
],
},
]
/** 内容区占位分区,顺序与设计稿一致 */
export const MOCK_SECTIONS: CatalogSection[] = [
{
id: 'common',
title: '常用推荐',
cards: [
{
id: 'github',
title: 'GitHub',
desc: '代码托管与开源社区',
href: '#',
initial: 'G',
},
{ id: 'zhihu', title: '知乎', desc: '深度内容问答社区', href: '#', initial: 'Z' },
{
id: 'bilibili',
title: '哔哩哔哩',
desc: '弹幕视频分享网站',
href: '#',
initial: 'B',
},
{
id: 'v2ex',
title: 'V2EX',
desc: '创意工作者讨论社区',
href: '#',
initial: 'V',
},
],
},
{
id: 'dev',
title: '前端调试工具',
cards: [
{
id: 'stackoverflow',
title: 'Stack Overflow',
desc: '程序员技术问答',
href: '#',
initial: 'S',
},
{ id: 'juejin', title: '掘金', desc: '开发者技术社区', href: '#', initial: 'J' },
],
},
{
id: 'code',
title: '在线编辑器',
cards: [
{
id: 'codepen',
title: 'CodePen',
desc: '前端在线代码演练场',
href: '#',
initial: 'C',
},
],
},
{
id: 'design',
title: 'UI 设计资源',
cards: [
{
id: 'dribbble',
title: 'Dribbble',
desc: '全球设计作品分享社区',
href: '#',
initial: 'D',
},
],
},
]
+22
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}
+58
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+26
View File
@@ -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>
)
}
+89
View File
@@ -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
}
+37
View File
@@ -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>
)
}
+82
View File
@@ -0,0 +1,82 @@
:root {
--bg-color: #f7f9fb;
--card-bg: #ffffff;
--primary-color: #3b82f6;
--primary-soft: #eff6ff;
--text-main: #1e293b;
--text-sub: #64748b;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--radius-main: 12px;
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 3px rgba(0, 0, 0, 0.02);
--shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.03);
--sidebar-width: 240px;
--top-nav-height: 60px;
/* 内容区最大宽度,宽屏时整体居中 */
--layout-max-width: 1280px;
--font-sans:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
'PingFang SC', 'Microsoft YaHei', Arial, 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;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#root {
height: 100%;
}
/* 桌面端锁死视口,滚动交给右侧内容区 */
body {
height: 100vh;
overflow: hidden;
background-color: var(--bg-color);
color: var(--text-main);
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
color: inherit;
border: none;
background: none;
}
:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
@media (max-width: 900px) {
html,
body,
#root {
height: auto;
}
body {
height: auto;
overflow: auto;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+19
View File
@@ -0,0 +1,19 @@
/**
* 登录后展示的用户模块数据。
*
* 后端目前没有鉴权 / 用户信息接口,这里先用占位数据,
* 等类似 `/api/me` 的接口就绪后替换为真实数据即可。
*/
export type UserProfile = {
name: string
role: string
bio: string
location: string
}
export const PLACEHOLDER_PROFILE: UserProfile = {
name: '本地管理员',
role: '管理员',
bio: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
location: '局域网',
}
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchProjects, subscribeProjects, type Project } from './api'
export type ProjectsStatus = 'loading' | 'ready' | 'error'
export type ProjectsState = {
status: ProjectsStatus
projects: Project[]
error: string | null
}
export type ProjectsController = ProjectsState & {
/** SSE 是否已连接 */
live: boolean
/** 是否正在手动刷新 */
refreshing: boolean
refresh: () => void
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** 刷新失败时保留上一份列表,避免页面突然清空。 */
function toErrorState(previous: ProjectsState, error: unknown): ProjectsState {
return { status: 'error', projects: previous.projects, error: toErrorMessage(error) }
}
/** 项目列表:首屏拉取 + SSE 推送 + 手动刷新。 */
export function useProjects(): ProjectsController {
const [state, setState] = useState<ProjectsState>({
status: 'loading',
projects: [],
error: null,
})
const [live, setLive] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const controllerRef = useRef<AbortController | null>(null)
// 首屏加载:setState 只发生在 Promise 回调里。
// 不在 effect 内同步 setState,否则会触发 react-hooks/set-state-in-effect。
useEffect(() => {
const controller = new AbortController()
controllerRef.current = controller
fetchProjects(controller.signal)
.then((projects) => {
if (!controller.signal.aborted) {
setState({ status: 'ready', projects, error: null })
}
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setState((previous) => toErrorState(previous, error))
}
})
return () => controller.abort()
}, [])
// 服务端已做防抖与去重,这里直接整体替换快照。
useEffect(() => {
return subscribeProjects({
onProjects: (projects) => setState({ status: 'ready', projects, error: null }),
onConnectionChange: setLive,
})
}, [])
const refresh = useCallback(() => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
setRefreshing(true)
fetchProjects(controller.signal)
.then((projects) => {
if (!controller.signal.aborted) {
setState({ status: 'ready', projects, error: null })
}
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setState((previous) => toErrorState(previous, error))
}
})
.finally(() => {
if (!controller.signal.aborted) setRefreshing(false)
})
}, [])
return { ...state, live, refreshing, refresh }
}