refactor: 清理遗留设置模型与无用代码
- 删除 settings 的 sections 与 sidebar 六个旧排序/显隐字段,前后端类型与校验同步 - 删除旧分类模型 types、旧 /api/catalog 客户端、旧路由解析与 settings CRUD - 删除未使用的 AppProject::initial - 删除 27 条已无组件的 CSS 规则(159 行) - 保持现有首页/接口页渲染,cargo test 52 passed
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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: '管理员',
|
||||
|
||||
+1
-63
@@ -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<Project[]> {
|
||||
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<Catalog> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-172
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* 内容数据模型。
|
||||
*
|
||||
* 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
|
||||
* 因此这里用可辨识联合把三种展示形态分开。
|
||||
* 内容数据模型:应用 / 海报 / 图文三种条目,以及详情页的推荐条目。
|
||||
*/
|
||||
|
||||
/** 一个可点击的应用 / 站点条目 */
|
||||
@@ -30,17 +27,6 @@ export type AppItem = {
|
||||
likes?: number
|
||||
}
|
||||
|
||||
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
|
||||
export type ContentGroup<T> = {
|
||||
id: string
|
||||
name: string
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
items: T[]
|
||||
}
|
||||
|
||||
export type AppGroup = ContentGroup<AppItem>
|
||||
|
||||
/** 竖版海报条目(电影) */
|
||||
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<PosterItem>[]
|
||||
/** 直接挂在分区上的条目(未分组) */
|
||||
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<ArticleItem>[]
|
||||
/** 直接挂在分区上的条目(未分组) */
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+3
-259
@@ -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<string, string>
|
||||
limits: Record<string, number>
|
||||
}
|
||||
|
||||
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<string, string[]>
|
||||
/** 新模型:侧栏隐藏的分组 key */
|
||||
tree_hidden: string[]
|
||||
/** 新模型:每级分组的顺序,键为父分组 key(根级用空串) */
|
||||
tree_order: Record<string, string[]>
|
||||
}
|
||||
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<SettingsDocument> {
|
||||
export async function saveSettings(
|
||||
version: number,
|
||||
settings: SiteSettings,
|
||||
): Promise<SettingsResult> { try {
|
||||
): Promise<SettingsResult> {
|
||||
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<T extends { id: string }>(
|
||||
categories: T[],
|
||||
sidebar: SiteSettings['sidebar'],
|
||||
): T[] {
|
||||
return orderAndFilter(categories, sidebar)
|
||||
}
|
||||
|
||||
/** 首页卡片:过滤隐藏、按 order 排序(未列出的保持原顺序) */
|
||||
export function applySectionSettings<T extends { id: string }>(
|
||||
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<T extends { id: string }>(
|
||||
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<T extends { id: string }>( 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<SimpleResult> {
|
||||
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<SimpleResult> {
|
||||
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<SimpleResult> {
|
||||
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<SimpleResult> {
|
||||
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<SimpleResult> {
|
||||
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<SimpleResult> {
|
||||
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 : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user