feat(admin): 增加背景管理
- settings 新增 background.light / background.dark,留空使用内置图 - 后台首页管理新增「背景管理」,两栏 URL 输入与实时预览,可一键恢复内置图 - 前台按设置覆盖明暗背景图,仍保留 65vh 与交叉淡入 - 保存/撤销沿用现有设置乐观锁流程
This commit is contained in:
+2
-2
@@ -18,7 +18,7 @@
|
|||||||
Stash 已从「只读本地导航站」升级为**可登录、可配置、可编辑内容的本地发布台**:
|
Stash 已从「只读本地导航站」升级为**可登录、可配置、可编辑内容的本地发布台**:
|
||||||
|
|
||||||
- 登录(单用户,argon2id + Cookie 会话);
|
- 登录(单用户,argon2id + Cookie 会话);
|
||||||
- `/admin` 设置页(页头滚动信息 / 首页卡片 / 归类 / 侧栏 / 资料 / 系统);
|
- `/admin` 设置页(页头 / 背景 / 内容卡片 / 侧栏 / 资料 / 系统);
|
||||||
- 条目就地编辑(Drawer + 乐观锁);
|
- 条目就地编辑(Drawer + 乐观锁);
|
||||||
- 分区新增/删除、**条目新增/删除(可选分区与分组)**、分组(子分类)新建/改名/删除、侧栏子分类配置、浏览/点赞/分享/跳转;
|
- 分区新增/删除、**条目新增/删除(可选分区与分组)**、分组(子分类)新建/改名/删除、侧栏子分类配置、浏览/点赞/分享/跳转;
|
||||||
- 数据库自动迁移到 **v18**;
|
- 数据库自动迁移到 **v18**;
|
||||||
@@ -79,7 +79,7 @@ src/
|
|||||||
mod.rs Db 门面(所有方法)+ 迁移测试
|
mod.rs Db 门面(所有方法)+ 迁移测试
|
||||||
schema.rs 建表、种子、版本迁移(0 / 15→…→23)
|
schema.rs 建表、种子、版本迁移(0 / 15→…→23)
|
||||||
auth.rs 管理员、密码哈希、会话(token 只存 sha256)
|
auth.rs 管理员、密码哈希、会话(token 只存 sha256)
|
||||||
settings.rs settings JSON(header/sidebar/profile/site)
|
settings.rs settings JSON(header/sidebar/background/profile/site)
|
||||||
entries.rs 条目读取/编辑、浏览数、点赞、审计
|
entries.rs 条目读取/编辑、浏览数、点赞、审计
|
||||||
groups.rs 分组树新增/移动/排序/删除
|
groups.rs 分组树新增/移动/排序/删除
|
||||||
cards.rs 首页卡片与勾选分组
|
cards.rs 首页卡片与勾选分组
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ pub struct SidebarSettings {
|
|||||||
pub show_nav: bool,
|
pub show_nav: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct BackgroundSettings {
|
||||||
|
/// 亮色背景图地址;空表示用内置图
|
||||||
|
pub light: String,
|
||||||
|
/// 暗色背景图地址;空表示用内置图
|
||||||
|
pub dark: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ProfileSettings {
|
pub struct ProfileSettings {
|
||||||
@@ -220,6 +229,7 @@ impl Default for SiteMetaSettings {
|
|||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub header: HeaderSettings,
|
pub header: HeaderSettings,
|
||||||
pub sidebar: SidebarSettings,
|
pub sidebar: SidebarSettings,
|
||||||
|
pub background: BackgroundSettings,
|
||||||
pub profile: ProfileSettings,
|
pub profile: ProfileSettings,
|
||||||
pub site: SiteMetaSettings,
|
pub site: SiteMetaSettings,
|
||||||
}
|
}
|
||||||
@@ -275,6 +285,16 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
|||||||
if settings.profile.location.chars().count() > 40 {
|
if settings.profile.location.chars().count() > 40 {
|
||||||
return Err(StashError::InvalidDirectory("位置最多 40 个字".into()));
|
return Err(StashError::InvalidDirectory("位置最多 40 个字".into()));
|
||||||
}
|
}
|
||||||
|
if settings.background.light.chars().count() > 500 {
|
||||||
|
return Err(StashError::InvalidDirectory(
|
||||||
|
"亮色背景地址最多 500 个字符".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if settings.background.dark.chars().count() > 500 {
|
||||||
|
return Err(StashError::InvalidDirectory(
|
||||||
|
"暗色背景地址最多 500 个字符".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if settings.site.title.chars().count() > 120 {
|
if settings.site.title.chars().count() > 120 {
|
||||||
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
|
return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1379,3 +1379,46 @@ html.theme-transition .site-background {
|
|||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 后台:背景管理预览 */
|
||||||
|
.background-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--su-space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--su-space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-preview {
|
||||||
|
position: relative;
|
||||||
|
height: 180px;
|
||||||
|
border: 1px solid var(--su-border);
|
||||||
|
border-radius: var(--su-radius-md);
|
||||||
|
background-color: var(--su-bg-subtle);
|
||||||
|
background-position: center top;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-preview span {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--su-space-3);
|
||||||
|
left: var(--su-space-3);
|
||||||
|
padding: 2px var(--su-space-2);
|
||||||
|
border-radius: var(--su-radius-pill);
|
||||||
|
background-color: rgb(0 0 0 / 55%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--su-font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.background-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ export default function AppNext() {
|
|||||||
show_service: true,
|
show_service: true,
|
||||||
show_nav: true,
|
show_nav: true,
|
||||||
},
|
},
|
||||||
|
background: { light: '', dark: '' },
|
||||||
profile: {
|
profile: {
|
||||||
name: '本地管理员',
|
name: '本地管理员',
|
||||||
role: '管理员',
|
role: '管理员',
|
||||||
@@ -644,8 +645,24 @@ export default function AppNext() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="site-background site-background-light" aria-hidden="true" />
|
<div
|
||||||
<div className="site-background site-background-dark" aria-hidden="true" />
|
className="site-background site-background-light"
|
||||||
|
style={
|
||||||
|
site.background.light.trim() === ''
|
||||||
|
? undefined
|
||||||
|
: { backgroundImage: `url(${JSON.stringify(site.background.light.trim())})` }
|
||||||
|
}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="site-background site-background-dark"
|
||||||
|
style={
|
||||||
|
site.background.dark.trim() === ''
|
||||||
|
? undefined
|
||||||
|
: { backgroundImage: `url(${JSON.stringify(site.background.dark.trim())})` }
|
||||||
|
}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
contained
|
contained
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
saveSettings,
|
saveSettings,
|
||||||
TONE_OPTIONS,
|
TONE_OPTIONS,
|
||||||
type ProfileSettings,
|
type ProfileSettings,
|
||||||
|
type BackgroundSettings,
|
||||||
type SettingsDocument,
|
type SettingsDocument,
|
||||||
type SettingsTone,
|
type SettingsTone,
|
||||||
type SiteSettings,
|
type SiteSettings,
|
||||||
@@ -63,6 +64,8 @@ import {
|
|||||||
import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
|
import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
|
||||||
import { useProjects } from '../../useProjects'
|
import { useProjects } from '../../useProjects'
|
||||||
import type { Project } from '../../api'
|
import type { Project } from '../../api'
|
||||||
|
import siteBgLight from '../../assets/site-bg.jpg'
|
||||||
|
import siteBgDark from '../../assets/site-bg-dark.jpg'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新版后台:原子模型。
|
* 新版后台:原子模型。
|
||||||
@@ -77,6 +80,7 @@ type TabKey =
|
|||||||
| 'home-sidebar'
|
| 'home-sidebar'
|
||||||
| 'home-cards'
|
| 'home-cards'
|
||||||
| 'home-header'
|
| 'home-header'
|
||||||
|
| 'home-background'
|
||||||
| 'profile'
|
| 'profile'
|
||||||
| 'system'
|
| 'system'
|
||||||
|
|
||||||
@@ -89,6 +93,7 @@ const ADMIN_NAV: MenuItem[] = [
|
|||||||
label: '首页管理',
|
label: '首页管理',
|
||||||
children: [
|
children: [
|
||||||
{ key: 'home-header', label: '页头' },
|
{ key: 'home-header', label: '页头' },
|
||||||
|
{ key: 'home-background', label: '背景管理' },
|
||||||
{ key: 'home-sidebar', label: '侧边栏' },
|
{ key: 'home-sidebar', label: '侧边栏' },
|
||||||
{ key: 'home-cards', label: '内容卡片' },
|
{ key: 'home-cards', label: '内容卡片' },
|
||||||
],
|
],
|
||||||
@@ -379,6 +384,13 @@ export function AdminNext() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'home-background' && (
|
||||||
|
<BackgroundPanel
|
||||||
|
value={draft.background}
|
||||||
|
onChange={(background) => setDraft({ ...draft, background })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'profile' && (
|
{tab === 'profile' && (
|
||||||
<ProfilePanel
|
<ProfilePanel
|
||||||
value={draft.profile}
|
value={draft.profile}
|
||||||
@@ -388,7 +400,9 @@ export function AdminNext() {
|
|||||||
|
|
||||||
{tab === 'system' && <SystemPanel />}
|
{tab === 'system' && <SystemPanel />}
|
||||||
|
|
||||||
{(tab === 'home-header' || tab === 'profile') && (
|
{(tab === 'home-header' ||
|
||||||
|
tab === 'home-background' ||
|
||||||
|
tab === 'profile') && (
|
||||||
<Space gap="sm" justify="end" className="admin-actions">
|
<Space gap="sm" justify="end" className="admin-actions">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -2037,6 +2051,64 @@ function HeaderPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BackgroundPanel({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: BackgroundSettings
|
||||||
|
onChange: (background: BackgroundSettings) => void
|
||||||
|
}) {
|
||||||
|
const update = (patch: Partial<BackgroundSettings>) => onChange({ ...value, ...patch })
|
||||||
|
const preview = (url: string, fallback: string) => ({
|
||||||
|
backgroundImage: `url(${JSON.stringify(url.trim() || fallback)})`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||||
|
<Title level={4}>背景管理</Title>
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
说明:分别设置亮色与暗色模式顶部的背景图,留空使用内置图;站内图片请放到托管目录 public/ 后填写路径,或填完整 URL。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className="background-grid">
|
||||||
|
<div className="background-section">
|
||||||
|
<FormField label="亮色背景图" htmlFor="background-light" hint="留空使用内置亮色背景">
|
||||||
|
<Input
|
||||||
|
id="background-light"
|
||||||
|
value={value.light}
|
||||||
|
placeholder="/bg-light.jpg 或 https://…"
|
||||||
|
onChange={(event) => update({ light: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="background-preview" style={preview(value.light, siteBgLight)}>
|
||||||
|
<span>亮色预览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="background-section">
|
||||||
|
<FormField label="暗色背景图" htmlFor="background-dark" hint="留空使用内置暗色背景">
|
||||||
|
<Input
|
||||||
|
id="background-dark"
|
||||||
|
value={value.dark}
|
||||||
|
placeholder="/bg-dark.jpg 或 https://…"
|
||||||
|
onChange={(event) => update({ dark: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="background-preview" style={preview(value.dark, siteBgDark)}>
|
||||||
|
<span>暗色预览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button variant="secondary" onClick={() => onChange({ light: '', dark: '' })}>
|
||||||
|
恢复内置背景
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ProfilePanel({
|
function ProfilePanel({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export type ProfileSettings = {
|
|||||||
avatar: string
|
avatar: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BackgroundSettings = {
|
||||||
|
/** 亮色背景图地址;空表示使用内置图 */
|
||||||
|
light: string
|
||||||
|
/** 暗色背景图地址;空表示使用内置图 */
|
||||||
|
dark: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SiteSettings = {
|
export type SiteSettings = {
|
||||||
header: {
|
header: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -40,6 +47,7 @@ export type SiteSettings = {
|
|||||||
/** 是否显示「分组导航」卡片(首页 / 分组树) */
|
/** 是否显示「分组导航」卡片(首页 / 分组树) */
|
||||||
show_nav: boolean
|
show_nav: boolean
|
||||||
}
|
}
|
||||||
|
background: BackgroundSettings
|
||||||
profile: ProfileSettings
|
profile: ProfileSettings
|
||||||
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
/** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */
|
||||||
site: SiteMetaSettings
|
site: SiteMetaSettings
|
||||||
@@ -107,6 +115,7 @@ export function defaultSettings(): SiteSettings {
|
|||||||
show_service: true,
|
show_service: true,
|
||||||
show_nav: true,
|
show_nav: true,
|
||||||
},
|
},
|
||||||
|
background: { light: '', dark: '' },
|
||||||
profile: {
|
profile: {
|
||||||
name: '本地管理员',
|
name: '本地管理员',
|
||||||
role: '管理员',
|
role: '管理员',
|
||||||
|
|||||||
Reference in New Issue
Block a user