/** * 站点设置:页头滚动信息 + 侧栏顺序/显隐。 * * 接口是公开读、登录后写;写的时候带 `version` 做乐观锁, * 冲突(多标签页同时改)时返回 409,页面提示刷新。 */ import type { Category } from './data/types' export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral' export type HeaderItem = { text: string tag?: string | null tone: SettingsTone } export type SectionSettings = { hidden: string[] order: string[] titles: Record limits: Record } export type ProfileSettings = { name: string role: string description: string /** 完整文案,如「IP属地:局域网」 */ location: string /** 头像地址;空表示用图标占位 */ avatar: string } export type SiteSettings = { header: { enabled: boolean /** 滚动一轮的秒数(8–120) */ speed: number items: HeaderItem[] } sidebar: { hidden: string[] order: string[] /** 隐藏的分组:`分区id/分组id` */ hidden_groups: string[] /** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */ shown_groups: string[] /** 每个分区下的分组顺序:分区 id → 分组 id 列表 */ group_order: Record } sections: SectionSettings profile: ProfileSettings } export type SettingsDocument = { version: number settings: SiteSettings } export type SettingsResult = | { ok: true; document: SettingsDocument } | { ok: false; status: number; error: string } export const TONE_OPTIONS: { value: SettingsTone; label: string }[] = [ { value: 'plain', label: '无底色' }, { value: 'neutral', label: '灰色' }, { value: 'primary', label: '品牌色' }, { value: 'success', label: '成功绿' }, ] export function defaultSettings(): SiteSettings { return { header: { enabled: true, speed: 24, items: [ { text: '共 {count} 个本地应用', tone: 'plain' }, { text: '{project}', tag: '本地', tone: 'primary' }, { text: '{status}', tag: '状态', tone: 'success' }, ], }, sidebar: { hidden: [], order: [], hidden_groups: [], shown_groups: [], group_order: {}, }, sections: { hidden: [], order: [], titles: {}, limits: {} }, profile: { name: '本地管理员', role: '管理员', description: '本地静态站点托管与预览,所有项目都运行在这台机器上。', location: 'IP属地:局域网', avatar: '', }, } } /** 占位符替换:`{count}` / `{project}` / `{status}` */ export function fillHeaderText( text: string, context: { count: number; project?: string; live: boolean }, ): string { return text .replaceAll('{count}', String(context.count)) .replaceAll('{project}', context.project ?? '—') .replaceAll('{status}', context.live ? '实时同步已连接' : '等待服务连接') } export async function fetchSettings(): Promise { try { const response = await fetch('/api/settings', { credentials: 'same-origin' }) if (!response.ok) throw new Error(String(response.status)) return (await response.json()) as SettingsDocument } catch { return { version: 0, settings: defaultSettings() } } } export async function saveSettings( version: number, settings: SiteSettings, ): Promise { try { const response = await fetch('/api/settings', { method: 'PATCH', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ version, settings }), }) if (response.ok) { return { ok: true, document: (await response.json()) as SettingsDocument } } const payload = (await response.json().catch(() => null)) as { error?: string } | null return { ok: false, status: response.status, error: payload?.error ?? `保存失败(${response.status})`, } } catch (error) { return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误', } } } /** * 按设置重排侧栏:先按 `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 } 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 : '网络错误' } } }