diff --git a/HANDOFF.md b/HANDOFF.md index d8c577b..a3f822d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -77,22 +77,22 @@ src/ error.rs StashError(含 Conflict → 409) db/ mod.rs Db 门面(所有方法)+ 迁移测试 - schema.rs 建表、种子、版本迁移(0/15→16→17) + schema.rs 建表、种子、版本迁移(0 / 15→…→23) auth.rs 管理员、密码哈希、会话(token 只存 sha256) - settings.rs settings JSON(header/sidebar/sections/profile) + settings.rs settings JSON(header/sidebar/profile/site) entries.rs 条目读取/编辑、浏览数、点赞、审计 - categories.rs 分区新增/删除 - catalog.rs 查询组装(前端三种视图) + groups.rs 分组树新增/移动/排序/删除 + cards.rs 首页卡片与勾选分组 + catalog.rs 分组树 + 条目查询组装 stash-web/src/ - App.tsx 主页壳、登录弹窗、编辑器、设置应用 + AppNext.tsx 前台壳(路由、卡片、侧栏、搜索) main.tsx 入口分流(/admin 与 /login) - auth.ts / settings.ts / items.ts 前端 API 客户端 + api.ts / auth.ts / settings.ts / items.ts / manage.ts 前端 API 客户端 components/ header/SiteHeader.tsx 品牌 + 滚动信息 + 操作图标 - admin/AdminPage.tsx /admin 全部 Tab + admin/AdminNext.tsx /admin 全部 Tab auth/LoginDialog.tsx Modal 登录 - auth/PasswordInput.tsx 带显隐切换的密码框 - content/ 首页、分区、条目、文章、编辑器 + content/ 首页、条目、文章、编辑器 sidebar/ 资料卡、服务面板、导航 docs/ADMIN-PLAN.md 后台方案 + P0–P4 实施记录 + 未做项 README.md 运行、路由、库结构、登录说明 diff --git a/src/db/settings.rs b/src/db/settings.rs index 4720668..df60708 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -92,13 +92,6 @@ impl Default for SidebarSettings { show_profile: true, show_service: true, show_nav: 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(), } } } @@ -115,33 +108,6 @@ pub struct SidebarSettings { /// 是否显示「分组导航」卡片(首页 / 分组树) #[serde(default = "default_true")] pub show_nav: bool, - /// 隐藏的分类 id - pub hidden: Vec, - /// 分类显示顺序(id);未列出的排在后面,保持原有顺序 - pub order: Vec, - /// 隐藏的分组,键为 `分区id/分组id` - pub hidden_groups: Vec, - /// 强制显示的分组(覆盖数据库里的 show_in_sidebar=0) - pub shown_groups: Vec, - /// 每个分区下分组的顺序:分区 id → 分组 id 列表 - pub group_order: std::collections::HashMap>, - /// 新模型:侧栏隐藏的分组 key(任意层级) - pub tree_hidden: Vec, - /// 新模型:每级分组的顺序,键是父分组 key(根级用空串) - pub tree_order: std::collections::HashMap>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(default)] -pub struct SectionsSettings { - /// 隐藏的首页卡片(分类 id) - pub hidden: Vec, - /// 卡片顺序(分类 id) - pub order: Vec, - /// 标题覆盖:id → 标题 - pub titles: std::collections::HashMap, - /// 预览条数覆盖:id → 条数 - pub limits: std::collections::HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -254,7 +220,6 @@ impl Default for SiteMetaSettings { pub struct Settings { pub header: HeaderSettings, pub sidebar: SidebarSettings, - pub sections: SectionsSettings, pub profile: ProfileSettings, pub site: SiteMetaSettings, } @@ -298,33 +263,6 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { } } } - if settings.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 { - return Err(StashError::InvalidDirectory("侧栏配置过长".into())); - } - if settings.sidebar.hidden_groups.len() > 200 - || settings.sidebar.shown_groups.len() > 200 - || settings.sidebar.group_order.len() > 100 - || settings.sidebar.tree_hidden.len() > 500 - || settings.sidebar.tree_order.len() > 200 - { - return Err(StashError::InvalidDirectory("侧栏分组配置过长".into())); - } - if settings.sections.hidden.len() > 100 || settings.sections.order.len() > 100 { - return Err(StashError::InvalidDirectory("首页卡片配置过长".into())); - } - if settings.sections.titles.len() > 100 || settings.sections.limits.len() > 100 { - return Err(StashError::InvalidDirectory("首页卡片配置过长".into())); - } - for title in settings.sections.titles.values() { - if title.chars().count() > 40 { - return Err(StashError::InvalidDirectory("卡片标题最多 40 个字".into())); - } - } - for limit in settings.sections.limits.values() { - if *limit > 60 { - return Err(StashError::InvalidDirectory("卡片条数最多 60".into())); - } - } if settings.profile.name.chars().count() > 40 { return Err(StashError::InvalidDirectory("名称最多 40 个字".into())); } @@ -457,7 +395,6 @@ mod tests { let settings = Settings::default(); assert!(settings.header.enabled); assert_eq!(settings.header.items.len(), 3); - assert!(settings.sections.hidden.is_empty()); assert!(validate(&settings).is_ok()); } diff --git a/src/listing.rs b/src/listing.rs index fdf8676..c211886 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -7,17 +7,6 @@ pub struct AppProject { pub route_path: String, } -impl AppProject { - pub fn initial(&self) -> String { - self.name - .chars() - .next() - .unwrap_or('A') - .to_uppercase() - .to_string() - } -} - pub fn scan_projects(root: &Path) -> Vec { let mut projects = Vec::new(); diff --git a/stash-web/src/App.css b/stash-web/src/App.css index fa54630..8544f36 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -478,18 +478,6 @@ color: var(--su-color-primary); } -.item-cover--square { - width: 96px; - height: 96px; - border-radius: var(--su-radius-md); -} - -.item-cover--portrait { - width: 179px; - aspect-ratio: 2 / 3; - border-radius: var(--su-radius-cover); -} - .item-cover img { width: 100%; height: 100%; @@ -803,21 +791,6 @@ gap: var(--su-space-2); } - - -.admin-nav-row { - grid-template-columns: auto minmax(0, 1fr) auto; -} - -.admin-section-row { - grid-template-columns: auto minmax(0, 1fr) 96px auto; -} - -.admin-item-actions { - display: flex; - justify-content: flex-end; -} - .admin-item-row .su-switch { justify-self: start; } @@ -840,10 +813,6 @@ display: none; } - .header-info { - display: none; - } - .search-toolbar { padding: var(--su-space-5) 0 var(--su-space-6); } @@ -904,69 +873,20 @@ margin-bottom: var(--su-space-5); } -.admin-create { - margin-top: var(--su-space-4); - padding-top: var(--su-space-4); - border-top: 1px dashed var(--su-border-strong); -} - -.admin-create-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 160px auto; - gap: var(--su-space-2); - align-items: center; -} - /* Select 自身有 160px 最小宽度,网格项必须允许收缩,否则会盖住「新增」按钮 */ -.admin-create-row > * { - min-width: 0; -} /* 侧栏里的子分类(分组):缩进一层,弱化文字 */ -.admin-nav-group-row { - padding-left: var(--su-space-6); -} /* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */ -.admin-category-row { - display: grid; - grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto auto; - align-items: center; - gap: var(--su-space-2); -} - -.admin-toggle { - display: flex; - align-items: center; - gap: var(--su-space-2); - white-space: nowrap; -} /* 新后台:分组树行 / 新建行 / 卡片头,用弹性布局避免网格列数对不上 */ -.admin-node-row { - display: flex; - align-items: center; - gap: var(--su-space-2); -} .admin-node-title { flex: 1; min-width: 0; } -.admin-node-format { - width: 170px; - flex: none; -} - -.admin-new-node-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--su-space-2); -} - /* 分组管理:参考后台的树形表格 */ .group-toolbar { display: flex; @@ -1098,14 +1018,6 @@ font-weight: 500; } -.group-format { - padding: 0 var(--su-space-2); - border-radius: var(--su-radius-xs); - background-color: var(--su-bg-subtle); - color: var(--su-text-muted); - font-size: var(--su-font-size-sm); -} - @@ -1243,10 +1155,6 @@ a.entry-href:hover { /* 侧栏到内容区共用一条滚动虚化遮罩,同一水平高度 */ -.sidebar-fade.is-visible { - opacity: 1; -} - /* 左侧卡片半透明,露出页面背景 */ .sidebar-home .profile-card, .sidebar-home .service-panel, @@ -1261,11 +1169,6 @@ a.entry-href:hover { border-radius: var(--su-radius-md); } -.sidebar-divider { - margin: var(--su-space-2) 0; - border-top: 1px dashed var(--su-border-strong); -} - /* 首页卡片:左侧卡片列表 + 右侧分组表格 */ .cards-manager { display: grid; @@ -1342,16 +1245,6 @@ a.entry-href:hover { } /* 分组管理里状态为「关闭」的分组:卡片里只能看、不能勾选 */ -.group-off-tag { - display: inline-block; - padding: 0 var(--su-space-2); - border-radius: var(--su-radius-pill); - background-color: var(--su-gray-2); - color: var(--su-text-muted); - font-size: var(--su-font-size-xs); - line-height: var(--su-line-height-sm); - white-space: nowrap; -} /* 卡片表格的状态列:启用/已停用同一个胶囊,只换颜色 */ .card-state { @@ -1373,11 +1266,6 @@ a.entry-href:hover { color: var(--su-color-danger); } -.group-state-on { - font-size: var(--su-font-size-xs); - color: var(--su-text-muted); -} - .card-order-index { display: grid; place-items: center; @@ -1396,45 +1284,7 @@ a.entry-href:hover { } } -.admin-category-row > * { - min-width: 0; -} - /* 分区管理:一个分区一块,下面挂卡片预览与子分组 */ -.admin-section-block { - display: flex; - flex-direction: column; - gap: var(--su-space-3); - padding-bottom: var(--su-space-4); - border-bottom: 1px dashed var(--su-border-strong); -} - -.admin-block-row { - display: flex; - align-items: center; - gap: var(--su-space-2); -} - -.admin-groups { - display: flex; - flex-direction: column; - gap: var(--su-space-2); - padding-left: var(--su-space-4); -} - -.admin-group-row { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto; - align-items: center; - gap: var(--su-space-2); -} - -.admin-new-group-row { - display: grid; - grid-template-columns: 190px minmax(0, 1fr) auto; - align-items: center; - gap: var(--su-space-2); -} /* 内容页:新增条目表单与条目列表 */ .admin-content-form { @@ -1447,15 +1297,6 @@ a.entry-href:hover { grid-column: 1 / -1; } -.admin-entry-row { - display: grid; - grid-template-columns: minmax(0, 1fr) 140px auto auto; - align-items: center; - gap: var(--su-space-2); - padding: var(--su-space-2) 0; - border-bottom: 1px solid var(--su-border); -} - .admin-item-actions .admin-remove:disabled { diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index 9e3a2df..90c10e2 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -275,15 +275,7 @@ export default function AppNext() { show_profile: true, show_service: true, show_nav: true, - hidden: [], - order: [], - hidden_groups: [], - shown_groups: [], - group_order: {}, - tree_hidden: [], - tree_order: {}, }, - sections: { hidden: [], order: [], titles: {}, limits: {} }, profile: { name: '本地管理员', role: '管理员', diff --git a/stash-web/src/api.ts b/stash-web/src/api.ts index 71a70e3..9ba6e52 100644 --- a/stash-web/src/api.ts +++ b/stash-web/src/api.ts @@ -1,5 +1,3 @@ -import type { Category } from './data/types' - export type Project = { name: string route_path: string @@ -26,7 +24,7 @@ export async function fetchProjects(signal?: AbortSignal): Promise { return Array.isArray(payload.projects) ? payload.projects : [] } -export type ProjectEventsHandlers = { +type ProjectEventsHandlers = { onProjects: (projects: Project[]) => void /** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */ onConnectionChange?: (connected: boolean) => void @@ -68,63 +66,3 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void { source.close() } } - -// --------------------------------------------------------------------------- -// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组 -// --------------------------------------------------------------------------- - -const CATALOG_ENDPOINT = '/api/catalog' -const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events' - -type CatalogPayload = { - categories?: Category[] -} - -/** 内容目录的完整返回;侧栏由这份分类树派生 */ -export type Catalog = { - categories: Category[] -} - -/** - * 拉取完整内容目录。 - * - * 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。 - */ -export async function fetchCatalog(signal?: AbortSignal): Promise { - const response = await fetch(CATALOG_ENDPOINT, { - signal, - headers: { Accept: 'application/json' }, - }) - - if (!response.ok) { - throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`) - } - - const payload = (await response.json()) as CatalogPayload - - return { - categories: Array.isArray(payload.categories) ? payload.categories : [], - } -} - -/** - * 订阅内容库变更。 - * - * 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据, - * 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。 - */ -export function subscribeCatalog(onChange: () => void): () => void { - if (typeof EventSource === 'undefined') { - return () => {} - } - - const source = new EventSource(CATALOG_EVENTS_ENDPOINT) - const handleChange = () => onChange() - - source.addEventListener('catalog', handleChange) - - return () => { - source.removeEventListener('catalog', handleChange) - source.close() - } -} diff --git a/stash-web/src/data/types.ts b/stash-web/src/data/types.ts index dba5df5..7a7bafa 100644 --- a/stash-web/src/data/types.ts +++ b/stash-web/src/data/types.ts @@ -1,8 +1,5 @@ /** - * 内容数据模型。 - * - * 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格, - * 因此这里用可辨识联合把三种展示形态分开。 + * 内容数据模型:应用 / 海报 / 图文三种条目,以及详情页的推荐条目。 */ /** 一个可点击的应用 / 站点条目 */ @@ -30,17 +27,6 @@ export type AppItem = { likes?: number } -/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */ -export type ContentGroup = { - id: string - name: string - /** 是否出现在侧栏;缺省视为 true */ - showInSidebar?: boolean - items: T[] -} - -export type AppGroup = ContentGroup - /** 竖版海报条目(电影) */ export type PosterItem = { id: string @@ -79,160 +65,3 @@ export type ArticleItem = { body?: string } -/** 首页展示块:分区卡片展示哪个节点、显示几条 */ -export type SectionBlock = { - /** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */ - nodeKey: string - /** 块标题,缺省用节点标题 */ - title?: string - /** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */ - previewLimit?: number -} - -/** 应用类分类:内部按分组组织 */ -export type AppCategory = { - id: string - kind: 'apps' - title: string - /** 标题左侧色条颜色,各分类用不同颜色区分 */ - barColor: string - /** 明细页 hero 区的字符图标 */ - icon: string - groups: AppGroup[] - /** 直接挂在分区上的条目(未分组) */ - items?: AppItem[] - /** 首页预览条数,默认 4 */ - previewLimit?: number - /** 首页预览为空时的提示文案 */ - emptyText?: string - /** 是否出现在侧栏;缺省视为 true */ - showInSidebar?: boolean - /** 首页卡片展示哪一块内容 */ - block?: SectionBlock -} - -/** 海报类分类:竖版海报网格 */ -export type PosterCategory = { - id: string - kind: 'posters' - title: string - barColor: string - icon: string - groups: ContentGroup[] - /** 直接挂在分区上的条目(未分组) */ - items: PosterItem[] - /** 首页预览条数,默认 6 */ - previewLimit?: number - /** 是否出现在侧栏;缺省视为 true */ - showInSidebar?: boolean - /** 首页卡片展示哪一块内容 */ - block?: SectionBlock -} - -/** 图文类分类:上图下文卡片网格 */ -export type ArticleCategory = { - id: string - kind: 'articles' - title: string - barColor: string - icon: string - groups: ContentGroup[] - /** 直接挂在分区上的条目(未分组) */ - items: ArticleItem[] - /** 首页预览条数,默认 4 */ - previewLimit?: number - /** 是否出现在侧栏;缺省视为 true */ - showInSidebar?: boolean - /** 首页卡片展示哪一块内容 */ - block?: SectionBlock -} - -export type Category = AppCategory | PosterCategory | ArticleCategory - -/** 分类内的条目总数(直属 + 分组的),用于角标与明细页计数 */ -export function categoryCount(category: Category): number { - const direct = category.items?.length ?? 0 - const grouped = category.groups.reduce((total, group) => total + group.items.length, 0) - return direct + grouped -} - -/** 把应用类分类的条目拍平(分组 + 直属),首页预览用 */ -export function flattenAppItems(category: AppCategory): AppItem[] { - return [ - ...category.groups.flatMap((group) => group.items), - ...(category.items ?? []), - ] -} - -/** 按 id 在分类里查找条目;详情页用 */ -export function findAppItem(category: Category, itemId: string): AppItem | undefined { - if (category.kind !== 'apps') return undefined - - const direct = (category.items ?? []).find((item) => item.id === itemId) - if (direct !== undefined) return direct - - for (const group of category.groups) { - const found = group.items.find((item) => item.id === itemId) - if (found !== undefined) return found - } - return undefined -} - -/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */ -export function relatedAppItems( - category: Category, - excludeId: string | undefined, - limit = 4, -): AppItem[] { - if (category.kind !== 'apps') return [] - - return flattenAppItems(category) - .filter((item) => item.id !== excludeId) - .slice(0, limit) -} - -/** 按 id 查找海报条目 */ -export function findPosterItem( - category: Category, - itemId: string, -): PosterItem | undefined { - if (category.kind !== 'posters') return undefined - const direct = category.items.find((item) => item.id === itemId) - if (direct !== undefined) return direct - for (const group of category.groups) { - const found = group.items.find((item) => item.id === itemId) - if (found !== undefined) return found - } - return undefined -} - -/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */ -export function relatedPosterItems( - category: Category, - excludeId: string | undefined, - limit = 4, -): PosterItem[] { - if (category.kind !== 'posters') return [] - - return [ - ...category.items, - ...category.groups.flatMap((group) => group.items), - ] - .filter((item) => item.id !== excludeId) - .slice(0, limit) -} - -/** 按 id 查找图文条目 */ -export function findArticleItem( - category: Category, - itemId: string, -): ArticleItem | undefined { - if (category.kind !== 'articles') return undefined - const direct = category.items.find((item) => item.id === itemId) - if (direct !== undefined) return direct - for (const group of category.groups) { - const found = group.items.find((item) => item.id === itemId) - if (found !== undefined) return found - } - return undefined -} diff --git a/stash-web/src/data/view.ts b/stash-web/src/data/view.ts index 38d3989..b47c817 100644 --- a/stash-web/src/data/view.ts +++ b/stash-web/src/data/view.ts @@ -16,7 +16,7 @@ * 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」 * 仍然落回条目所属的分组,而不是去猜一个返回目标。 * - * 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。 + * 这里只做「视图 → 地址」的纯转换,读写 history 的部分在 `AppNext.tsx`。 */ export type DetailRoute = { @@ -45,15 +45,10 @@ export type View = | ItemRoute /** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */ -const CATEGORY_PREFIX = '/c/' -/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */ -const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1) +const CATEGORY_SEGMENT = 'c' /** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */ const ITEM_MARK = 'i' -/** 首页视图;未知地址一律归到这里 */ -export const HOME_VIEW: View = { type: 'home' } - /** 把视图转成地址。 */ export function viewPath(view: View): string { if (view.type === 'home') return '/' @@ -65,60 +60,7 @@ export function viewPath(view: View): string { return `/${segments.join('/')}` } -/** - * 把地址解析成视图;看不懂的地址(段数不对、百分号编码非法)按首页处理。 - * - * 这里只做形状校验,不判断分类 / 条目是否真的存在——那是数据层的事, - * 而且首屏还没拿到内容库时,它们本来就「暂时不存在」。 - */ -export function parseViewPath(pathname: string): View { - if (!pathname.startsWith(CATEGORY_PREFIX)) return HOME_VIEW - - const segments = pathname - .slice(CATEGORY_PREFIX.length) - .split('/') - .filter((segment) => segment !== '') - - const [categorySegment] = segments - if (categorySegment === undefined) return HOME_VIEW - - const categoryId = decodeSegment(categorySegment) - if (categoryId === undefined) return HOME_VIEW - - if (segments.length === 1) return { type: 'detail', categoryId } - - if (segments.length === 2) { - const groupId = decodeSegment(segments[1]) - if (groupId === undefined) return HOME_VIEW - return { type: 'detail', categoryId, groupId } - } - - if (segments.length === 3 && segments[1] === ITEM_MARK) { - const itemId = decodeSegment(segments[2]) - if (itemId === undefined) return HOME_VIEW - return { type: 'item', categoryId, itemId } - } - - if (segments.length === 4 && segments[2] === ITEM_MARK) { - const groupId = decodeSegment(segments[1]) - const itemId = decodeSegment(segments[3]) - if (groupId === undefined || itemId === undefined) return HOME_VIEW - return { type: 'item', categoryId, itemId, groupId } - } - - return HOME_VIEW -} - /** 单个路径段编码:id 里可能带斜杠、空格、中文 */ function encodeSegment(value: string): string { return encodeURIComponent(value) } - -/** 解码单个路径段;百分号编码非法时返回 undefined */ -function decodeSegment(segment: string): string | undefined { - try { - return decodeURIComponent(segment) - } catch { - return undefined - } -} diff --git a/stash-web/src/settings.ts b/stash-web/src/settings.ts index 0f43018..a4bff83 100644 --- a/stash-web/src/settings.ts +++ b/stash-web/src/settings.ts @@ -1,10 +1,9 @@ /** - * 站点设置:页头滚动信息 + 侧栏顺序/显隐。 + * 站点设置:页头滚动信息 + 侧栏卡片显隐。 * * 接口是公开读、登录后写;写的时候带 `version` 做乐观锁, * 冲突(多标签页同时改)时返回 409,页面提示刷新。 */ -import type { Category } from './data/types' export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral' @@ -14,13 +13,6 @@ export type HeaderItem = { tone: SettingsTone } -export type SectionSettings = { - hidden: string[] - order: string[] - titles: Record - limits: Record -} - export type ProfileSettings = { name: string role: string @@ -47,20 +39,7 @@ export type SiteSettings = { show_service: boolean /** 是否显示「分组导航」卡片(首页 / 分组树) */ show_nav: boolean - hidden: string[] - order: string[] - /** 隐藏的分组:`分区id/分组id` */ - hidden_groups: string[] - /** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */ - shown_groups: string[] - /** 每个分区下的分组顺序:分区 id → 分组 id 列表 */ - group_order: Record - /** 新模型:侧栏隐藏的分组 key */ - tree_hidden: string[] - /** 新模型:每级分组的顺序,键为父分组 key(根级用空串) */ - tree_order: Record } - sections: SectionSettings profile: ProfileSettings /** 站点级:标签标题 / favicon / 品牌 / 页头操作工具 */ site: SiteMetaSettings @@ -127,15 +106,7 @@ export function defaultSettings(): SiteSettings { show_profile: true, show_service: true, show_nav: true, - hidden: [], - order: [], - hidden_groups: [], - shown_groups: [], - group_order: {}, - tree_hidden: [], - tree_order: {}, }, - sections: { hidden: [], order: [], titles: {}, limits: {} }, profile: { name: '本地管理员', role: '管理员', @@ -177,7 +148,8 @@ export async function fetchSettings(): Promise { export async function saveSettings( version: number, settings: SiteSettings, -): Promise { try { +): Promise { + try { const response = await fetch('/api/settings', { method: 'PATCH', credentials: 'same-origin', @@ -203,231 +175,3 @@ export async function saveSettings( } } } - -/** - * 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。 - */export function applySidebarSettings( - categories: T[], - sidebar: SiteSettings['sidebar'], -): T[] { - return orderAndFilter(categories, sidebar) -} - -/** 首页卡片:过滤隐藏、按 order 排序(未列出的保持原顺序) */ -export function applySectionSettings( - categories: T[], - sections: SectionSettings, -): T[] { - return orderAndFilter(categories, sections) -} - -/** - * 侧栏子分类(分组):按设置过滤、排序。 - * - * 数据库里的 `show_in_sidebar` 是默认值;`shown_groups` 可以把它强行打开, - * `hidden_groups` 则再隐藏一层。 - */ -export function applySidebarGroups( - categories: Category[], - sidebar: SiteSettings['sidebar'], -): Category[] { - return categories.map((category) => { - if (category.kind === 'apps') { - return { ...category, groups: filterGroups(category.groups, category.id, sidebar) } - } - if (category.kind === 'posters') { - return { ...category, groups: filterGroups(category.groups, category.id, sidebar) } - } - return { ...category, groups: filterGroups(category.groups, category.id, sidebar) } - }) -} - -/** 按设置过滤、排序某个分区下的分组 */ -function filterGroups( - groups: T[], - categoryId: string, - sidebar: SiteSettings['sidebar'], -): T[] { - const hidden = new Set(sidebar.hidden_groups) - const shown = new Set(sidebar.shown_groups) - const ranks = new Map( - (sidebar.group_order[categoryId] ?? []).map((id, index) => [id, index] as const), - ) - - return groups - .filter((group) => { - const key = `${categoryId}/${group.id}` - const showInSidebar = (group as { showInSidebar?: boolean }).showInSidebar - return !hidden.has(key) && (showInSidebar !== false || shown.has(key)) - }) - .map((group, index) => ({ - group, - index, - rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER, - })) - .sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank)) - .map((entry) => entry.group) -} - -/** 卡片标题:设置里的覆盖值优先 */ -export function sectionTitle( - id: string, - fallback: string, - sections: SectionSettings, -): string { - const override = sections.titles[id] - return override !== undefined && override.trim() !== '' ? override : fallback -} - -/** 卡片预览条数:设置里的覆盖值优先 */ -export function sectionLimit( - id: string, - fallback: number, - sections: SectionSettings, -): number { - const override = sections.limits[id] - return typeof override === 'number' && override > 0 ? override : fallback -} - -function orderAndFilter( categories: T[], - config: { hidden: string[]; order: string[] }, -): T[] { - const hidden = new Set(config.hidden) - const ranks = new Map(config.order.map((id, index) => [id, index])) - - return categories - .filter((category) => !hidden.has(category.id)) - .map((category, index) => ({ - category, - index, - rank: ranks.get(category.id) ?? Number.MAX_SAFE_INTEGER, - })) - .sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank)) - .map((entry) => entry.category) -} - -// ---------- 分区(首页卡片 / 侧栏项)的新增与删除 ---------- - -export type NewCategory = { id: string; title: string; kind: 'apps' | 'posters' | 'articles' } - -export type SimpleResult = { ok: true } | { ok: false; error: string } - -export async function createCategory(input: NewCategory): Promise { - try { - const response = await fetch('/api/categories', { - method: 'POST', - credentials: 'same-origin', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(input), - }) - if (response.ok) return { ok: true } - const payload = (await response.json().catch(() => null)) as { error?: string } | null - return { ok: false, error: payload?.error ?? `创建失败(${response.status})` } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : '网络错误' } - } -} - -export async function deleteCategory(id: string): Promise { - try { - const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, { - method: 'DELETE', - credentials: 'same-origin', - }) - if (response.ok) return { ok: true } - const payload = (await response.json().catch(() => null)) as { error?: string } | null - return { ok: false, error: payload?.error ?? `删除失败(${response.status})` } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : '网络错误' } - } -} - -/** 修改分区(归类、标题、图标、色条、预览条数) */ -export type UpdateCategoryInput = { - title?: string - icon?: string - barColor?: string - kind?: 'apps' | 'posters' | 'articles' - previewLimit?: number - emptyText?: string - /** 首页卡片预览哪个节点(分区自己或其分组) */ - blockNodeKey?: string - /** 是否允许子分组(与类型无关的开关) */ - allowGroups?: boolean -} - -export async function updateCategory( - id: string, - patch: UpdateCategoryInput, -): Promise { - try { - const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, { - method: 'PATCH', - credentials: 'same-origin', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(patch), - }) - if (response.ok) return { ok: true } - const payload = (await response.json().catch(() => null)) as { error?: string } | null - return { ok: false, error: payload?.error ?? `保存失败(${response.status})` } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : '网络错误' } - } -} - -// ---------- 分组(子分类) ---------- - -export async function createGroup( - sectionId: string, - input: { id: string; title: string }, -): Promise { - try { - const response = await fetch( - `/api/categories/${encodeURIComponent(sectionId)}/groups`, - { - method: 'POST', - credentials: 'same-origin', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(input), - }, - ) - if (response.ok) return { ok: true } - const payload = (await response.json().catch(() => null)) as { error?: string } | null - return { ok: false, error: payload?.error ?? `创建失败(${response.status})` } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : '网络错误' } - } -} - -export async function updateGroup( - id: string, - patch: { title?: string; showInSidebar?: boolean }, -): Promise { - try { - const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, { - method: 'PATCH', - credentials: 'same-origin', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(patch), - }) - if (response.ok) return { ok: true } - const payload = (await response.json().catch(() => null)) as { error?: string } | null - return { ok: false, error: payload?.error ?? `保存失败(${response.status})` } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : '网络错误' } - } -} - -export async function deleteGroup(id: string): Promise { - try { - const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, { - method: 'DELETE', - credentials: 'same-origin', - }) - if (response.ok) return { ok: true } - const payload = (await response.json().catch(() => null)) as { error?: string } | null - return { ok: false, error: payload?.error ?? `删除失败(${response.status})` } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : '网络错误' } - } -}