feat(cards): 卡片外观可配置(不透明度 / 毛玻璃)
- 站点设置新增 card.opacity(40–100)与 card.blur,后台「首页管理 → 卡片外观」可调 - 前台所有卡片(首页 / 分组页 / 详情 / 侧栏)共用一套 CSS 变量 - 顺带带上上一轮:背景缓存、搜索记忆、明暗图标跟随实际状态、卡片描边/阴影、分组页卡片外壳
This commit is contained in:
+6
-2
@@ -113,6 +113,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
|
|||||||
"header": { "enabled": true, "speed": 24, "gap": 26, "items": [...] },
|
"header": { "enabled": true, "speed": 24, "gap": 26, "items": [...] },
|
||||||
"sidebar": { "show_profile": true, "show_service": false, "show_nav": true },
|
"sidebar": { "show_profile": true, "show_service": false, "show_nav": true },
|
||||||
"background": { "light": "", "dark": "" }, // 空 = 内置背景
|
"background": { "light": "", "dark": "" }, // 空 = 内置背景
|
||||||
|
"card": { "opacity": 100, "blur": false }, // 卡片不透明度 40–100 + 毛玻璃
|
||||||
"search": {
|
"search": {
|
||||||
"default": "internal",
|
"default": "internal",
|
||||||
"engines": [
|
"engines": [
|
||||||
@@ -147,6 +148,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
|
|||||||
- 搜索框:默认搜索项、五个外部引擎的名称 / URL / 启停、恢复默认;
|
- 搜索框:默认搜索项、五个外部引擎的名称 / URL / 启停、恢复默认;
|
||||||
- 侧边栏:资料卡 / 本地服务 / 分组导航显隐;
|
- 侧边栏:资料卡 / 本地服务 / 分组导航显隐;
|
||||||
- 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序;
|
- 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序;
|
||||||
|
- 卡片外观:前台所有卡片的不透明度(40–100)与毛玻璃开关;
|
||||||
- **资料管理**:头像、名称、角色、简介、位置;
|
- **资料管理**:头像、名称、角色、简介、位置;
|
||||||
- **系统管理**:改密码、下载备份。
|
- **系统管理**:改密码、下载备份。
|
||||||
|
|
||||||
@@ -156,13 +158,15 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等
|
|||||||
- 第一项固定「站内搜索」,分隔线后是启用的外部引擎;
|
- 第一项固定「站内搜索」,分隔线后是启用的外部引擎;
|
||||||
- 站内模式:输入过滤本地内容;回车打开第一个匹配条目/分组;
|
- 站内模式:输入过滤本地内容;回车打开第一个匹配条目/分组;
|
||||||
- 外部模式:输入**不再过滤**本地内容;回车用该引擎 URL 新标签页搜索;
|
- 外部模式:输入**不再过滤**本地内容;回车用该引擎 URL 新标签页搜索;
|
||||||
- 当前选择只存在当前页面会话,刷新后回到后台默认项。
|
- 当前选择存在本机 localStorage(`stash-search-engine`),刷新后仍用它;引擎被停用 / 删除时回落到后台默认项。
|
||||||
|
|
||||||
主题:
|
主题:
|
||||||
|
|
||||||
- 深色背景来自 Alright 演示站,资源 `site-bg-dark.jpg`;
|
- 深色背景来自 Alright 演示站,资源 `site-bg-dark.jpg`;
|
||||||
- 明暗切换优先用 View Transition 整页快照交叉淡入(160ms),不支持时回退到 CSS 颜色过渡;
|
- 明暗切换优先用 View Transition 整页快照交叉淡入(160ms),不支持时回退到 CSS 颜色过渡;
|
||||||
- 首帧由 `index.html` 内联脚本按 localStorage / 系统偏好先上色;
|
- 首帧由 `index.html` 内联脚本按 localStorage / 系统偏好先上色;
|
||||||
|
- 背景地址也缓存在 localStorage(`stash-background`),刷新首帧直接用它,设置回来再校准,不会先闪内置背景;
|
||||||
|
- 页头图标显示**当前**实际明暗(深色月亮 / 浅色太阳),点它是切到另一侧;
|
||||||
- 背景图两层(light/dark)交叉淡入。
|
- 背景图两层(light/dark)交叉淡入。
|
||||||
|
|
||||||
## 8. HTTP API(当前保留)
|
## 8. HTTP API(当前保留)
|
||||||
@@ -208,7 +212,7 @@ PATCH /api/auth/password
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
|
| 推送 | 本地 `develop` 领先远端 20 个提交,尚未推送;Gitea HTTPS 凭据需准备 |
|
||||||
| 搜索引擎 | id 固定五个;新增需同时补 EngineIcon、后端校验、前端默认值、后台面板 |
|
| 搜索引擎 | id 固定五个;新增需同时补 EngineIcon、后端校验、前端默认值、后台面板 |
|
||||||
| 搜索选择记忆 | 不持久化;刷新按后台 `search.default` |
|
| 搜索选择记忆 | 存本机 localStorage(`stash-search-engine`);换设备 / 清站点数据后回到后台默认 |
|
||||||
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
|
| 图片上传 | 背景 / 头像 / Logo 只支持 URL,不支持上传 |
|
||||||
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
|
| 卡片/分组拖拽 | 排序用数值 / 现有 SortableList;分组拖拽排序未做 |
|
||||||
| 备份恢复 | 只有 `/api/backup` 下载;恢复需停机替换库文件 |
|
| 备份恢复 | 只有 `/api/backup` 下载;恢复需停机替换库文件 |
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
- 登录后点「站点设置」进入 `/admin`,左侧导航 + 右侧内容:
|
- 登录后点「站点设置」进入 `/admin`,左侧导航 + 右侧内容:
|
||||||
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
|
- **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除;
|
||||||
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
|
- **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除;
|
||||||
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示);
|
- **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示)、**卡片外观**(所有卡片的不透明度 40–100 与毛玻璃开关);
|
||||||
- **资料管理**与**系统管理**(改密码、下载整库备份);
|
- **资料管理**与**系统管理**(改密码、下载整库备份);
|
||||||
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
|
- 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效;
|
||||||
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub const SETTINGS_KEY: &str = "site";
|
|||||||
pub const MAX_HEADER_ITEMS: usize = 8;
|
pub const MAX_HEADER_ITEMS: usize = 8;
|
||||||
pub const MIN_SPEED: u32 = 8;
|
pub const MIN_SPEED: u32 = 8;
|
||||||
pub const MAX_SPEED: u32 = 120;
|
pub const MAX_SPEED: u32 = 120;
|
||||||
|
pub const MIN_CARD_OPACITY: u32 = 40;
|
||||||
|
pub const MAX_CARD_OPACITY: u32 = 100;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -119,6 +121,25 @@ pub struct BackgroundSettings {
|
|||||||
pub dark: String,
|
pub dark: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 卡片外观:透明度与毛玻璃。前台所有卡片共用一套(首页 / 分组页 / 详情页 / 侧栏)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct CardSettings {
|
||||||
|
/// 卡片底色的不透明度(百分比,40–100)
|
||||||
|
pub opacity: u32,
|
||||||
|
/// 毛玻璃:背景模糊 + 提饱和度
|
||||||
|
pub blur: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CardSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
opacity: 100,
|
||||||
|
blur: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct SearchEngineSettings {
|
pub struct SearchEngineSettings {
|
||||||
@@ -288,6 +309,7 @@ pub struct Settings {
|
|||||||
pub header: HeaderSettings,
|
pub header: HeaderSettings,
|
||||||
pub sidebar: SidebarSettings,
|
pub sidebar: SidebarSettings,
|
||||||
pub background: BackgroundSettings,
|
pub background: BackgroundSettings,
|
||||||
|
pub card: CardSettings,
|
||||||
pub search: SearchSettings,
|
pub search: SearchSettings,
|
||||||
pub profile: ProfileSettings,
|
pub profile: ProfileSettings,
|
||||||
pub site: SiteMetaSettings,
|
pub site: SiteMetaSettings,
|
||||||
@@ -354,6 +376,11 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
|||||||
"暗色背景地址最多 500 个字符".into(),
|
"暗色背景地址最多 500 个字符".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if !(MIN_CARD_OPACITY..=MAX_CARD_OPACITY).contains(&settings.card.opacity) {
|
||||||
|
return Err(StashError::InvalidDirectory(format!(
|
||||||
|
"卡片不透明度需在 {MIN_CARD_OPACITY}–{MAX_CARD_OPACITY} 之间"
|
||||||
|
)));
|
||||||
|
}
|
||||||
if settings.search.engines.len() > 10 {
|
if settings.search.engines.len() > 10 {
|
||||||
return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into()));
|
return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into()));
|
||||||
}
|
}
|
||||||
@@ -551,6 +578,14 @@ mod tests {
|
|||||||
let mut empty = Settings::default();
|
let mut empty = Settings::default();
|
||||||
empty.header.items.clear();
|
empty.header.items.clear();
|
||||||
assert!(save(&mut conn, empty, 0).is_err());
|
assert!(save(&mut conn, empty, 0).is_err());
|
||||||
|
|
||||||
|
let mut too_transparent = Settings::default();
|
||||||
|
too_transparent.card.opacity = MIN_CARD_OPACITY - 1;
|
||||||
|
assert!(save(&mut conn, too_transparent, 0).is_err());
|
||||||
|
|
||||||
|
let mut clear = Settings::default();
|
||||||
|
clear.card.opacity = MAX_CARD_OPACITY;
|
||||||
|
assert!(save(&mut conn, clear, 0).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+49
-9
@@ -1,12 +1,46 @@
|
|||||||
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
|
/* 页面级样式:只保留「组件库没有」的部分(背景、外壳、封面兜底、正文版式)。
|
||||||
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
|
颜色 / 间距 / 字号全部取自 @stash/ui 的令牌。 */
|
||||||
|
|
||||||
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套阴影 */
|
/* 主题级覆盖:放 :root 而不是 .app,前后台两个壳才能拿到同一套描边/阴影。
|
||||||
|
卡片保留一层很浅的投影;浅色默认 gray-1 几乎看不见,描边提一档。 */
|
||||||
:root {
|
:root {
|
||||||
|
--su-card-border: var(--su-gray-3);
|
||||||
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
|
--su-shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
|
||||||
--su-shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
|
--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 {
|
.app {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -1119,6 +1153,16 @@ a.entry-href:hover {
|
|||||||
overflow: hidden;
|
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-wrap,
|
||||||
#page-scroll > aside .sidebar-home {
|
#page-scroll > aside .sidebar-home {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1160,18 +1204,14 @@ a.entry-href:hover {
|
|||||||
|
|
||||||
/* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */
|
/* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */
|
||||||
|
|
||||||
/* 左侧卡片半透明,露出页面背景 */
|
/* 侧栏卡片保持实底:原来 64% 半透明 + 毛玻璃,同一张卡落在背景图不同亮度的位置
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-home .nav-card {
|
.sidebar-home .nav-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--su-card-border);
|
||||||
border-radius: var(--su-radius-md);
|
border-radius: var(--su-radius-md);
|
||||||
|
box-shadow: var(--su-shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 首页卡片:左侧卡片列表 + 右侧分组表格 */
|
/* 首页卡片:左侧卡片列表 + 右侧分组表格 */
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { fetchSession, logout, type SessionState } from './auth'
|
|||||||
import {
|
import {
|
||||||
DEFAULT_SEARCH_ENGINES,
|
DEFAULT_SEARCH_ENGINES,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
|
readCachedBackground,
|
||||||
|
writeCachedBackground,
|
||||||
type SettingsDocument,
|
type SettingsDocument,
|
||||||
type SiteSettings,
|
type SiteSettings,
|
||||||
} from './settings'
|
} from './settings'
|
||||||
@@ -69,6 +71,25 @@ type Route =
|
|||||||
|
|
||||||
const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const
|
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 {
|
function parseRoute(pathname: string): Route {
|
||||||
const segments = pathname.split('/').filter(Boolean)
|
const segments = pathname.split('/').filter(Boolean)
|
||||||
if (segments[0] === 'interface') return { kind: 'api' }
|
if (segments[0] === 'interface') return { kind: 'api' }
|
||||||
@@ -214,11 +235,13 @@ export default function AppNext() {
|
|||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [session, setSession] = useState<SessionState | null>(null)
|
const [session, setSession] = useState<SessionState | null>(null)
|
||||||
const [settings, setSettings] = useState<SettingsDocument | null>(null)
|
const [settings, setSettings] = useState<SettingsDocument | null>(null)
|
||||||
|
/** 上次缓存的背景:设置回来之前先用它上屏,避免刷新时先闪内置背景 */
|
||||||
|
const [cachedBackground] = useState(readCachedBackground)
|
||||||
const [loginOpen, setLoginOpen] = useState(false)
|
const [loginOpen, setLoginOpen] = useState(false)
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
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 [engineOpen, setEngineOpen] = useState(false)
|
||||||
const engineRef = useRef<HTMLDivElement>(null)
|
const engineRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -246,6 +269,11 @@ export default function AppNext() {
|
|||||||
return () => source.close()
|
return () => source.close()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 背景地址跟着设置存一份到本机:下次刷新首帧直接用它,不用等接口回来再换
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings !== null) writeCachedBackground(settings.settings.background)
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const content = document.getElementById('content-scroll')
|
const content = document.getElementById('content-scroll')
|
||||||
const main = content?.parentElement
|
const main = content?.parentElement
|
||||||
@@ -304,7 +332,8 @@ export default function AppNext() {
|
|||||||
show_service: true,
|
show_service: true,
|
||||||
show_nav: true,
|
show_nav: true,
|
||||||
},
|
},
|
||||||
background: { light: '', dark: '' },
|
background: cachedBackground ?? { light: '', dark: '' },
|
||||||
|
card: { opacity: 100, blur: false },
|
||||||
search: {
|
search: {
|
||||||
default: 'internal',
|
default: 'internal',
|
||||||
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
|
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
|
||||||
@@ -380,6 +409,13 @@ export default function AppNext() {
|
|||||||
}
|
}
|
||||||
}, [scheme])
|
}, [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 toggleTheme = () => {
|
||||||
const next: ThemeChoice = isDark ? 'light' : 'dark'
|
const next: ThemeChoice = isDark ? 'light' : 'dark'
|
||||||
@@ -435,6 +471,7 @@ export default function AppNext() {
|
|||||||
|
|
||||||
const chooseEngine = (next: string) => {
|
const chooseEngine = (next: string) => {
|
||||||
setSearchEngineId(next)
|
setSearchEngineId(next)
|
||||||
|
writeSearchEngine(next)
|
||||||
setEngineOpen(false)
|
setEngineOpen(false)
|
||||||
searchRef.current?.focus()
|
searchRef.current?.focus()
|
||||||
}
|
}
|
||||||
@@ -631,7 +668,9 @@ export default function AppNext() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{group.entries.length > 0 ? (
|
{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 : (
|
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
|
||||||
<Empty text="这个分组还没有内容" />
|
<Empty text="这个分组还没有内容" />
|
||||||
)}
|
)}
|
||||||
@@ -640,7 +679,7 @@ export default function AppNext() {
|
|||||||
{group.children
|
{group.children
|
||||||
.filter((child) => child.enabled !== false)
|
.filter((child) => child.enabled !== false)
|
||||||
.map((child) => (
|
.map((child) => (
|
||||||
<section key={child.key} className="group-child-block">
|
<Card key={child.key} padding="md" className="group-child-block">
|
||||||
<div className="group-title">
|
<div className="group-title">
|
||||||
<span
|
<span
|
||||||
className="group-bar"
|
className="group-bar"
|
||||||
@@ -663,7 +702,7 @@ export default function AppNext() {
|
|||||||
) : (
|
) : (
|
||||||
<Empty text="暂无内容" />
|
<Empty text="暂无内容" />
|
||||||
)}
|
)}
|
||||||
</section>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ type TabKey =
|
|||||||
| 'content'
|
| 'content'
|
||||||
| 'home-sidebar'
|
| 'home-sidebar'
|
||||||
| 'home-cards'
|
| 'home-cards'
|
||||||
|
| 'home-cards-look'
|
||||||
| 'home-header'
|
| 'home-header'
|
||||||
| 'home-background'
|
| 'home-background'
|
||||||
| 'home-search'
|
| 'home-search'
|
||||||
@@ -102,6 +103,7 @@ const ADMIN_NAV: MenuItem[] = [
|
|||||||
{ key: 'home-search', label: '搜索框' },
|
{ key: 'home-search', label: '搜索框' },
|
||||||
{ key: 'home-sidebar', label: '侧边栏' },
|
{ key: 'home-sidebar', label: '侧边栏' },
|
||||||
{ key: 'home-cards', label: '内容卡片' },
|
{ key: 'home-cards', label: '内容卡片' },
|
||||||
|
{ key: 'home-cards-look', label: '卡片外观' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: 'profile', label: '资料管理' },
|
{ key: 'profile', label: '资料管理' },
|
||||||
@@ -281,7 +283,7 @@ export function AdminNext() {
|
|||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<IconButton
|
<IconButton
|
||||||
label={isDark ? '切换到浅色' : '切换到深色'}
|
label={isDark ? '切换到浅色' : '切换到深色'}
|
||||||
icon={<Icon name={isDark ? 'sun_line' : 'moon_line'} />}
|
icon={<Icon name={isDark ? 'moon_line' : 'sun_line'} />}
|
||||||
onClick={() => void toggleTheme()}
|
onClick={() => void toggleTheme()}
|
||||||
disabled={!authenticated || saving}
|
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' && (
|
{tab === 'home-background' && (
|
||||||
<BackgroundPanel
|
<BackgroundPanel
|
||||||
value={draft.background}
|
value={draft.background}
|
||||||
@@ -416,6 +428,7 @@ export function AdminNext() {
|
|||||||
{(tab === 'home-header' ||
|
{(tab === 'home-header' ||
|
||||||
tab === 'home-background' ||
|
tab === 'home-background' ||
|
||||||
tab === 'home-search' ||
|
tab === 'home-search' ||
|
||||||
|
tab === 'home-cards-look' ||
|
||||||
tab === 'profile') && (
|
tab === 'profile') && (
|
||||||
<Space gap="sm" justify="end" className="admin-actions">
|
<Space gap="sm" justify="end" className="admin-actions">
|
||||||
<Button
|
<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({
|
function CardsPanel({
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function SiteHeader({
|
|||||||
{site.actions.theme && (
|
{site.actions.theme && (
|
||||||
<IconButton
|
<IconButton
|
||||||
label={dark ? '切换到浅色' : '切换到深色'}
|
label={dark ? '切换到浅色' : '切换到深色'}
|
||||||
icon={<Icon name={dark ? 'sun_line' : 'moon_line'} />}
|
icon={<Icon name={dark ? 'moon_line' : 'sun_line'} />}
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,6 +30,41 @@ export type BackgroundSettings = {
|
|||||||
dark: string
|
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 SearchEngineId = 'google' | 'bing' | 'baidu' | 'sogou' | '360'
|
||||||
|
|
||||||
export type SearchEngineSettings = {
|
export type SearchEngineSettings = {
|
||||||
@@ -98,6 +133,7 @@ export type SiteSettings = {
|
|||||||
show_nav: boolean
|
show_nav: boolean
|
||||||
}
|
}
|
||||||
background: BackgroundSettings
|
background: BackgroundSettings
|
||||||
|
card: CardSettings
|
||||||
search: SearchSettings
|
search: SearchSettings
|
||||||
profile: ProfileSettings
|
profile: ProfileSettings
|
||||||
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
||||||
@@ -167,6 +203,7 @@ export function defaultSettings(): SiteSettings {
|
|||||||
show_nav: true,
|
show_nav: true,
|
||||||
},
|
},
|
||||||
background: { light: '', dark: '' },
|
background: { light: '', dark: '' },
|
||||||
|
card: { opacity: 100, blur: false },
|
||||||
search: {
|
search: {
|
||||||
default: 'internal',
|
default: 'internal',
|
||||||
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
|
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
|
||||||
|
|||||||
Reference in New Issue
Block a user