feat(front): 前台切换到分组树 + 卡片组合
- 新 AppNext:首页按 /api/catalog2 的卡片勾选渲染(支持混合格式、每组条数),侧栏按分组树 + tree_hidden 渲染 - 分组页 /g/<key>、条目页 /g/<key>/i/<id>;条目在子树内递归查找,面包屑显示祖先链 - SPA 回落增加 /g/ 前缀;旧 /c/... 地址兼容 - 文档同步(路由、API、v19、限制) - 测试 59 passed;Playwright 验证首页/混合卡片/分组页/条目页/侧栏隐藏
This commit is contained in:
+27
-18
@@ -94,18 +94,18 @@ README.md 运行、路由、库结构、登录说明
|
||||
- 会话:随机 32 字节 token,HttpOnly + SameSite=Strict + Path=/,7 天,库里存 sha256;
|
||||
- 登录失败 5 次锁 60 秒;写接口校验 Origin;写接口需要会话。
|
||||
|
||||
### `/admin`
|
||||
### `/admin`(新原子模型)
|
||||
| Tab | 能力 |
|
||||
| --- | --- |
|
||||
| 页头滚动信息 | 启用开关、速度 8–120、条目增删/排序、标签与色调、占位符 `{count}` `{project}` `{status}` |
|
||||
| 首页卡片 | 显示/隐藏、标题覆盖、预览条数、顺序、**新增分类**、**删除分类**(不可删的显示灰色按钮) |
|
||||
| 内容 | 弹窗新增应用/海报/图文条目(选分区;开启子分组的分区可选分组或「直属分区」);列表逐条编辑(含换分区/分组)与删除 |
|
||||
| 分区 | 分区类型(应用/海报/图文)、标题、图标、色条、预览条数、**「子分组」开关(拨动即保存)**;开关打开时管理分组与首页卡片预览 |
|
||||
| 侧栏导航 | 分类显示/隐藏、顺序、删除/新增;开启子分组的分区可显隐/排序/新建分组;空分组不出现在侧栏 |
|
||||
| 资料 | 头像 URL、名称、角色、简介、位置(应用到侧栏资料卡) |
|
||||
| 系统 | 修改密码(清空所有会话)、下载备份(VACUUM INTO 快照) |
|
||||
| 分组管理 | 多层分组树:新建根/子分组、改名、根分组格式(应用/海报/图文)、删除(级联子分组与条目) |
|
||||
| 内容 | 选分组新增/编辑/删除条目;条目只属于一个分组 |
|
||||
| 侧边栏 | 分组树逐项勾选(`settings.sidebar.tree_hidden`),子分组可单独勾选 |
|
||||
| 首页卡片 | 新建/改名/删除卡片;勾选若干分组(可混合格式),每组单独设条数 |
|
||||
| 页头 | 启用开关、速度、条目、标签与色调 |
|
||||
| 资料 | 头像 URL、名称、角色、简介、位置 |
|
||||
| 系统 | 修改密码、下载备份 |
|
||||
|
||||
保存:页头 / 首页卡片 / 资料走 `PATCH /api/settings`(`version` 乐观锁,冲突 409);「内容」「分区」逐项保存(条目、分区、分组各自接口);成功用 Toast(2.4s 自动消失)。
|
||||
保存:分组/内容/卡片逐项保存;侧边栏、页头、资料走 `PATCH /api/settings`(`version` 乐观锁)。
|
||||
|
||||
### 内容编辑
|
||||
- 详情页 `[编辑]` 只在登录后显示;
|
||||
@@ -124,7 +124,16 @@ README.md 运行、路由、库结构、登录说明
|
||||
```
|
||||
GET /api/health
|
||||
GET /api/projects GET /api/projects/events (SSE)
|
||||
GET /api/catalog GET /api/catalog/events (SSE)
|
||||
GET /api/catalog GET /api/catalog/events (SSE) 旧结构(兼容)
|
||||
GET /api/catalog2 分组树 + 卡片(新前台/后台)
|
||||
GET /api/cards 卡片列表
|
||||
POST /api/nodes {key,title,parentKey?,format?} 需登录
|
||||
PATCH /api/nodes/{key} {title,parentKey?,sort?,format?} 需登录
|
||||
DELETE /api/nodes/{key} 需登录,级联
|
||||
POST /api/cards {title} 需登录
|
||||
PATCH /api/cards/{id} {title?,sort?} 需登录
|
||||
DELETE /api/cards/{id} 需登录
|
||||
PUT /api/cards/{id}/groups [{groupKey,previewLimit}] 需登录
|
||||
|
||||
GET /api/auth/session -> {authenticated, has_admin, username}
|
||||
POST /api/auth/setup {password} 仅当无管理员
|
||||
@@ -155,8 +164,8 @@ GET /api/backup SQLite 快照附件 需登录
|
||||
## 5. 数据库与迁移
|
||||
|
||||
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
|
||||
- 当前 `PRAGMA user_version = 18`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18、更高版本拒绝启动。
|
||||
- `node.allow_groups` 是「允许子分组」开关(与类型无关);老库升级时应用类默认开启,其余默认关闭,已有分组的非应用分区自动开启。
|
||||
- 当前 `PRAGMA user_version = 19`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19、更高版本拒绝启动。
|
||||
- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`card` / `card_group` 是首页卡片的展示组合(卡片不拥有内容)。
|
||||
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
|
||||
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
|
||||
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
|
||||
@@ -192,13 +201,13 @@ GET /api/backup SQLite 快照附件 需登录
|
||||
| 项 | 状态 |
|
||||
| --- | --- |
|
||||
| 备份恢复/导入 | 只提供下载快照;恢复需停机替换 `stash.db` |
|
||||
| 回收站/软删除 | 未做,删除分类会级联删条目(有二次确认) |
|
||||
| 回收站/软删除 | 未做,删除分组会级联删子分组与条目(有二次确认) |
|
||||
| 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 |
|
||||
| 拖拽排序 | 用上移/下移按钮替代 |
|
||||
| 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 |
|
||||
| 多用户/角色 | 未做,当前单管理员 |
|
||||
| 分区编辑 UI | 已支持标题/图标/色条/类型/预览条数(`PATCH /api/categories/{id}`);改类型会自动迁移条目 |
|
||||
| 条目移动 | 编辑弹窗里可直接换分区 / 分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
|
||||
| 分组管理 | 由分区的「子分组」开关控制(与类型无关);关闭时条目平铺、分组内容不丢;分组拖拽排序未做 |
|
||||
| 分组移动 UI | 后端 `PATCH /api/nodes/{key}` 支持 `parentKey` 移动与防环;后台暂未暴露移动入口 |
|
||||
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
|
||||
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 |
|
||||
| 登录限流 | 进程内计数,重启清空 |
|
||||
|
||||
## 10. 约定与坑
|
||||
@@ -209,7 +218,7 @@ GET /api/backup SQLite 快照附件 需登录
|
||||
- 改前端后必须 `npm run build` 再 `cargo build`,否则内嵌资源是旧的。
|
||||
- 重启前先杀 `stash.exe`,否则 cargo 报「拒绝访问」。
|
||||
- `/login` 是旧地址,`main.tsx` 会重定向到 `/`;登录已改为弹窗。
|
||||
- 前端路由:`/`、`/c/<分区>`、`/c/<分区>/<分组>`、`/c/<分区>/i/<条目>`,`/admin` 独立壳。
|
||||
- 前端路由:`/`、`/g/<分组 key>`、`/g/<分组 key>/i/<条目>`;旧 `/c/...` 地址按第一个路径段当分组 key 兼容,`/admin` 独立壳。
|
||||
|
||||
## 11. 新会话建议的下一步
|
||||
|
||||
|
||||
@@ -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 天);
|
||||
@@ -65,11 +65,9 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
||||
|
||||
| 地址 | 页面 |
|
||||
| --- | --- |
|
||||
| `/` | 首页分区卡片 |
|
||||
| `/c/<分区>` | 分类明细 |
|
||||
| `/c/<分区>/<分组>` | 只展示该分组 |
|
||||
| `/c/<分区>/i/<条目>` | 条目详情,返回 → 分类明细 |
|
||||
| `/c/<分区>/<分组>/i/<条目>` | 条目详情,返回 → 该分组 |
|
||||
| `/` | 首页卡片(按后台勾选的分组组合) |
|
||||
| `/g/<分组>` | 分组页(自身内容 + 子分组) |
|
||||
| `/g/<分组>/i/<条目>` | 条目详情 |
|
||||
|
||||
地址里的每一段都是**业务键**(也就是接口 JSON 里的 `id`):库里是 `node.key` / `entry.key`,
|
||||
本地项目则是目录名(访问地址仍然是 `route_path`,两者不再混用)。
|
||||
|
||||
@@ -147,6 +147,14 @@ DELETE /api/items/:id
|
||||
|
||||
## 11. 实施记录
|
||||
|
||||
### P9(2026-10-30,前台切换)
|
||||
|
||||
- **前台**:`AppNext` 改为读 `/api/catalog2`,首页按 `card` 勾选的分组渲染(可混合格式,每组条数独立),侧栏按分组树 + `tree_hidden` 渲染,分组页 `/g/<key>`、条目页 `/g/<key>/i/<id>`;
|
||||
- **详情**:条目在分组子树内递归查找,面包屑显示完整祖先链;编辑仍用 Drawer + `PATCH /api/items`;
|
||||
- **服务端**:SPA 回落增加 `/g/` 前缀;
|
||||
- **兼容**:旧 `/c/...` 地址按第一段作为分组 key 跳转;旧 `/api/catalog`、旧后台组件暂时保留;
|
||||
- 验证:首页卡片、混合格式卡片(应用 + 海报)、侧栏隐藏指定分组、分组页/条目页均通过。
|
||||
|
||||
### P8(2026-10-30,子分组开关)
|
||||
|
||||
- **与类型解耦**:分区新增 `allow_groups` 开关(迁移 v18);三种类型共用同一模板,开启后可建分组,关闭后平铺;
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ fn not_found_page(path: &str) -> Response {
|
||||
/// 只接管明确列出的前缀,托管目录里写错的路径仍然返回 404 页面:
|
||||
/// 把所有未命中都冒充成 200 的「万能 SPA 回落」,会让人分不清
|
||||
/// 到底是路由拼错了还是文件真的不存在。
|
||||
const SPA_PREFIXES: &[&str] = &["/c/"];
|
||||
const SPA_PREFIXES: &[&str] = &["/c/", "/g/"];
|
||||
|
||||
/// 精确匹配的前端页面(没有实体文件,返回入口页)
|
||||
const SPA_PAGES: &[&str] = &["/login", "/admin"];
|
||||
|
||||
@@ -336,6 +336,33 @@
|
||||
background-color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
/* 新前台:卡片内的分组块 / 分组页层级 */
|
||||
.card-group-block + .card-group-block {
|
||||
margin-top: var(--su-space-4);
|
||||
}
|
||||
|
||||
.card-group-title {
|
||||
margin: 0 0 var(--su-space-2);
|
||||
font-size: var(--su-font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.group-crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--su-space-2) var(--su-space-3);
|
||||
}
|
||||
|
||||
.group-crumb-sep {
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.group-child-block {
|
||||
margin-top: var(--su-space-2);
|
||||
}
|
||||
|
||||
/* 明细页的分组标题:与首页卡片标题同一套「色条 + 标题」 */
|
||||
.group-title {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
import {
|
||||
Empty,
|
||||
Layout,
|
||||
Menu,
|
||||
Notice,
|
||||
Space,
|
||||
Toaster,
|
||||
type MenuItem,
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BackToTop } from './components/common/BackToTop'
|
||||
import { ArticleDetailView } from './components/content/ArticleDetailView'
|
||||
import { AppGrid } from './components/content/AppGrid'
|
||||
import { CesCardGrid } from './components/content/CesCardGrid'
|
||||
import { ItemDetailView } from './components/content/ItemDetailView'
|
||||
import { ItemEditor } from './components/content/ItemEditor'
|
||||
import { PosterGrid } from './components/content/PosterGrid'
|
||||
import { SectionCard } from './components/content/SectionCard'
|
||||
import { SiteHeader } from './components/header/SiteHeader'
|
||||
import { SearchBar } from './components/search/SearchBar'
|
||||
import { LoginDialog } from './components/auth/LoginDialog'
|
||||
import { ProfileCard } from './components/sidebar/ProfileCard'
|
||||
import { ServicePanel } from './components/sidebar/ServicePanel'
|
||||
import { useProjects } from './useProjects'
|
||||
import { fetchSession, logout, type SessionState } from './auth'
|
||||
import {
|
||||
fetchSettings,
|
||||
type SettingsDocument,
|
||||
type SiteSettings,
|
||||
} from './settings'
|
||||
import {
|
||||
appDetailModel,
|
||||
posterDetailModel,
|
||||
posterToRelatedItem,
|
||||
toRelatedItem,
|
||||
type ItemDetailModel,
|
||||
} from './data/detail'
|
||||
import type { AppItem, ArticleItem, PosterItem, RelatedItem } from './data/types'
|
||||
import { fetchManage, type CardItem, type GroupNode, type ManageData } from './manage'
|
||||
import './App.css'
|
||||
|
||||
/**
|
||||
* 新前台:分组树 + 卡片组合。
|
||||
*
|
||||
* 路由:
|
||||
* / 首页(卡片按勾选分组渲染)
|
||||
* /g/<分组 key> 分组页(自身条目 + 子分组)
|
||||
* /g/<分组 key>/i/<条目> 条目详情
|
||||
* 旧 /c/... 地址按第一个路径段当成分组 key,兼容老书签。
|
||||
*/
|
||||
type Route =
|
||||
| { kind: 'home' }
|
||||
| { kind: 'group'; key: string }
|
||||
| { kind: 'item'; key: string; itemId: string }
|
||||
|
||||
function parseRoute(pathname: string): Route {
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
if (
|
||||
(segments[0] === 'g' || segments[0] === 'c') &&
|
||||
segments[1] !== undefined
|
||||
) {
|
||||
if (segments[2] === 'i' && segments[3] !== undefined) {
|
||||
return { kind: 'item', key: decodeURIComponent(segments[1]), itemId: decodeURIComponent(segments[3]) }
|
||||
}
|
||||
return { kind: 'group', key: decodeURIComponent(segments[1]) }
|
||||
}
|
||||
return { kind: 'home' }
|
||||
}
|
||||
|
||||
function useRoute() {
|
||||
const [route, setRoute] = useState<Route>(() => parseRoute(window.location.pathname))
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setRoute(parseRoute(window.location.pathname))
|
||||
window.addEventListener('popstate', sync)
|
||||
return () => window.removeEventListener('popstate', sync)
|
||||
}, [])
|
||||
|
||||
const navigate = (path: string) => {
|
||||
if (window.location.pathname === path) return
|
||||
window.history.pushState({}, '', path)
|
||||
setRoute(parseRoute(path))
|
||||
document.getElementById('content-scroll')?.scrollTo({ top: 0 })
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
return { route, navigate }
|
||||
}
|
||||
|
||||
function findGroup(groups: GroupNode[], key: string): GroupNode | undefined {
|
||||
for (const group of groups) {
|
||||
if (group.key === key) return group
|
||||
const found = findGroup(group.children, key)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findPath(groups: GroupNode[], key: string, trail: GroupNode[] = []): GroupNode[] | undefined {
|
||||
for (const group of groups) {
|
||||
const next = [...trail, group]
|
||||
if (group.key === key) return next
|
||||
const found = findPath(group.children, key, next)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findEntryInGroup(
|
||||
group: GroupNode,
|
||||
entryId: string,
|
||||
): { group: GroupNode; entry: Record<string, unknown> } | undefined {
|
||||
const direct = group.entries.find((candidate) => candidate.id === entryId)
|
||||
if (direct !== undefined) {
|
||||
return { group, entry: direct as unknown as Record<string, unknown> }
|
||||
}
|
||||
for (const child of group.children) {
|
||||
const found = findEntryInGroup(child, entryId)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findEntry(
|
||||
groups: GroupNode[],
|
||||
groupKey: string,
|
||||
entryId: string,
|
||||
): { group: GroupNode; entry: Record<string, unknown> } | undefined {
|
||||
const group = findGroup(groups, groupKey)
|
||||
if (group === undefined) return undefined
|
||||
return findEntryInGroup(group, entryId)
|
||||
}
|
||||
|
||||
function groupEntryCount(group: GroupNode): number {
|
||||
return group.entries.length + group.children.reduce((total, child) => total + groupEntryCount(child), 0)
|
||||
}
|
||||
|
||||
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
|
||||
function collectEntries(group: GroupNode): Record<string, unknown>[] {
|
||||
return [
|
||||
...(group.entries as unknown as Record<string, unknown>[]),
|
||||
...group.children.flatMap((child) => collectEntries(child)),
|
||||
]
|
||||
}
|
||||
|
||||
function defaultLimit(group: GroupNode): number {
|
||||
const fallback = group.format === 'apps' ? 4 : group.format === 'posters' ? 6 : 4
|
||||
return group.previewLimit ?? fallback
|
||||
}
|
||||
|
||||
function matches(entry: Record<string, unknown>, keyword: string): boolean {
|
||||
if (keyword === '') return true
|
||||
const text = [entry.name, entry.title, entry.description, entry.desc]
|
||||
.filter((value) => typeof value === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return text.includes(keyword)
|
||||
}
|
||||
|
||||
export default function AppNext() {
|
||||
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
||||
const { route, navigate } = useRoute()
|
||||
const [manage, setManage] = useState<ManageData>({ groups: [], cards: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [session, setSession] = useState<SessionState | null>(null)
|
||||
const [settings, setSettings] = useState<SettingsDocument | null>(null)
|
||||
const [loginOpen, setLoginOpen] = useState(false)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const load = () => {
|
||||
void fetchManage().then((data) => {
|
||||
setManage(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
void fetchSession().then(setSession)
|
||||
void fetchSettings().then(setSettings)
|
||||
|
||||
const source = new EventSource('/api/catalog/events')
|
||||
source.onmessage = () => load()
|
||||
return () => source.close()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
|
||||
setScrolled(Math.max(inner, window.scrollY) > 4)
|
||||
}
|
||||
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 site: SiteSettings = settings?.settings ?? {
|
||||
header: { enabled: true, speed: 24, items: [] },
|
||||
sidebar: {
|
||||
hidden: [],
|
||||
order: [],
|
||||
hidden_groups: [],
|
||||
shown_groups: [],
|
||||
group_order: {},
|
||||
tree_hidden: [],
|
||||
tree_order: {},
|
||||
},
|
||||
sections: { hidden: [], order: [], titles: {}, limits: {} },
|
||||
profile: {
|
||||
name: '本地管理员',
|
||||
role: '管理员',
|
||||
description: '',
|
||||
location: '',
|
||||
avatar: '',
|
||||
},
|
||||
}
|
||||
|
||||
const keywordLower = keyword.trim().toLowerCase()
|
||||
|
||||
const sidebarItems: MenuItem[] = useMemo(() => {
|
||||
const hidden = new Set(site.sidebar.tree_hidden)
|
||||
|
||||
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => {
|
||||
const ranks = new Map(
|
||||
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const),
|
||||
)
|
||||
return [...nodes].sort((a, b) => {
|
||||
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER
|
||||
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
|
||||
if (left === right) return a.sort - b.sort
|
||||
return left - right
|
||||
})
|
||||
}
|
||||
|
||||
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
|
||||
sortNodes(nodes, parentKey).flatMap((node) => {
|
||||
if (hidden.has(node.key)) return build(node.children, node.key)
|
||||
const children = build(node.children, node.key)
|
||||
return [
|
||||
{
|
||||
key: `g:${node.key}`,
|
||||
label: node.title,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
|
||||
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
|
||||
|
||||
const activeKey =
|
||||
route.kind === 'home'
|
||||
? 'home'
|
||||
: route.kind === 'group'
|
||||
? `g:${route.key}`
|
||||
: `g:${route.key}`
|
||||
|
||||
function renderEntries(
|
||||
group: GroupNode,
|
||||
entries: Record<string, unknown>[],
|
||||
options: { detail?: boolean; limit?: number } = {},
|
||||
) {
|
||||
const filtered = entries.filter((entry) => matches(entry, keywordLower))
|
||||
const limit = options.limit ?? filtered.length
|
||||
const items = filtered.slice(0, limit)
|
||||
const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`)
|
||||
|
||||
if (group.format === 'posters') {
|
||||
return <PosterGrid items={items as unknown as PosterItem[]} onOpen={open} />
|
||||
}
|
||||
if (group.format === 'articles') {
|
||||
return <CesCardGrid items={items as unknown as ArticleItem[]} onOpen={open} />
|
||||
}
|
||||
return <AppGrid items={items as unknown as AppItem[]} onOpen={open} />
|
||||
}
|
||||
|
||||
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
|
||||
const all = collectEntries(group).filter((entry) => matches(entry, keywordLower))
|
||||
if (all.length === 0) return null
|
||||
return (
|
||||
<div key={group.key} className="card-group-block">
|
||||
{showTitle && <h3 className="card-group-title">{group.title}</h3>}
|
||||
{renderEntries(group, all, { limit })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCard(card: CardItem) {
|
||||
const resolved = card.groups
|
||||
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
|
||||
.filter((item): item is { ref: typeof item.ref; group: GroupNode } => item.group !== undefined)
|
||||
|
||||
const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
|
||||
const first = resolved[0]?.group
|
||||
if (resolved.length === 0 || total === 0) return null
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
key={card.id}
|
||||
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),
|
||||
)}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
function renderHome() {
|
||||
const cards = manage.cards
|
||||
return (
|
||||
<div className="home-view">
|
||||
{loading && <Empty text="正在读取内容库…" />}
|
||||
{!loading && cards.length === 0 && <Empty text="后台还没有创建首页卡片" />}
|
||||
{cards.map(renderCard)}
|
||||
{keywordLower !== '' && cards.length > 0 && (
|
||||
<Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderGroupPage(key: string) {
|
||||
const group = findGroup(manage.groups, key)
|
||||
if (group === undefined) {
|
||||
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
|
||||
}
|
||||
|
||||
const trail = findPath(manage.groups, key) ?? [group]
|
||||
|
||||
return (
|
||||
<div className="detail-view">
|
||||
<nav className="detail-breadcrumb group-crumbs">
|
||||
<button type="button" className="link-button" onClick={() => navigate('/')}>
|
||||
首页
|
||||
</button>
|
||||
{trail.map((node) => (
|
||||
<span key={node.key}>
|
||||
<span className="group-crumb-sep">/</span>
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => navigate(`/g/${encodeURIComponent(node.key)}`)}
|
||||
>
|
||||
{node.title}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="detail-hero">
|
||||
<span className="detail-hero-icon" aria-hidden="true">
|
||||
{group.icon || '•'}
|
||||
</span>
|
||||
<h2 className="detail-hero-title">{group.title}</h2>
|
||||
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
|
||||
</div>
|
||||
|
||||
{group.entries.length > 0 ? (
|
||||
renderEntries(group, group.entries)
|
||||
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
|
||||
<Empty text="这个分组还没有内容" />
|
||||
)}
|
||||
|
||||
<Space direction="column" gap="lg" align="stretch">
|
||||
{group.children.map((child) => (
|
||||
<section key={child.key} className="group-child-block">
|
||||
<div className="group-title">
|
||||
<span
|
||||
className="group-bar"
|
||||
style={{ backgroundColor: child.barColor || undefined }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3>{child.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
{child.entries.length > 0 ? (
|
||||
renderEntries(child, child.entries, { limit: 12 })
|
||||
) : (
|
||||
<Empty text="暂无内容" />
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderItemPage(key: string, itemId: string) {
|
||||
const found = findEntry(manage.groups, key, itemId)
|
||||
if (found === undefined) {
|
||||
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
|
||||
}
|
||||
|
||||
const { group, entry } = found
|
||||
const trail = findPath(manage.groups, group.key) ?? [group]
|
||||
const crumbs = [
|
||||
{ label: '首页', href: '/' },
|
||||
...trail.map((node) => ({
|
||||
label: node.title,
|
||||
href: `/g/${encodeURIComponent(node.key)}`,
|
||||
})),
|
||||
{ label: String(entry.name ?? entry.title ?? entry.id) },
|
||||
]
|
||||
|
||||
if (group.format === 'articles') {
|
||||
const article = entry as unknown as ArticleItem
|
||||
return (
|
||||
<ArticleDetailView
|
||||
article={article}
|
||||
categoryId={group.key}
|
||||
categoryTitle={group.title}
|
||||
onNavigate={navigate}
|
||||
onEdit={() => setEditingKey(String(entry.id))}
|
||||
canEdit={session?.authenticated === true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const model: ItemDetailModel =
|
||||
group.format === 'posters'
|
||||
? posterDetailModel(entry as unknown as PosterItem)
|
||||
: appDetailModel(entry as unknown as AppItem)
|
||||
|
||||
const related: RelatedItem[] =
|
||||
group.format === 'posters'
|
||||
? group.entries
|
||||
.filter((item) => item.id !== entry.id)
|
||||
.slice(0, 4)
|
||||
.map((item) => posterToRelatedItem(item as unknown as PosterItem))
|
||||
: group.entries
|
||||
.filter((item) => item.id !== entry.id)
|
||||
.slice(0, 4)
|
||||
.map((item) => toRelatedItem(item as unknown as AppItem))
|
||||
|
||||
return (
|
||||
<ItemDetailView
|
||||
itemKey={String(entry.id)}
|
||||
crumbs={crumbs}
|
||||
model={model}
|
||||
related={related}
|
||||
onNavigate={navigate}
|
||||
onOpenItem={(next) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(next)}`)}
|
||||
onEdit={() => setEditingKey(String(entry.id))}
|
||||
canEdit={session?.authenticated === true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
hasAdmin={session?.has_admin ?? true}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onSuccess={(next) => {
|
||||
setSession(next)
|
||||
setLoginOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ItemEditor
|
||||
open={editingKey !== null}
|
||||
itemKey={editingKey}
|
||||
onClose={() => setEditingKey(null)}
|
||||
onSaved={() => {
|
||||
setEditingKey(null)
|
||||
load()
|
||||
}}
|
||||
/>
|
||||
|
||||
<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={site.header.items}
|
||||
headerEnabled={site.header.enabled}
|
||||
headerSpeed={site.header.speed}
|
||||
authenticated={session?.authenticated === true}
|
||||
username={session?.username ?? undefined}
|
||||
onFocusSearch={() => searchRef.current?.focus()}
|
||||
onLogin={() => setLoginOpen(true)}
|
||||
onLogout={() => {
|
||||
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
|
||||
}}
|
||||
onSettings={() => window.location.assign('/admin')}
|
||||
/>
|
||||
}
|
||||
sider={
|
||||
<div className="sidebar">
|
||||
<ProfileCard profile={site.profile} />
|
||||
<ServicePanel
|
||||
projects={projects}
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
<div className="nav-card">
|
||||
<Menu
|
||||
items={sidebarItems}
|
||||
activeKey={activeKey}
|
||||
itemHeight={39}
|
||||
onSelect={(key) => {
|
||||
if (key === 'home') navigate('/')
|
||||
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{route.kind === 'home' && renderHome()}
|
||||
{route.kind === 'group' && renderGroupPage(route.key)}
|
||||
{route.kind === 'item' && renderItemPage(route.key, route.itemId)}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<BackToTop visible={scrolled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '@stash/ui/style.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import AppNext from './AppNext.tsx'
|
||||
import { AdminNext } from './components/admin/AdminNext'
|
||||
|
||||
/**
|
||||
@@ -23,5 +23,5 @@ if (path === '/login') {
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
|
||||
root.render(
|
||||
<StrictMode>{path === '/admin' ? <AdminNext /> : <App />}</StrictMode>,
|
||||
<StrictMode>{path === '/admin' ? <AdminNext /> : <AppNext />}</StrictMode>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user