style: 统一前后台宽度与数值标尺,站点外壳改用 @stash/ui
对应风格审计的 1-4 条: 1. 宽度:后台内容区改为与页头内层同宽同左缘(1380 - 40 = 1340), 修掉品牌 x=50、卡片 x=280 的错位 2. 阴影:--su-shadow-card / hover 的覆盖从 .app 挪到 :root,前后台同一套 3. 死代码:删除未被引用的 App.tsx(436 行)、AdminPage.tsx(54KB)与 .content-veil 样式 4. 标尺:37 处脱离 4/8/12/16/20/24/32/40 与字阶的间距/字号/圆角/行高改为令牌 (含新增的 --su-font-size-2xl);6px 的认证角标按图标保留并注明原因 顺带把重复实现收进库: - 页头品牌 -> Brand - 页头滚动信息 -> Ticker(含实测宽度的无缝循环) - 搜索框 -> SearchField(variant=card),删除 components/search/SearchBar.tsx - 后台「首页 Logo」预览与后台页头同样复用 Brand
This commit is contained in:
+50
-159
@@ -1,14 +1,17 @@
|
|||||||
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
|
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
|
||||||
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
|
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
|
||||||
|
|
||||||
|
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套阴影 */
|
||||||
|
:root {
|
||||||
|
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
|
||||||
|
--su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
/* 透明:让固定背景图从下面透出来(Layout 默认有底色) */
|
/* 透明:让固定背景图从下面透出来(Layout 默认有底色) */
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
/* 旧版影子是 2% / 6%,比库默认更深,属于主题级覆盖 */
|
|
||||||
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
|
|
||||||
--su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部背景图:固定 65vh,底部渐隐到页面底色 */
|
/* 顶部背景图:固定 65vh,底部渐隐到页面底色 */
|
||||||
@@ -45,32 +48,7 @@
|
|||||||
padding: 0 var(--su-space-5);
|
padding: 0 var(--su-space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-brand {
|
/* 后台「首页 Logo」的实时预览:外观交给库里的 Brand,这里只管外框 */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-brand-mark {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--su-radius-pill);
|
|
||||||
background-color: var(--su-text-main);
|
|
||||||
color: var(--su-white);
|
|
||||||
font-size: var(--su-font-size-base);
|
|
||||||
font-weight: var(--su-font-weight-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-brand-logo {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--su-radius-sm);
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 后台「首页 Logo」的实时预览 */
|
|
||||||
.brand-preview {
|
.brand-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -81,42 +59,9 @@
|
|||||||
background-color: var(--su-bg-card);
|
background-color: var(--su-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-brand-name {
|
|
||||||
font-size: var(--su-font-size-lg);
|
|
||||||
line-height: var(--su-line-height-lg);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 页头滚动信息 ---------- */
|
/* ---------- 页头滚动信息 ---------- */
|
||||||
|
|
||||||
.header-ticker {
|
/* 跑马灯外壳在 @stash/ui 的 Ticker 里,这里只剩每条信息的排版 */
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent 0,
|
|
||||||
#000 32px,
|
|
||||||
#000 calc(100% - 32px),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-ticker-track {
|
|
||||||
display: flex;
|
|
||||||
width: max-content;
|
|
||||||
animation: header-ticker 24s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 每组占满整条轨道宽度:任何时候只看到一份信息,滚动时下一份从右侧进入 */
|
|
||||||
.header-ticker-group {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
gap: var(--su-space-8);
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-ticker-item {
|
.header-ticker-item {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -127,24 +72,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes header-ticker {
|
|
||||||
from {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 位移量由 SiteHeader 量出「一组」的真实宽度写进 --ticker-shift;
|
|
||||||
回退值仅在第一帧兜底,正常情况不会用到 */
|
|
||||||
to {
|
|
||||||
transform: translateX(var(--ticker-shift, -33.3333%));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.header-ticker-track {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -158,21 +85,6 @@
|
|||||||
padding: var(--su-space-8) 0 calc(var(--su-space-10) + var(--su-space-1));
|
padding: var(--su-space-8) 0 calc(var(--su-space-10) + var(--su-space-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-row {
|
|
||||||
width: min(480px, 100%);
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 旧版搜索是「浅边框 + 卡片阴影」,聚焦时不额外加边框/焦点环 */
|
|
||||||
.search-row > *:first-child,
|
|
||||||
.search-row > *:first-child:focus-within {
|
|
||||||
border-color: var(--su-border);
|
|
||||||
border-radius: var(--su-radius-md);
|
|
||||||
box-shadow: var(--su-shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索框保持实底清晰,透明度/虚化只作用于周围区域 */
|
|
||||||
|
|
||||||
/* ---------- 两栏骨架 ---------- */
|
/* ---------- 两栏骨架 ---------- */
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -183,30 +95,6 @@
|
|||||||
padding-bottom: var(--su-space-10);
|
padding-bottom: var(--su-space-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部渐隐:sticky 固定在滚动区顶端,内容从下面淡出而不是被硬切 */
|
|
||||||
.content-veil {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 5;
|
|
||||||
height: 32px;
|
|
||||||
/* 抵消自身高度 + 父容器 gap,避免把首张卡片往下顶 */
|
|
||||||
margin-bottom: calc(-32px - var(--su-space-5));
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
background: linear-gradient(to bottom, var(--su-bg-page) 0%, transparent 100%);
|
|
||||||
transition: opacity var(--su-duration-fast) var(--su-ease-in-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-veil.is-visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.content-veil {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -220,8 +108,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--su-space-2);
|
||||||
padding: 18px 14px 14px;
|
padding: var(--su-space-5) var(--su-space-4) var(--su-space-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,11 +118,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-role {
|
.profile-role {
|
||||||
padding: 1px 8px;
|
padding: 0 var(--su-space-2);
|
||||||
border: 1px solid var(--su-border);
|
border: 1px solid var(--su-border);
|
||||||
border-radius: var(--su-radius-pill);
|
border-radius: var(--su-radius-pill);
|
||||||
font-size: var(--su-font-size-xs);
|
font-size: var(--su-font-size-xs);
|
||||||
line-height: 18px;
|
line-height: var(--su-line-height-sm);
|
||||||
color: var(--su-text-sub);
|
color: var(--su-text-sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +134,7 @@
|
|||||||
.profile-location {
|
.profile-location {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: var(--su-font-size-xs);
|
font-size: var(--su-font-size-xs);
|
||||||
line-height: 18px;
|
line-height: var(--su-line-height-sm);
|
||||||
color: var(--su-text-muted);
|
color: var(--su-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,30 +144,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.service-panel {
|
.service-panel {
|
||||||
padding: 12px 14px;
|
padding: var(--su-space-3) var(--su-space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 旧版标题与统计值的行高比库默认更紧 */
|
/* 旧版标题与统计值的行高比库默认更紧 */
|
||||||
.service-panel h2 {
|
.service-panel h2 {
|
||||||
line-height: 20px;
|
line-height: var(--su-line-height-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 旧版连接状态药丸:22 高、12px */
|
/* 旧版连接状态药丸:22 高、12px */
|
||||||
.service-status {
|
.service-status {
|
||||||
height: 22px;
|
height: 22px;
|
||||||
padding: 0 10px;
|
padding: 0 var(--su-space-3);
|
||||||
font-size: var(--su-font-size-xs);
|
font-size: var(--su-font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 旧版统计行是 12/18 */
|
/* 旧版统计行是 12/18 */
|
||||||
.service-stats span {
|
.service-stats span {
|
||||||
line-height: 18px;
|
line-height: var(--su-line-height-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-stats {
|
.service-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--su-space-2);
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,10 +222,10 @@
|
|||||||
right: -18px;
|
right: -18px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
padding: 0 4px;
|
padding: 0 var(--su-space-1);
|
||||||
border-radius: 8px;
|
border-radius: var(--su-radius-sm);
|
||||||
box-shadow: 0 1px 3px color-mix(in srgb, var(--su-color-danger) 30%, transparent);
|
box-shadow: 0 1px 3px color-mix(in srgb, var(--su-color-danger) 30%, transparent);
|
||||||
font-size: 12px;
|
font-size: var(--su-font-size-xs);
|
||||||
font-weight: var(--su-font-weight-strong);
|
font-weight: var(--su-font-weight-strong);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -356,7 +244,7 @@
|
|||||||
width: 4px;
|
width: 4px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-right: var(--su-space-2);
|
margin-right: var(--su-space-2);
|
||||||
border-radius: 2px;
|
border-radius: var(--su-radius-xs);
|
||||||
background-color: var(--su-color-primary);
|
background-color: var(--su-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +267,7 @@
|
|||||||
.group-crumbs {
|
.group-crumbs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--su-space-2);
|
||||||
padding: var(--su-space-2) var(--su-space-3);
|
padding: var(--su-space-2) var(--su-space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +289,7 @@
|
|||||||
width: 4px;
|
width: 4px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-right: var(--su-space-2);
|
margin-right: var(--su-space-2);
|
||||||
border-radius: 2px;
|
border-radius: var(--su-radius-xs);
|
||||||
background-color: var(--su-color-primary);
|
background-color: var(--su-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,8 +298,8 @@
|
|||||||
.app-card {
|
.app-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: var(--su-space-4);
|
||||||
padding: 6px 0;
|
padding: var(--su-space-2) 0;
|
||||||
border-radius: var(--su-radius-sm);
|
border-radius: var(--su-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +310,7 @@
|
|||||||
.app-card-info {
|
.app-card-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: var(--su-space-1);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,13 +318,15 @@
|
|||||||
.app-card-avatar {
|
.app-card-avatar {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
font-size: 18px;
|
font-size: var(--su-font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card-avatar > span + span {
|
.app-card-avatar > span + span {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
|
/* 6px 不是文字尺寸:这是 10px 圆标里的一枚「✓」字形,按图标对待;
|
||||||
|
设计系统的字阶(12 起)不覆盖这种装饰字形 */
|
||||||
font-size: 6px;
|
font-size: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +385,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 0 4px;
|
padding: var(--su-space-3) 0 var(--su-space-1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +399,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--su-color-primary-soft);
|
background-color: var(--su-color-primary-soft);
|
||||||
color: var(--su-color-primary);
|
color: var(--su-color-primary);
|
||||||
font-size: 24px;
|
font-size: var(--su-font-size-xl);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,7 +419,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 24px 28px;
|
padding: var(--su-space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
@@ -545,7 +435,7 @@
|
|||||||
/* 旧版标签:2px 10px、12px、主色浅底 */
|
/* 旧版标签:2px 10px、12px、主色浅底 */
|
||||||
.item-tag {
|
.item-tag {
|
||||||
height: 22px;
|
height: 22px;
|
||||||
padding: 0 10px;
|
padding: 0 var(--su-space-3);
|
||||||
font-size: var(--su-font-size-xs);
|
font-size: var(--su-font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +455,7 @@
|
|||||||
.item-cover {
|
.item-cover {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
margin: 18px 0 10px;
|
margin: var(--su-space-5) 0 var(--su-space-3);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--su-color-primary-soft);
|
background-color: var(--su-color-primary-soft);
|
||||||
color: var(--su-color-primary);
|
color: var(--su-color-primary);
|
||||||
@@ -590,13 +480,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-cover-letter {
|
.item-cover-letter {
|
||||||
font-size: 32px;
|
font-size: var(--su-font-size-2xl);
|
||||||
color: var(--su-text-sub);
|
color: var(--su-text-sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-quote {
|
.item-quote {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: var(--su-space-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -604,7 +494,7 @@
|
|||||||
|
|
||||||
.item-quote-mark {
|
.item-quote-mark {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 32px;
|
font-size: var(--su-font-size-2xl);
|
||||||
font-weight: var(--su-font-weight-strong);
|
font-weight: var(--su-font-weight-strong);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: color-mix(in srgb, var(--su-color-danger) 35%, transparent);
|
color: color-mix(in srgb, var(--su-color-danger) 35%, transparent);
|
||||||
@@ -647,7 +537,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: var(--su-space-2);
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: var(--su-radius-cover);
|
border-radius: var(--su-radius-cover);
|
||||||
background-color: var(--su-color-primary);
|
background-color: var(--su-color-primary);
|
||||||
@@ -717,7 +607,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--su-space-3);
|
gap: var(--su-space-3);
|
||||||
padding: 32px 40px 40px;
|
padding: var(--su-space-8) var(--su-space-10) var(--su-space-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-lead {
|
.article-lead {
|
||||||
@@ -735,7 +625,7 @@
|
|||||||
.related-grid {
|
.related-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 14px 24px;
|
gap: var(--su-space-4) var(--su-space-6);
|
||||||
margin-top: var(--su-space-4);
|
margin-top: var(--su-space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,9 +639,9 @@
|
|||||||
.related-item {
|
.related-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--su-space-3);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px;
|
padding: var(--su-space-1);
|
||||||
border-radius: var(--su-radius-cover);
|
border-radius: var(--su-radius-cover);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background-color var(--su-duration-fast) var(--su-ease-in-out);
|
transition: background-color var(--su-duration-fast) var(--su-ease-in-out);
|
||||||
@@ -862,9 +752,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-main {
|
.admin-main {
|
||||||
width: min(880px, calc(100% - 48px));
|
/* 与页头内层同宽同左缘:页头 1380 减左右各 20 的内边距 = 内容从 50 起 */
|
||||||
|
width: min(var(--su-layout-width), 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--su-space-8) 0 var(--su-space-10);
|
padding: var(--su-space-8) var(--su-space-5) var(--su-space-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-locked {
|
.admin-locked {
|
||||||
@@ -1434,7 +1325,7 @@ a.entry-href:hover {
|
|||||||
.cards-side-list {
|
.cards-side-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: var(--su-space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards-side-item {
|
.cards-side-item {
|
||||||
@@ -1564,18 +1455,18 @@ a.entry-href:hover {
|
|||||||
.card-order-cell {
|
.card-order-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: var(--su-space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分组管理里状态为「关闭」的分组:卡片里只能看、不能勾选 */
|
/* 分组管理里状态为「关闭」的分组:卡片里只能看、不能勾选 */
|
||||||
.group-off-tag {
|
.group-off-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0 8px;
|
padding: 0 var(--su-space-2);
|
||||||
border-radius: var(--su-radius-pill);
|
border-radius: var(--su-radius-pill);
|
||||||
background-color: var(--su-gray-2);
|
background-color: var(--su-gray-2);
|
||||||
color: var(--su-text-muted);
|
color: var(--su-text-muted);
|
||||||
font-size: var(--su-font-size-xs);
|
font-size: var(--su-font-size-xs);
|
||||||
line-height: 20px;
|
line-height: var(--su-line-height-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,437 +0,0 @@
|
|||||||
import { Empty, Layout, Notice, Toaster } from '@stash/ui'
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { BackToTop } from './components/common/BackToTop'
|
|
||||||
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
|
||||||
import { DetailView } from './components/content/DetailView'
|
|
||||||
import { HomeView } from './components/content/HomeView'
|
|
||||||
import { ItemDetailView } from './components/content/ItemDetailView'
|
|
||||||
import { SiteHeader } from './components/header/SiteHeader'
|
|
||||||
import { SearchBar } from './components/search/SearchBar'
|
|
||||||
import { Sidebar } from './components/sidebar/Sidebar'
|
|
||||||
import { withLocalCategory } from './data/local'
|
|
||||||
import {
|
|
||||||
appDetailModel,
|
|
||||||
posterDetailModel,
|
|
||||||
posterToRelatedItem,
|
|
||||||
toRelatedItem,
|
|
||||||
} from './data/detail'
|
|
||||||
import { filterCategories } from './data/filter'
|
|
||||||
import type { NavTarget } from './data/nav'
|
|
||||||
import {
|
|
||||||
type Category,
|
|
||||||
findAppItem,
|
|
||||||
findArticleItem,
|
|
||||||
findPosterItem,
|
|
||||||
relatedAppItems,
|
|
||||||
relatedPosterItems,
|
|
||||||
} from './data/types'
|
|
||||||
import { useProjects } from './useProjects'
|
|
||||||
import { useCatalog } from './useCatalog'
|
|
||||||
import { useViewRoute } from './useViewRoute'
|
|
||||||
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
|
|
||||||
import {
|
|
||||||
applySidebarGroups,
|
|
||||||
applySidebarSettings,
|
|
||||||
defaultSettings,
|
|
||||||
fetchSettings,
|
|
||||||
type SettingsDocument,
|
|
||||||
} from './settings'
|
|
||||||
import { LoginDialog } from './components/auth/LoginDialog'
|
|
||||||
import { ItemEditor } from './components/content/ItemEditor'
|
|
||||||
import { parseViewPath, viewPath, type View } from './data/view'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
|
||||||
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
|
|
||||||
const catalog = useCatalog()
|
|
||||||
const [keyword, setKeyword] = useState('')
|
|
||||||
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
|
|
||||||
const { view, go } = useViewRoute()
|
|
||||||
const [session, setSession] = useState<SessionState | null>(null)
|
|
||||||
const [settings, setSettings] = useState<SettingsDocument>({
|
|
||||||
version: 0,
|
|
||||||
settings: defaultSettings(),
|
|
||||||
})
|
|
||||||
const [loginOpen, setLoginOpen] = useState(false)
|
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
|
||||||
const [scrolled, setScrolled] = useState(false)
|
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
// 登录态:只用来切换页头按钮与后续的编辑入口
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true
|
|
||||||
fetchSession().then((next) => {
|
|
||||||
if (alive) setSession(next)
|
|
||||||
})
|
|
||||||
fetchSettings().then((next) => {
|
|
||||||
if (alive) setSettings(next)
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const categories = useMemo(
|
|
||||||
() => withLocalCategory(projects, catalog.categories),
|
|
||||||
[projects, catalog.categories],
|
|
||||||
)
|
|
||||||
const visibleCategories = useMemo(
|
|
||||||
() => filterCategories(categories, keyword),
|
|
||||||
[categories, keyword],
|
|
||||||
)
|
|
||||||
|
|
||||||
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
|
|
||||||
// 再按 /admin 里的顺序、显隐与分组设置重排一遍
|
|
||||||
const sidebarCategories = useMemo(
|
|
||||||
() =>
|
|
||||||
applySidebarGroups(
|
|
||||||
applySidebarSettings(
|
|
||||||
categories.filter(
|
|
||||||
(category) => category.id !== 'local' && category.showInSidebar !== false,
|
|
||||||
),
|
|
||||||
settings.settings.sidebar,
|
|
||||||
),
|
|
||||||
settings.settings.sidebar,
|
|
||||||
),
|
|
||||||
[categories, settings.settings.sidebar],
|
|
||||||
)
|
|
||||||
|
|
||||||
const detailView = view.type === 'detail' ? view : undefined
|
|
||||||
const itemView = view.type === 'item' ? view : undefined
|
|
||||||
|
|
||||||
const detailCategory =
|
|
||||||
detailView !== undefined
|
|
||||||
? categories.find((category) => category.id === detailView.categoryId)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const itemCategory =
|
|
||||||
itemView !== undefined
|
|
||||||
? categories.find((category) => category.id === itemView.categoryId)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// 应用与电影共用一个详情模型,这里按分类类型做映射
|
|
||||||
const detailModel = useMemo(() => {
|
|
||||||
if (itemView === undefined || itemCategory === undefined) return undefined
|
|
||||||
|
|
||||||
if (itemCategory.kind === 'apps') {
|
|
||||||
const item = findAppItem(itemCategory, itemView.itemId)
|
|
||||||
return item !== undefined ? appDetailModel(item) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemCategory.kind === 'posters') {
|
|
||||||
const poster = findPosterItem(itemCategory, itemView.itemId)
|
|
||||||
return poster !== undefined ? posterDetailModel(poster) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}, [itemView, itemCategory])
|
|
||||||
|
|
||||||
const relatedItems = useMemo(() => {
|
|
||||||
if (itemView === undefined || itemCategory === undefined) return []
|
|
||||||
|
|
||||||
if (itemCategory.kind === 'apps') {
|
|
||||||
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemCategory.kind === 'posters') {
|
|
||||||
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}, [itemView, itemCategory])
|
|
||||||
|
|
||||||
// 切换视图时把内容区(内部滚动)和窗口都回到顶部
|
|
||||||
useEffect(() => {
|
|
||||||
const scroller = document.getElementById('content-scroll')
|
|
||||||
scroller?.scrollTo({ top: 0 })
|
|
||||||
window.scrollTo({ top: 0 })
|
|
||||||
}, [view])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 滚动状态同时驱动「顶部渐隐」和「回到顶部」。
|
|
||||||
* 桌面端滚的是内容容器(scroll 事件不冒泡),所以用捕获阶段监听。
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const sync = () => {
|
|
||||||
const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
|
|
||||||
const next = Math.max(inner, window.scrollY) > 4
|
|
||||||
setScrolled((current) => (current === next ? current : next))
|
|
||||||
}
|
|
||||||
|
|
||||||
sync()
|
|
||||||
window.addEventListener('scroll', sync, { passive: true })
|
|
||||||
document.addEventListener('scroll', sync, { passive: true, capture: true })
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', sync)
|
|
||||||
document.removeEventListener('scroll', sync, { capture: true })
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「打开这个分区」应该落到哪个视图。
|
|
||||||
*
|
|
||||||
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
|
|
||||||
*/
|
|
||||||
const detailViewFor = (categoryId: string): View => {
|
|
||||||
const category = categories.find((item) => item.id === categoryId)
|
|
||||||
|
|
||||||
if (category?.block !== undefined) {
|
|
||||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
|
||||||
if (group !== undefined) {
|
|
||||||
return { type: 'detail', categoryId, groupId: group.id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: 'detail', categoryId }
|
|
||||||
}
|
|
||||||
|
|
||||||
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
|
|
||||||
|
|
||||||
/** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */
|
|
||||||
const groupOfItem = (category: Category, itemId: string): string | undefined => {
|
|
||||||
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const openItem = (categoryId: string, itemId: string) => {
|
|
||||||
const category = categories.find((item) => item.id === categoryId)
|
|
||||||
|
|
||||||
go({
|
|
||||||
type: 'item',
|
|
||||||
categoryId,
|
|
||||||
itemId,
|
|
||||||
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
|
|
||||||
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 侧栏导航:带分组时直接打开那个分组;
|
|
||||||
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
|
|
||||||
*/
|
|
||||||
const navigate = (target: NavTarget) => {
|
|
||||||
if (target.groupId === undefined) {
|
|
||||||
openDetail(target.categoryId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
|
|
||||||
}
|
|
||||||
|
|
||||||
const goHome = () => go({ type: 'home' })
|
|
||||||
|
|
||||||
/** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */
|
|
||||||
const navigateHref = (href: string) => go(parseViewPath(href))
|
|
||||||
|
|
||||||
const searching = keyword.trim() !== ''
|
|
||||||
|
|
||||||
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
|
|
||||||
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
|
|
||||||
*/
|
|
||||||
const dataPending =
|
|
||||||
viewCategoryId !== undefined &&
|
|
||||||
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 条目详情按分类类型分流:
|
|
||||||
* 图文走长文版式,应用 / 电影走通用的条目版式。
|
|
||||||
*/
|
|
||||||
function renderItemView() {
|
|
||||||
if (itemView === undefined) return null
|
|
||||||
if (itemCategory === undefined) {
|
|
||||||
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemCategory.kind === 'articles') {
|
|
||||||
const article = findArticleItem(itemCategory, itemView.itemId)
|
|
||||||
if (article === undefined) {
|
|
||||||
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ArticleDetailView
|
|
||||||
article={article}
|
|
||||||
categoryId={itemCategory.id}
|
|
||||||
categoryTitle={itemCategory.title}
|
|
||||||
onNavigate={navigateHref}
|
|
||||||
onEdit={() => setEditingKey(itemView.itemId)}
|
|
||||||
canEdit={session?.authenticated === true}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detailModel === undefined) {
|
|
||||||
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ItemDetailView
|
|
||||||
itemKey={itemView.itemId}
|
|
||||||
crumbs={[
|
|
||||||
{ label: '首页', href: '/' },
|
|
||||||
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
|
|
||||||
{ label: detailModel.title },
|
|
||||||
]}
|
|
||||||
model={detailModel}
|
|
||||||
related={relatedItems}
|
|
||||||
onNavigate={navigateHref}
|
|
||||||
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
|
||||||
onEdit={() => setEditingKey(itemView.itemId)}
|
|
||||||
canEdit={session?.authenticated === true}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
void logoutRequest().then(() => {
|
|
||||||
setSession({ authenticated: false, has_admin: true })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toaster />
|
|
||||||
|
|
||||||
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
|
|
||||||
<LoginDialog
|
|
||||||
open={loginOpen}
|
|
||||||
hasAdmin={session?.has_admin ?? false}
|
|
||||||
onClose={() => setLoginOpen(false)}
|
|
||||||
onSuccess={(next) => {
|
|
||||||
setSession(next)
|
|
||||||
setLoginOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ItemEditor
|
|
||||||
open={editingKey !== null}
|
|
||||||
itemKey={editingKey}
|
|
||||||
onClose={() => setEditingKey(null)}
|
|
||||||
onSaved={() => catalog.reload()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
|
||||||
<div className="site-background" aria-hidden="true" />
|
|
||||||
|
|
||||||
<Layout
|
|
||||||
contained
|
|
||||||
maxWidth={1380}
|
|
||||||
gutter={20}
|
|
||||||
siderWidth={240}
|
|
||||||
gap={20}
|
|
||||||
headerHeight={60}
|
|
||||||
headerVariant="solid"
|
|
||||||
siderBordered={false}
|
|
||||||
contentPadding="none"
|
|
||||||
contentOffset={88}
|
|
||||||
height="screen"
|
|
||||||
scrollable
|
|
||||||
hideScrollbar
|
|
||||||
contentId="content-scroll"
|
|
||||||
className="app"
|
|
||||||
header={
|
|
||||||
<SiteHeader
|
|
||||||
projectCount={projects.length}
|
|
||||||
sampleProject={projects[0]?.name}
|
|
||||||
live={live}
|
|
||||||
headerItems={settings.settings.header.items}
|
|
||||||
headerEnabled={settings.settings.header.enabled}
|
|
||||||
headerSpeed={settings.settings.header.speed}
|
|
||||||
site={settings.settings.site}
|
|
||||||
authenticated={session?.authenticated === true}
|
|
||||||
username={session?.username ?? undefined}
|
|
||||||
onRefresh={() => refresh()}
|
|
||||||
onLogin={() => setLoginOpen(true)}
|
|
||||||
onLogout={handleLogout}
|
|
||||||
onSettings={() => window.location.assign('/admin')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
sider={
|
|
||||||
<Sidebar
|
|
||||||
projects={projects}
|
|
||||||
categories={sidebarCategories}
|
|
||||||
profile={settings.settings.profile}
|
|
||||||
live={live}
|
|
||||||
refreshing={refreshing}
|
|
||||||
onRefresh={refresh}
|
|
||||||
activeCategoryId={viewCategoryId}
|
|
||||||
activeGroupId={detailView?.groupId}
|
|
||||||
homeActive={view.type === 'home'}
|
|
||||||
onNavigate={navigate}
|
|
||||||
onHome={goHome}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="content">
|
|
||||||
{/* 滚动后淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
|
|
||||||
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
|
|
||||||
|
|
||||||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
|
||||||
|
|
||||||
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
|
||||||
|
|
||||||
{status === 'error' && (
|
|
||||||
<Notice
|
|
||||||
tone="error"
|
|
||||||
title="连不上 Stash 服务"
|
|
||||||
action={{ label: '重试', onClick: refresh }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Notice>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
|
|
||||||
{catalog.status === 'error' && (
|
|
||||||
<Notice
|
|
||||||
tone="error"
|
|
||||||
title="内容库读取失败"
|
|
||||||
action={{ label: '重试', onClick: catalog.reload }}
|
|
||||||
>
|
|
||||||
{catalog.error}
|
|
||||||
</Notice>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detailView === undefined && itemView === undefined && (
|
|
||||||
<>
|
|
||||||
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
|
|
||||||
|
|
||||||
<HomeView
|
|
||||||
categories={visibleCategories}
|
|
||||||
sections={settings.settings.sections}
|
|
||||||
onOpenDetail={openDetail}
|
|
||||||
onOpenItem={openItem}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{searching && visibleCategories.length === 0 && (
|
|
||||||
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
|
|
||||||
换个关键字试试,或清空搜索查看全部。
|
|
||||||
</Notice>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detailView !== undefined && detailCategory !== undefined && (
|
|
||||||
<DetailView
|
|
||||||
category={detailCategory}
|
|
||||||
groupId={detailView.groupId}
|
|
||||||
onNavigate={navigateHref}
|
|
||||||
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detailView !== undefined && detailCategory === undefined && (
|
|
||||||
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{itemView !== undefined && renderItemView()}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
|
|
||||||
<BackToTop visible={scrolled} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Layout,
|
Layout,
|
||||||
Menu,
|
Menu,
|
||||||
Notice,
|
Notice,
|
||||||
|
SearchField,
|
||||||
Space,
|
Space,
|
||||||
Toaster,
|
Toaster,
|
||||||
type MenuItem,
|
type MenuItem,
|
||||||
@@ -17,7 +18,6 @@ import { ItemEditor } from './components/content/ItemEditor'
|
|||||||
import { PosterGrid } from './components/content/PosterGrid'
|
import { PosterGrid } from './components/content/PosterGrid'
|
||||||
import { SectionCard } from './components/content/SectionCard'
|
import { SectionCard } from './components/content/SectionCard'
|
||||||
import { SiteHeader } from './components/header/SiteHeader'
|
import { SiteHeader } from './components/header/SiteHeader'
|
||||||
import { SearchBar } from './components/search/SearchBar'
|
|
||||||
import { LoginDialog } from './components/auth/LoginDialog'
|
import { LoginDialog } from './components/auth/LoginDialog'
|
||||||
import { ProfileCard } from './components/sidebar/ProfileCard'
|
import { ProfileCard } from './components/sidebar/ProfileCard'
|
||||||
import { ServicePanel } from './components/sidebar/ServicePanel'
|
import { ServicePanel } from './components/sidebar/ServicePanel'
|
||||||
@@ -654,7 +654,15 @@ export default function AppNext() {
|
|||||||
}
|
}
|
||||||
toolbar={
|
toolbar={
|
||||||
<div className="search-toolbar">
|
<div className="search-toolbar">
|
||||||
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
<SearchField
|
||||||
|
value={keyword}
|
||||||
|
onChange={setKeyword}
|
||||||
|
inputRef={searchRef}
|
||||||
|
placeholder="搜索本地项目…"
|
||||||
|
label="搜索本地项目"
|
||||||
|
variant="card"
|
||||||
|
hotkey="k"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
sider={
|
sider={
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Brand,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
FormField,
|
FormField,
|
||||||
@@ -202,16 +203,7 @@ export function AdminNext() {
|
|||||||
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
|
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
|
||||||
<header className="admin-topbar">
|
<header className="admin-topbar">
|
||||||
<div className="site-header-inner">
|
<div className="site-header-inner">
|
||||||
<a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
|
<Brand href="/" name={brandName} mark={brandMark} logo={brandLogo} />
|
||||||
{brandLogo !== '' ? (
|
|
||||||
<img className="site-brand-logo" src={brandLogo} alt="" />
|
|
||||||
) : (
|
|
||||||
<span className="site-brand-mark" aria-hidden="true">
|
|
||||||
{brandMark}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -1806,17 +1798,12 @@ function HeaderPanel({
|
|||||||
<Text size="sm" strong>
|
<Text size="sm" strong>
|
||||||
首页 Logo
|
首页 Logo
|
||||||
</Text>
|
</Text>
|
||||||
<div className="brand-preview" aria-hidden="true">
|
<div className="brand-preview">
|
||||||
{site.brand.logo.trim() !== '' ? (
|
<Brand
|
||||||
<img className="site-brand-logo" src={site.brand.logo} alt="" />
|
name={site.brand.name}
|
||||||
) : (
|
mark={site.brand.mark}
|
||||||
<span className="site-brand-mark">
|
logo={site.brand.logo}
|
||||||
{(site.brand.mark.trim() || site.brand.name.trim().slice(0, 1) || 'S').slice(0, 4)}
|
/>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{site.brand.name.trim() !== '' && (
|
|
||||||
<span className="site-brand-name">{site.brand.name}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<FormField label="Logo 图片地址" htmlFor="site-logo" hint="留空则用右侧的首字圆形标记">
|
<FormField label="Logo 图片地址" htmlFor="site-logo" hint="留空则用右侧的首字圆形标记">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { Brand, Icon, IconButton, Tag, Ticker } from '@stash/ui'
|
||||||
import { Icon, IconButton, Tag } from '@stash/ui'
|
|
||||||
import {
|
import {
|
||||||
fillHeaderText,
|
fillHeaderText,
|
||||||
type HeaderItem,
|
type HeaderItem,
|
||||||
@@ -9,8 +8,8 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
|
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
|
||||||
* 品牌(logo / 文字)与操作图标是否出现,都来自「页头」Tab 的站点设置。
|
* 品牌与操作图标是否出现,都来自「页头」Tab 的站点设置。
|
||||||
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
|
* 外壳(品牌、跑马灯)都用组件库,这里只拼内容。
|
||||||
*/
|
*/
|
||||||
type SiteHeaderProps = {
|
type SiteHeaderProps = {
|
||||||
projectCount: number
|
projectCount: number
|
||||||
@@ -42,40 +41,6 @@ const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
|
|||||||
success: 'success',
|
success: 'success',
|
||||||
}
|
}
|
||||||
|
|
||||||
type InfoGroupProps = {
|
|
||||||
items: HeaderItem[]
|
|
||||||
projectCount: number
|
|
||||||
sampleProject?: string
|
|
||||||
live: boolean
|
|
||||||
'aria-hidden'?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 运行信息:同一份内容渲染三遍,轨道平移 1/3 形成无缝滚动 */
|
|
||||||
function InfoGroup({
|
|
||||||
items,
|
|
||||||
projectCount,
|
|
||||||
sampleProject,
|
|
||||||
live,
|
|
||||||
'aria-hidden': ariaHidden,
|
|
||||||
}: InfoGroupProps) {
|
|
||||||
const context = { count: projectCount, project: sampleProject, live }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="header-ticker-group" aria-hidden={ariaHidden}>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<span className="header-ticker-item" key={`${index}-${item.text}`}>
|
|
||||||
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
|
|
||||||
<Tag tone={tagTone[item.tone]} shape="rounded">
|
|
||||||
{item.tag}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
{fillHeaderText(item.text, context)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteHeader({
|
export function SiteHeader({
|
||||||
projectCount,
|
projectCount,
|
||||||
sampleProject,
|
sampleProject,
|
||||||
@@ -92,79 +57,30 @@ export function SiteHeader({
|
|||||||
onLogout,
|
onLogout,
|
||||||
onSettings,
|
onSettings,
|
||||||
}: SiteHeaderProps) {
|
}: SiteHeaderProps) {
|
||||||
const brandName = site.brand.name.trim()
|
const context = { count: projectCount, project: sampleProject, live }
|
||||||
const brandMark = site.brand.mark.trim() || brandName.slice(0, 1) || 'S'
|
|
||||||
const brandLogo = site.brand.logo.trim()
|
|
||||||
|
|
||||||
// 跑马灯的位移要正好等于「一组」的宽度,循环才接得上。
|
|
||||||
// CSS 里的 -100%/3 会按轨道的 border-box(只有一组宽)算,导致提前 2/3 回跳,
|
|
||||||
// 所以这里量一次真实宽度写进 --ticker-shift。
|
|
||||||
const trackRef = useRef<HTMLDivElement>(null)
|
|
||||||
const tickerKey = `${headerItems.map((item) => `${item.tag ?? ''}:${item.text}`).join('|')}@${headerSpeed}`
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const track = trackRef.current
|
|
||||||
if (track === null) return
|
|
||||||
|
|
||||||
const sync = () => {
|
|
||||||
const group = track.firstElementChild
|
|
||||||
if (!(group instanceof HTMLElement)) return
|
|
||||||
track.style.setProperty('--ticker-shift', `${-group.getBoundingClientRect().width}px`)
|
|
||||||
}
|
|
||||||
|
|
||||||
sync()
|
|
||||||
const observer = new ResizeObserver(sync)
|
|
||||||
observer.observe(track)
|
|
||||||
window.addEventListener('resize', sync)
|
|
||||||
return () => {
|
|
||||||
observer.disconnect()
|
|
||||||
window.removeEventListener('resize', sync)
|
|
||||||
}
|
|
||||||
}, [tickerKey])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="site-header-inner">
|
<div className="site-header-inner">
|
||||||
<a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
|
<Brand
|
||||||
{brandLogo !== '' ? (
|
href="/"
|
||||||
<img className="site-brand-logo" src={brandLogo} alt="" />
|
name={site.brand.name}
|
||||||
) : (
|
mark={site.brand.mark}
|
||||||
<span className="site-brand-mark" aria-hidden="true">
|
logo={site.brand.logo}
|
||||||
{brandMark}
|
/>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{headerEnabled && (
|
{headerEnabled && (
|
||||||
<div className="header-ticker" role="status" aria-label="运行状态">
|
<Ticker speed={headerSpeed} label="运行状态">
|
||||||
<div
|
{headerItems.map((item, index) => (
|
||||||
ref={trackRef}
|
<span className="header-ticker-item" key={`${index}-${item.text}`}>
|
||||||
className="header-ticker-track"
|
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
|
||||||
style={{ animationDuration: `${headerSpeed}s` }}
|
<Tag tone={tagTone[item.tone]} shape="rounded">
|
||||||
>
|
{item.tag}
|
||||||
<InfoGroup
|
</Tag>
|
||||||
items={headerItems}
|
)}
|
||||||
projectCount={projectCount}
|
{fillHeaderText(item.text, context)}
|
||||||
sampleProject={sampleProject}
|
</span>
|
||||||
live={live}
|
))}
|
||||||
/>
|
</Ticker>
|
||||||
<InfoGroup
|
|
||||||
items={headerItems}
|
|
||||||
projectCount={projectCount}
|
|
||||||
sampleProject={sampleProject}
|
|
||||||
live={live}
|
|
||||||
// 后两份只用于视觉无缝,读屏忽略
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<InfoGroup
|
|
||||||
items={headerItems}
|
|
||||||
projectCount={projectCount}
|
|
||||||
sampleProject={sampleProject}
|
|
||||||
live={live}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Input } from '@stash/ui'
|
|
||||||
import { useEffect, type RefObject } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局搜索框:吸在内容区顶部。
|
|
||||||
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
|
|
||||||
*/
|
|
||||||
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-row">
|
|
||||||
<Input
|
|
||||||
inputRef={inputRef}
|
|
||||||
size="lg"
|
|
||||||
icon="search_3_line"
|
|
||||||
value={value}
|
|
||||||
placeholder="搜索本地项目…"
|
|
||||||
aria-label="搜索本地项目"
|
|
||||||
clearable
|
|
||||||
onClear={() => onChange('')}
|
|
||||||
spellCheck={false}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') onChange('')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user