feat: 侧栏卡片显隐、本地服务卡片恢复、页面同步滚动

- 侧边栏设置新增「侧栏卡片」:本地管理员/本地服务显隐,即时保存 show_profile/show_service
- 首页固定第一位恢复「本地服务」卡片(运行时项目网格),移除卡片三点菜单
- 后台首页卡片新增只读「本地服务」表,标记不可删除
- 前台改为整页滚动,左右两栏同步
- 验证:显隐落库、三点为 0、本地表无删除、滚动两栏同步 300px
This commit is contained in:
2026-10-31 14:59:41 +08:00
parent 11f992cdad
commit 0e6c772466
8 changed files with 208 additions and 19 deletions
+3 -2
View File
@@ -99,8 +99,8 @@ README.md 运行、路由、库结构、登录说明
| --- | --- |
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/图标/排序/状态)、删除;格式已下沉到条目,不再在分组上设置 |
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 格式 / 跳转链接 / 所属分组 / 操作;每条内容单独选格式(应用/海报/图文),同一分组可混合 |
| 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 |
| 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片弹窗输入标题;卡片可拖动排序(影响首页顺序);表格内开关/条数即时保存,标题用「保存」按钮 |
| 侧边栏 | 顶部「侧栏卡片」控制本地管理员 / 本地服务显隐;下方「分组导航」逐项勾选;开关即保存 |
| 首页卡片 | 左侧列表含只读「本地服务」表(不可删除)+ 自建卡片;右侧分组表格;卡片可拖动排序,表格内开关/条数即时保存 |
| 页头 | 启用开关、速度、条目、标签与色调 |
| 资料 | 头像 URL、名称、角色、简介、位置 |
| 系统 | 修改密码、下载备份 |
@@ -166,6 +166,7 @@ GET /api/backup SQLite 快照附件 需登录
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
- 当前 `PRAGMA user_version = 21`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21、更高版本拒绝启动。
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。
- `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true);`card` 表只存自建卡片,首页「本地服务」卡片由 `/api/projects` 运行时生成,不落库、不可删。
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
+1 -1
View File
@@ -34,7 +34,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
### 登录与设置(v0.1.1 起)
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,每条可单独设格式,主分组含子分组)、侧边栏(勾选分组树)、首页卡片(左侧卡片列表 + 分组表格),以及页头滚动信息、资料与系统设置;
- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,每条可单独设格式,主分组含子分组)、侧边栏(侧栏卡片显隐 + 分组导航勾选)、首页卡片(本地服务只读表 + 左侧卡片列表 + 分组表格),以及页头滚动信息、资料与系统设置;
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
+8
View File
@@ -147,6 +147,14 @@ DELETE /api/items/:id
## 11. 实施记录
### P17(2026-10-31,侧栏卡片显隐、本地服务卡片、同步滚动)
- 侧边栏设置顶部新增「侧栏卡片」:本地管理员 / 本地服务各一个开关,即时保存到 `settings.sidebar.show_profile / show_service`;与下方分组导航分区显示;
- 首页恢复「本地服务」卡片(由 `/api/projects` 运行时生成,应用网格),固定排在最前;移除卡片右上角三点;
- 首页卡片后台新增只读「本地服务」表:列出项目名 / 访问地址,标记不可删除,不提供删除/改名;
- 前台放弃整屏内滚动,改为页面整体滚动,左右两栏同步滚动;
- 验证:开关即落库、前台隐藏对应卡片、三点数量为 0、本地服务表无删除按钮、滚动 300px 两栏同步位移 300px。
### P16(2026-10-31,格式下沉到条目)
- 数据库 v21:`entry.format`(apps / posters / articles),老数据按所属根分组的 `view_mode` 自动回填;新库 seed 按分区写入;
+27 -1
View File
@@ -79,9 +79,35 @@ impl Default for HeaderSettings {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
fn default_true() -> bool {
true
}
impl Default for SidebarSettings {
fn default() -> Self {
Self {
show_profile: true,
show_service: true,
hidden: Vec::new(),
order: Vec::new(),
hidden_groups: Vec::new(),
shown_groups: Vec::new(),
group_order: std::collections::HashMap::new(),
tree_hidden: Vec::new(),
tree_order: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SidebarSettings {
/// 是否显示「本地管理员」资料卡
#[serde(default = "default_true")]
pub show_profile: bool,
/// 是否显示「本地服务」面板
#[serde(default = "default_true")]
pub show_service: bool,
/// 隐藏的分类 id
pub hidden: Vec<String>,
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
+5
View File
@@ -1299,6 +1299,11 @@ a.entry-href:hover {
min-width: 0;
}
.sidebar-divider {
margin: var(--su-space-2) 0;
border-top: 1px dashed var(--su-border-strong);
}
/* 首页卡片:左侧卡片列表 + 右侧分组表格 */
.cards-manager {
display: grid;
+36 -11
View File
@@ -208,6 +208,8 @@ export default function AppNext() {
const site: SiteSettings = settings?.settings ?? {
header: { enabled: true, speed: 24, items: [] },
sidebar: {
show_profile: true,
show_service: true,
hidden: [],
order: [],
hidden_groups: [],
@@ -309,6 +311,30 @@ export default function AppNext() {
)
}
function renderLocalService() {
const items: AppItem[] = projects.map((project) => ({
id: project.name,
name: project.name,
desc: project.route_path,
icon: project.name.slice(0, 1).toUpperCase(),
href: project.route_path,
badge: '本地项目',
comment: '本地静态站点,由 Stash 直接托管。',
views: 0,
likes: 0,
}))
return (
<SectionCard title="本地服务" count={items.length} barColor="#3858f6">
{items.length > 0 ? (
<AppGrid items={items} />
) : (
<Empty text="还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。" />
)}
</SectionCard>
)
}
function renderCard(card: CardItem) {
const resolved = card.groups
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
@@ -327,7 +353,6 @@ export default function AppNext() {
title={card.title}
count={total}
barColor={first?.barColor}
onMore={() => first !== undefined && navigate(`/g/${encodeURIComponent(first.key)}`)}
>
{resolved.map(({ group, ref }) =>
renderGroupBlock(group, ref.previewLimit ?? defaultLimit(group), resolved.length > 1),
@@ -342,6 +367,7 @@ export default function AppNext() {
<div className="home-view">
{loading && <Empty text="正在读取内容库…" />}
{!loading && cards.length === 0 && <Empty text="后台还没有创建首页卡片" />}
{renderLocalService()}
{cards.map(renderCard)}
{keywordLower !== '' && cards.length > 0 && (
<Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice>
@@ -521,9 +547,6 @@ export default function AppNext() {
siderBordered={false}
contentPadding="none"
contentOffset={88}
height="screen"
scrollable
hideScrollbar
contentId="content-scroll"
className="app"
header={
@@ -546,13 +569,15 @@ export default function AppNext() {
}
sider={
<div className="sidebar">
<ProfileCard profile={site.profile} />
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
/>
{site.sidebar.show_profile !== false && <ProfileCard profile={site.profile} />}
{site.sidebar.show_service !== false && (
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
/>
)}
<div className="nav-card">
<Menu
items={sidebarItems}
+122 -4
View File
@@ -12,6 +12,7 @@ import {
Space,
Switch,
Tabs,
Tag,
Text,
Textarea,
Title,
@@ -47,6 +48,8 @@ import {
type GroupNode,
} from '../../manage'
import { createItem, deleteItem, fetchItem, saveItem } from '../../items'
import { useProjects } from '../../useProjects'
import type { Project } from '../../api'
/**
* 新版后台:原子模型。
@@ -96,6 +99,7 @@ export function AdminNext() {
const [cards, setCards] = useState<CardItem[]>([])
const [tab, setTab] = useState<TabKey>('groups')
const [saving, setSaving] = useState(false)
const { projects } = useProjects()
const [pending, setPending] = useState<{
kind: 'group' | 'card'
id: string
@@ -257,6 +261,14 @@ export function AdminNext() {
else hidden.add(key)
void saveSidebar({ ...draft.sidebar, tree_hidden: [...hidden] })
}}
onToggleCard={(card, visible) => {
void saveSidebar({
...draft.sidebar,
...(card === 'profile'
? { show_profile: visible }
: { show_service: visible }),
})
}}
/>
)}
@@ -264,6 +276,7 @@ export function AdminNext() {
<CardsPanel
groups={groups}
cards={cards}
projects={projects}
onReload={load}
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })}
/>
@@ -1190,11 +1203,13 @@ function SidebarPanel({
groups,
value,
onToggle,
onToggleCard,
saving,
}: {
groups: GroupNode[]
value: SiteSettings['sidebar']
onToggle: (key: string, visible: boolean) => void
onToggleCard: (card: 'profile' | 'service', visible: boolean) => void
saving: boolean
}) {
const hidden = new Set(value.tree_hidden)
@@ -1219,7 +1234,38 @@ function SidebarPanel({
return (
<Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
勾选要在侧边栏显示的分组;子分组可以单独勾选,父分组不勾选也不影响子项。开关修改后立即保存。
侧栏卡片与分组导航分开控制;开关修改后立即保存。
</Text>
<Text size="sm" strong>
侧栏卡片
</Text>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">本地管理员</Text>
<Switch
checked={value.show_profile !== false}
onChange={(visible) => onToggleCard('profile', visible)}
label="显示本地管理员卡片"
disabled={saving}
/>
</div>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">本地服务</Text>
<Switch
checked={value.show_service !== false}
onChange={(visible) => onToggleCard('service', visible)}
label="显示本地服务卡片"
disabled={saving}
/>
</div>
<div className="sidebar-divider" />
<Text size="sm" strong>
分组导航
</Text>
<Text size="sm" tone="muted">
勾选要在侧边栏显示的分组;子分组可以单独勾选,父分组不勾选也不影响子项。
</Text>
{render(groups, 0)}
</Space>
@@ -1231,16 +1277,19 @@ function SidebarPanel({
function CardsPanel({
groups,
cards,
projects,
onReload,
onDelete,
}: {
groups: GroupNode[]
cards: CardItem[]
projects: Project[]
onReload: () => void
onDelete: (id: number, title: string) => void
}) {
const options = useMemo(() => flattenCardsGroups(groups), [groups])
const [activeId, setActiveId] = useState<number | null>(cards[0]?.id ?? null)
const [view, setView] = useState<'local' | 'card'>('local')
const [pendingId, setPendingId] = useState<number | null>(null)
const [newOpen, setNewOpen] = useState(false)
const [newTitle, setNewTitle] = useState('')
@@ -1256,6 +1305,7 @@ function CardsPanel({
return
}
if (pendingId !== null && cards.some((card) => card.id === pendingId)) {
setView('card')
setActiveId(pendingId)
setPendingId(null)
return
@@ -1328,6 +1378,15 @@ function CardsPanel({
</div>
<div className="cards-side-list">
<button
type="button"
className={`cards-side-item${view === 'local' ? ' is-active' : ''}`}
onClick={() => setView('local')}
>
<span className="cards-side-title">本地服务</span>
<span className="cards-side-count">{projects.length}</span>
</button>
{cards.map((card) => (
<button
key={card.id}
@@ -1335,13 +1394,16 @@ function CardsPanel({
draggable={!reordering}
className={[
'cards-side-item',
active?.id === card.id ? 'is-active' : '',
view === 'card' && active?.id === card.id ? 'is-active' : '',
dragId === card.id ? 'is-dragging' : '',
overId === card.id && dragId !== null && dragId !== card.id ? 'is-over' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setActiveId(card.id)}
onClick={() => {
setView('card')
setActiveId(card.id)
}}
onDragStart={(event) => {
setDragId(card.id)
event.dataTransfer.effectAllowed = 'move'
@@ -1382,7 +1444,9 @@ function CardsPanel({
</aside>
<div className="cards-main">
{active === undefined ? (
{view === 'local' ? (
<LocalServiceTable projects={projects} />
) : active === undefined ? (
<Text tone="muted">新建一张卡片开始配置。</Text>
) : (
<CardTable
@@ -1435,6 +1499,60 @@ function CardsPanel({
)
}
function LocalServiceTable({ projects }: { projects: Project[] }) {
return (
<div>
<div className="cards-table-head">
<div className="admin-node-title">
<Input value="本地服务" disabled aria-label="本地服务卡片" />
</div>
<Tag tone="neutral">不可删除</Tag>
</div>
<Text size="sm" tone="muted">
本地服务卡片由托管目录自动生成,随项目扫描更新;后台只能查看,不能删除或改名。
</Text>
<div className="group-table-wrap cards-table-wrap">
<table className="group-table cards-table">
<thead>
<tr>
<th>项目名称</th>
<th className="entry-col-href">访问地址</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.name}>
<td>
<span className="group-name">{project.name}</span>
</td>
<td>
<a
className="entry-href"
href={project.route_path}
target="_blank"
rel="noreferrer"
>
{project.route_path}
</a>
</td>
</tr>
))}
{projects.length === 0 && (
<tr>
<td colSpan={2} className="group-empty">
还没有托管项目
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
function CardTable({
card,
options,
+6
View File
@@ -39,6 +39,10 @@ export type SiteSettings = {
items: HeaderItem[]
}
sidebar: {
/** 是否显示「本地管理员」资料卡 */
show_profile: boolean
/** 是否显示「本地服务」面板 */
show_service: boolean
hidden: string[]
order: string[]
/** 隐藏的分组:`分区id/分组id` */
@@ -84,6 +88,8 @@ export function defaultSettings(): SiteSettings {
],
},
sidebar: {
show_profile: true,
show_service: true,
hidden: [],
order: [],
hidden_groups: [],