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,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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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: '局域网',
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user