feat(admin): 新后台信息架构(分组管理/内容/侧边栏/卡片)

- /admin 切换为 AdminNext:分组管理(多层树、格式、增删改)、内容(选分组新增/编辑/删除条目)
- 侧边栏按分组树勾选,存入 settings.sidebar.tree_hidden
- 首页卡片:新建卡片、勾选分组(可混合格式)、每组条数
- 新增 manage.ts 客户端;旧 AdminPage 暂留作回退
- 测试 59 passed;Playwright 跑通 建根/子分组 → 加条目 → 侧栏勾选 → 混合卡片
This commit is contained in:
2026-10-30 20:40:31 +08:00
parent 6340e435e9
commit 2ccfb9b623
6 changed files with 1524 additions and 2 deletions
+6
View File
@@ -92,6 +92,10 @@ pub struct SidebarSettings {
pub shown_groups: Vec<String>, pub shown_groups: Vec<String>,
/// 每个分区下分组的顺序:分区 id → 分组 id 列表 /// 每个分区下分组的顺序:分区 id → 分组 id 列表
pub group_order: std::collections::HashMap<String, Vec<String>>, pub group_order: std::collections::HashMap<String, Vec<String>>,
/// 新模型:侧栏隐藏的分组 key(任意层级)
pub tree_hidden: Vec<String>,
/// 新模型:每级分组的顺序,键是父分组 key(根级用空串)
pub tree_order: std::collections::HashMap<String, Vec<String>>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -181,6 +185,8 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
if settings.sidebar.hidden_groups.len() > 200 if settings.sidebar.hidden_groups.len() > 200
|| settings.sidebar.shown_groups.len() > 200 || settings.sidebar.shown_groups.len() > 200
|| settings.sidebar.group_order.len() > 100 || settings.sidebar.group_order.len() > 100
|| settings.sidebar.tree_hidden.len() > 500
|| settings.sidebar.tree_order.len() > 200
{ {
return Err(StashError::InvalidDirectory("侧栏分组配置过长".into())); return Err(StashError::InvalidDirectory("侧栏分组配置过长".into()));
} }
+24
View File
@@ -997,6 +997,30 @@
white-space: nowrap; 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);
}
.admin-category-row > * { .admin-category-row > * {
min-width: 0; min-width: 0;
} }
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'
import '@stash/ui/style.css' import '@stash/ui/style.css'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { AdminPage } from './components/admin/AdminPage' import { AdminNext } from './components/admin/AdminNext'
/** /**
* 入口分流: * 入口分流:
@@ -23,5 +23,5 @@ if (path === '/login') {
const root = createRoot(document.getElementById('root')!) const root = createRoot(document.getElementById('root')!)
root.render( root.render(
<StrictMode>{path === '/admin' ? <AdminPage /> : <App />}</StrictMode>, <StrictMode>{path === '/admin' ? <AdminNext /> : <App />}</StrictMode>,
) )
+181
View File
@@ -0,0 +1,181 @@
/**
* 原子模型的后台接口:分组树 + 卡片。
*
* 分组是唯一容器,卡片只是「勾选分组」的展示组合;两者都通过业务键关联。
*/
export type GroupFormat = 'apps' | 'posters' | 'articles'
export type GroupEntry = {
id: string
name?: string
title?: string
}
export type GroupNode = {
key: string
parentKey?: string | null
title: string
format: GroupFormat
icon: string
barColor: string
previewLimit?: number | null
sort: number
entries: GroupEntry[]
children: GroupNode[]
}
export type CardGroupRef = {
groupKey: string
previewLimit?: number | null
}
export type CardItem = {
id: number
key: string
title: string
sort: number
groups: CardGroupRef[]
}
export type ManageData = {
groups: GroupNode[]
cards: CardItem[]
}
export type SimpleResult = { ok: true } | { ok: false; error: string }
async function errorMessage(response: Response, fallback: string): Promise<string> {
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return payload?.error ?? `${fallback}(${response.status})`
}
export async function fetchManage(): Promise<ManageData> {
try {
const response = await fetch('/api/catalog2', { credentials: 'same-origin' })
if (!response.ok) return { groups: [], cards: [] }
return (await response.json()) as ManageData
} catch {
return { groups: [], cards: [] }
}
}
export async function createNode(input: {
key: string
title: string
parentKey?: string
format?: GroupFormat
}): Promise<SimpleResult> {
try {
const response = await fetch('/api/nodes', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '创建失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateNode(
key: string,
patch: { title?: string; parentKey?: string; sort?: number; format?: GroupFormat },
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteNode(key: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/nodes/${encodeURIComponent(key)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '删除失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function createCard(title: string): Promise<{ ok: true; id: number } | { ok: false; error: string }> {
try {
const response = await fetch('/api/cards', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!response.ok) return { ok: false, error: await errorMessage(response, '创建失败') }
const payload = (await response.json()) as { id: number }
return { ok: true, id: payload.id }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function updateCard(
id: number,
patch: { title?: string; sort?: number },
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteCard(id: number): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '删除失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function setCardGroups(
id: number,
groups: CardGroupRef[],
): Promise<SimpleResult> {
try {
const response = await fetch(`/api/cards/${id}/groups`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(groups),
})
return response.ok ? { ok: true } : { ok: false, error: await errorMessage(response, '保存失败') }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
/** 把分组树拍平成下拉选项 */
export function flattenGroups(
groups: GroupNode[],
depth = 0,
): { key: string; title: string; depth: number; format: GroupFormat }[] {
return groups.flatMap((group) => [
{ key: group.key, title: group.title, depth, format: group.format },
...flattenGroups(group.children, depth + 1),
])
}
+6
View File
@@ -47,6 +47,10 @@ export type SiteSettings = {
shown_groups: string[] shown_groups: string[]
/** 每个分区下的分组顺序:分区 id → 分组 id 列表 */ /** 每个分区下的分组顺序:分区 id → 分组 id 列表 */
group_order: Record<string, string[]> group_order: Record<string, string[]>
/** 新模型:侧栏隐藏的分组 key */
tree_hidden: string[]
/** 新模型:每级分组的顺序,键为父分组 key(根级用空串) */
tree_order: Record<string, string[]>
} }
sections: SectionSettings sections: SectionSettings
profile: ProfileSettings profile: ProfileSettings
@@ -85,6 +89,8 @@ export function defaultSettings(): SiteSettings {
hidden_groups: [], hidden_groups: [],
shown_groups: [], shown_groups: [],
group_order: {}, group_order: {},
tree_hidden: [],
tree_order: {},
}, },
sections: { hidden: [], order: [], titles: {}, limits: {} }, sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: { profile: {