feat(cards): 卡片外观可配置(不透明度 / 毛玻璃)

- 站点设置新增 card.opacity(40–100)与 card.blur,后台「首页管理 → 卡片外观」可调
- 前台所有卡片(首页 / 分组页 / 详情 / 侧栏)共用一套 CSS 变量
- 顺带带上上一轮:背景缓存、搜索记忆、明暗图标跟随实际状态、卡片描边/阴影、分组页卡片外壳
This commit is contained in:
2026-11-05 12:45:11 +08:00
parent a9a913942a
commit 4ce1b7e7c1
8 changed files with 229 additions and 19 deletions
+49 -9
View File
@@ -1,12 +1,46 @@
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套阴影 */
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套描边/阴影。
卡片保留一层很浅的投影;浅色默认 gray-1 几乎看不见,描边提一档。 */
:root {
--su-card-border: var(--su-gray-3);
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
--su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
}
/* 深色下描边要比卡片底(#202022)亮一档,才是一条实线 */
:root[data-scheme='dark'] {
--su-card-border: var(--su-border-strong);
}
/* 卡片外观(后台「卡片外观」可调):底色透明度 + 毛玻璃,前台所有卡片共用一套。
变量由 AppNext 按站点设置写到 <html> 上;没设置时就是实底、无毛玻璃。 */
.app .section-card,
.app .profile-card,
.app .service-panel,
.app .nav-card,
.app .group-own-block,
.app .group-child-block,
.app .detail-nav,
.app .item-card,
.app .article-body-card,
.app .related-card {
background-color: color-mix(
in srgb,
var(--su-bg-card) var(--stash-card-alpha, 100%),
transparent
);
backdrop-filter: var(--stash-card-blur, none);
}
/* 卡片阴影默认会被滚动容器在左右边缘一刀切掉(卡片正好贴着容器边)。
负边距把容器撑宽 16px、再补同样的内边距,内容位置不变,阴影有地方画。 */
#page-scroll {
margin: 0 -16px;
padding: 0 16px;
}
.app {
position: relative;
z-index: 1;
@@ -1119,6 +1153,16 @@ a.entry-href:hover {
overflow: hidden;
}
/* 侧栏卡片也贴着 aside 的边缘,阴影同样会被裁掉。
撑宽 16px 再补等量内边距,卡片宽度和位置都不变,阴影有地方画(窄屏堆叠时不适用)。 */
@media (min-width: 1101px) {
#page-scroll > aside {
width: calc(var(--layout-sider) + 32px);
margin: 0 -16px;
padding: 0 16px;
}
}
#page-scroll > aside .sidebar-wrap,
#page-scroll > aside .sidebar-home {
display: flex;
@@ -1160,18 +1204,14 @@ a.entry-href:hover {
/* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */
/* 左侧卡片半透明,露出页面背景 */
.sidebar-home .profile-card,
.sidebar-home .service-panel,
.sidebar-home .nav-card {
background-color: color-mix(in srgb, var(--su-bg-card) 64%, transparent);
border-color: color-mix(in srgb, var(--su-card-border) 55%, transparent);
backdrop-filter: blur(14px) saturate(1.15);
}
/* 侧栏卡片保持实底:原来 64% 半透明 + 毛玻璃,同一张卡落在背景图不同亮度的位置
会显出深浅差,明暗两套主题也分不出来。现在和首页卡片同一种底,只有背景图透出卡片外面。 */
.sidebar-home .nav-card {
overflow: hidden;
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-md);
box-shadow: var(--su-shadow-card);
}
/* 首页卡片:左侧卡片列表 + 右侧分组表格 */
+44 -5
View File
@@ -32,6 +32,8 @@ import { fetchSession, logout, type SessionState } from './auth'
import {
DEFAULT_SEARCH_ENGINES,
fetchSettings,
readCachedBackground,
writeCachedBackground,
type SettingsDocument,
type SiteSettings,
} from './settings'
@@ -69,6 +71,25 @@ type Route =
const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const
const SEARCH_ENGINE_KEY = 'stash-search-engine'
/** 上次选的搜索引擎:只存在这台浏览器,刷新后仍用它;失效时回落到站点默认 */
function readSearchEngine(): string | null {
try {
return window.localStorage.getItem(SEARCH_ENGINE_KEY)
} catch {
return null
}
}
function writeSearchEngine(id: string): void {
try {
window.localStorage.setItem(SEARCH_ENGINE_KEY, id)
} catch {
// 隐私模式下 localStorage 不可写,忽略即可
}
}
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' }
@@ -214,11 +235,13 @@ export default function AppNext() {
const [keyword, setKeyword] = useState('')
const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument | null>(null)
/** 上次缓存的背景:设置回来之前先用它上屏,避免刷新时先闪内置背景 */
const [cachedBackground] = useState(readCachedBackground)
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const [searchEngineId, setSearchEngineId] = useState<string | null>(null)
const [searchEngineId, setSearchEngineId] = useState<string | null>(readSearchEngine)
const [engineOpen, setEngineOpen] = useState(false)
const engineRef = useRef<HTMLDivElement>(null)
@@ -246,6 +269,11 @@ export default function AppNext() {
return () => source.close()
}, [])
// 背景地址跟着设置存一份到本机:下次刷新首帧直接用它,不用等接口回来再换
useEffect(() => {
if (settings !== null) writeCachedBackground(settings.settings.background)
}, [settings])
useEffect(() => {
const content = document.getElementById('content-scroll')
const main = content?.parentElement
@@ -304,7 +332,8 @@ export default function AppNext() {
show_service: true,
show_nav: true,
},
background: { light: '', dark: '' },
background: cachedBackground ?? { light: '', dark: '' },
card: { opacity: 100, blur: false },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
@@ -380,6 +409,13 @@ export default function AppNext() {
}
}, [scheme])
// 卡片外观:透明度和毛玻璃写在 <html> 的 CSS 变量上,前台所有卡片共用一套
useEffect(() => {
const root = document.documentElement.style
root.setProperty('--stash-card-alpha', `${site.card.opacity}%`)
root.setProperty('--stash-card-blur', site.card.blur ? 'blur(14px) saturate(1.15)' : 'none')
}, [site.card.opacity, site.card.blur])
/** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */
const toggleTheme = () => {
const next: ThemeChoice = isDark ? 'light' : 'dark'
@@ -435,6 +471,7 @@ export default function AppNext() {
const chooseEngine = (next: string) => {
setSearchEngineId(next)
writeSearchEngine(next)
setEngineOpen(false)
searchRef.current?.focus()
}
@@ -631,7 +668,9 @@ export default function AppNext() {
</div>
{group.entries.length > 0 ? (
renderEntries(group, group.entries)
<Card padding="md" className="group-own-block">
{renderEntries(group, group.entries)}
</Card>
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
<Empty text="这个分组还没有内容" />
)}
@@ -640,7 +679,7 @@ export default function AppNext() {
{group.children
.filter((child) => child.enabled !== false)
.map((child) => (
<section key={child.key} className="group-child-block">
<Card key={child.key} padding="md" className="group-child-block">
<div className="group-title">
<span
className="group-bar"
@@ -663,7 +702,7 @@ export default function AppNext() {
) : (
<Empty text="暂无内容" />
)}
</section>
</Card>
))}
</Space>
</div>
+56 -1
View File
@@ -83,6 +83,7 @@ type TabKey =
| 'content'
| 'home-sidebar'
| 'home-cards'
| 'home-cards-look'
| 'home-header'
| 'home-background'
| 'home-search'
@@ -102,6 +103,7 @@ const ADMIN_NAV: MenuItem[] = [
{ key: 'home-search', label: '搜索框' },
{ key: 'home-sidebar', label: '侧边栏' },
{ key: 'home-cards', label: '内容卡片' },
{ key: 'home-cards-look', label: '卡片外观' },
],
},
{ key: 'profile', label: '资料管理' },
@@ -281,7 +283,7 @@ export function AdminNext() {
<div className="header-actions">
<IconButton
label={isDark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={isDark ? 'sun_line' : 'moon_line'} />}
icon={<Icon name={isDark ? 'moon_line' : 'sun_line'} />}
onClick={() => void toggleTheme()}
disabled={!authenticated || saving}
/>
@@ -390,6 +392,16 @@ export function AdminNext() {
/>
)}
{tab === 'home-cards-look' && (
<section className="admin-home-section">
<Title level={4}>卡片外观</Title>
<CardLookPanel
value={draft.card}
onChange={(card) => setDraft({ ...draft, card })}
/>
</section>
)}
{tab === 'home-background' && (
<BackgroundPanel
value={draft.background}
@@ -416,6 +428,7 @@ export function AdminNext() {
{(tab === 'home-header' ||
tab === 'home-background' ||
tab === 'home-search' ||
tab === 'home-cards-look' ||
tab === 'profile') && (
<Space gap="sm" justify="end" className="admin-actions">
<Button
@@ -1394,6 +1407,48 @@ function SidebarPanel({
)
}
// ---------- 卡片外观(透明度 / 毛玻璃) ----------
function CardLookPanel({
value,
onChange,
}: {
value: SiteSettings['card']
onChange: (card: SiteSettings['card']) => void
}) {
return (
<Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
说明:前台所有卡片共用这一套外观(首页、分组页、详情页、侧栏)。不透明度越低,背景图透得越多;
毛玻璃要配合透明才看得出来。
</Text>
<FormField
label="卡片不透明度"
htmlFor="card-opacity"
hint="百分比 40–100;100 为实底,40 最透"
>
<Input
id="card-opacity"
type="number"
min={40}
max={100}
value={String(value.opacity)}
onChange={(event) => {
const opacity = Number(event.target.value)
if (Number.isFinite(opacity)) onChange({ ...value, opacity })
}}
/>
</FormField>
<ToggleRow
label="毛玻璃"
checked={value.blur === true}
onChange={(blur) => onChange({ ...value, blur })}
switchLabel="卡片使用毛玻璃"
/>
</Space>
)
}
// ---------- 首页卡片(勾选分组) ----------
function CardsPanel({
@@ -102,7 +102,7 @@ export function SiteHeader({
{site.actions.theme && (
<IconButton
label={dark ? '切换到浅色' : '切换到深色'}
icon={<Icon name={dark ? 'sun_line' : 'moon_line'} />}
icon={<Icon name={dark ? 'moon_line' : 'sun_line'} />}
onClick={onToggleTheme}
/>
)}
+37
View File
@@ -30,6 +30,41 @@ export type BackgroundSettings = {
dark: string
}
export type CardSettings = {
/** 卡片底色的不透明度(百分比,40–100) */
opacity: number
/** 毛玻璃:背景模糊 + 提饱和度 */
blur: boolean
}
const BACKGROUND_CACHE_KEY = 'stash-background'
/**
* 上次拿到的背景地址。
*
* 设置要等 `/api/settings` 回来,刷新时首帧只能先画内置背景,自定义背景随后才换上来,
* 会闪一下。先用上次缓存上屏,等接口回来再校准。
*/
export function readCachedBackground(): BackgroundSettings | null {
try {
const raw = window.localStorage.getItem(BACKGROUND_CACHE_KEY)
if (raw === null) return null
const value = JSON.parse(raw) as Partial<BackgroundSettings>
if (typeof value.light !== 'string' || typeof value.dark !== 'string') return null
return { light: value.light, dark: value.dark }
} catch {
return null
}
}
export function writeCachedBackground(background: BackgroundSettings): void {
try {
window.localStorage.setItem(BACKGROUND_CACHE_KEY, JSON.stringify(background))
} catch {
// 隐私模式下 localStorage 不可写,忽略即可
}
}
export type SearchEngineId = 'google' | 'bing' | 'baidu' | 'sogou' | '360'
export type SearchEngineSettings = {
@@ -98,6 +133,7 @@ export type SiteSettings = {
show_nav: boolean
}
background: BackgroundSettings
card: CardSettings
search: SearchSettings
profile: ProfileSettings
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
@@ -167,6 +203,7 @@ export function defaultSettings(): SiteSettings {
show_nav: true,
},
background: { light: '', dark: '' },
card: { opacity: 100, blur: false },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),