From 165d3f112ee68ae5b95f5fe7626a4da8b824f207 Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 30 Oct 2026 20:45:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(front):=20=E5=89=8D=E5=8F=B0=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=88=B0=E5=88=86=E7=BB=84=E6=A0=91=20+=20=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E7=BB=84=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 AppNext:首页按 /api/catalog2 的卡片勾选渲染(支持混合格式、每组条数),侧栏按分组树 + tree_hidden 渲染 - 分组页 /g/、条目页 /g//i/;条目在子树内递归查找,面包屑显示祖先链 - SPA 回落增加 /g/ 前缀;旧 /c/... 地址兼容 - 文档同步(路由、API、v19、限制) - 测试 59 passed;Playwright 验证首页/混合卡片/分组页/条目页/侧栏隐藏 --- HANDOFF.md | 45 +-- README.md | 10 +- docs/ADMIN-PLAN.md | 8 + src/server.rs | 2 +- stash-web/src/App.css | 27 ++ stash-web/src/AppNext.tsx | 572 ++++++++++++++++++++++++++++++++++++++ stash-web/src/main.tsx | 4 +- 7 files changed, 641 insertions(+), 27 deletions(-) create mode 100644 stash-web/src/AppNext.tsx diff --git a/HANDOFF.md b/HANDOFF.md index c6e85c5..d6b1ab5 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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. 新会话建议的下一步 diff --git a/README.md b/README.md index 492444a..05c3dd0 100644 --- a/README.md +++ b/README.md @@ -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`,两者不再混用)。 diff --git a/docs/ADMIN-PLAN.md b/docs/ADMIN-PLAN.md index a1ed26c..a567631 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -147,6 +147,14 @@ DELETE /api/items/:id ## 11. 实施记录 +### P9(2026-10-30,前台切换) + +- **前台**:`AppNext` 改为读 `/api/catalog2`,首页按 `card` 勾选的分组渲染(可混合格式,每组条数独立),侧栏按分组树 + `tree_hidden` 渲染,分组页 `/g/`、条目页 `/g//i/`; +- **详情**:条目在分组子树内递归查找,面包屑显示完整祖先链;编辑仍用 Drawer + `PATCH /api/items`; +- **服务端**:SPA 回落增加 `/g/` 前缀; +- **兼容**:旧 `/c/...` 地址按第一段作为分组 key 跳转;旧 `/api/catalog`、旧后台组件暂时保留; +- 验证:首页卡片、混合格式卡片(应用 + 海报)、侧栏隐藏指定分组、分组页/条目页均通过。 + ### P8(2026-10-30,子分组开关) - **与类型解耦**:分区新增 `allow_groups` 开关(迁移 v18);三种类型共用同一模板,开启后可建分组,关闭后平铺; diff --git a/src/server.rs b/src/server.rs index e9e8c0a..bc1a1ff 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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"]; diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 6b69a50..0e6eeae 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -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; diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx new file mode 100644 index 0000000..e5ee81c --- /dev/null +++ b/stash-web/src/AppNext.tsx @@ -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(() => 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 } | undefined { + const direct = group.entries.find((candidate) => candidate.id === entryId) + if (direct !== undefined) { + return { group, entry: direct as unknown as Record } + } + 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 } | 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[] { + return [ + ...(group.entries as unknown as Record[]), + ...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, 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({ groups: [], cards: [] }) + const [loading, setLoading] = useState(true) + const [keyword, setKeyword] = useState('') + const [session, setSession] = useState(null) + const [settings, setSettings] = useState(null) + const [loginOpen, setLoginOpen] = useState(false) + const [editingKey, setEditingKey] = useState(null) + const [scrolled, setScrolled] = useState(false) + const searchRef = useRef(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[], + 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 + } + if (group.format === 'articles') { + return + } + return + } + + function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) { + const all = collectEntries(group).filter((entry) => matches(entry, keywordLower)) + if (all.length === 0) return null + return ( +
+ {showTitle &&

{group.title}

} + {renderEntries(group, all, { limit })} +
+ ) + } + + 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 ( + first !== undefined && navigate(`/g/${encodeURIComponent(first.key)}`)} + > + {resolved.map(({ group, ref }) => + renderGroupBlock(group, ref.previewLimit ?? defaultLimit(group), resolved.length > 1), + )} + + ) + } + + function renderHome() { + const cards = manage.cards + return ( +
+ {loading && } + {!loading && cards.length === 0 && } + {cards.map(renderCard)} + {keywordLower !== '' && cards.length > 0 && ( + 在卡片与分组内按标题过滤。 + )} +
+ ) + } + + function renderGroupPage(key: string) { + const group = findGroup(manage.groups, key) + if (group === undefined) { + return + } + + const trail = findPath(manage.groups, key) ?? [group] + + return ( +
+ + +
+ +

{group.title}

+ [{groupEntryCount(group)}] +
+ + {group.entries.length > 0 ? ( + renderEntries(group, group.entries) + ) : group.children.some((child) => groupEntryCount(child) > 0) ? null : ( + + )} + + + {group.children.map((child) => ( +
+
+
+ {child.entries.length > 0 ? ( + renderEntries(child, child.entries, { limit: 12 }) + ) : ( + + )} +
+ ))} +
+
+ ) + } + + function renderItemPage(key: string, itemId: string) { + const found = findEntry(manage.groups, key, itemId) + if (found === undefined) { + return + } + + 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 ( + 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 ( + navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(next)}`)} + onEdit={() => setEditingKey(String(entry.id))} + canEdit={session?.authenticated === true} + /> + ) + } + + return ( + <> + + setLoginOpen(false)} + onSuccess={(next) => { + setSession(next) + setLoginOpen(false) + }} + /> + + setEditingKey(null)} + onSaved={() => { + setEditingKey(null) + load() + }} + /> + +