feat(admin): 登录改页内弹窗 + P1 站点设置(页头/侧栏)
- 登录:LoginDialog(Modal)替代 /login 页面;/login 重定向回首页 - 设置接口:GET /api/settings 公开读、PATCH 需登录 + version 乐观锁(409/400) - setting 表存 site JSON:页头(启用/速度/条目+标签+色调)、侧栏(hidden/order) - /admin:Tabs 页头/侧栏,条目增删与上下移,保存后首页生效 - 首页应用设置:ticker 文本占位符与速度、侧栏顺序与显隐 - 测试 42 passed(settings 5 例)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。
|
||||
*
|
||||
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
|
||||
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
|
||||
*/
|
||||
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
|
||||
|
||||
export type HeaderItem = {
|
||||
text: string
|
||||
tag?: string | null
|
||||
tone: SettingsTone
|
||||
}
|
||||
|
||||
export type SiteSettings = {
|
||||
header: {
|
||||
enabled: boolean
|
||||
/** 滚动一轮的秒数(8–120) */
|
||||
speed: number
|
||||
items: HeaderItem[]
|
||||
}
|
||||
sidebar: {
|
||||
hidden: string[]
|
||||
order: string[]
|
||||
}
|
||||
}
|
||||
|
||||
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: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/** 占位符替换:`{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<SettingsDocument> {
|
||||
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<SettingsResult> {
|
||||
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<T extends { id: string }>(
|
||||
categories: T[],
|
||||
sidebar: SiteSettings['sidebar'],
|
||||
): T[] {
|
||||
const hidden = new Set(sidebar.hidden)
|
||||
const ranks = new Map(sidebar.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)
|
||||
}
|
||||
Reference in New Issue
Block a user