feat(admin): 新后台信息架构(分组管理/内容/侧边栏/卡片)
- /admin 切换为 AdminNext:分组管理(多层树、格式、增删改)、内容(选分组新增/编辑/删除条目) - 侧边栏按分组树勾选,存入 settings.sidebar.tree_hidden - 首页卡片:新建卡片、勾选分组(可混合格式)、每组条数 - 新增 manage.ts 客户端;旧 AdminPage 暂留作回退 - 测试 59 passed;Playwright 跑通 建根/子分组 → 加条目 → 侧栏勾选 → 混合卡片
This commit is contained in:
@@ -92,6 +92,10 @@ pub struct SidebarSettings {
|
||||
pub shown_groups: Vec<String>,
|
||||
/// 每个分区下分组的顺序:分区 id → 分组 id 列表
|
||||
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)]
|
||||
@@ -181,6 +185,8 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -997,6 +997,30 @@
|
||||
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 > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import '@stash/ui/style.css'
|
||||
import './index.css'
|
||||
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')!)
|
||||
|
||||
root.render(
|
||||
<StrictMode>{path === '/admin' ? <AdminPage /> : <App />}</StrictMode>,
|
||||
<StrictMode>{path === '/admin' ? <AdminNext /> : <App />}</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
@@ -47,6 +47,10 @@ export type SiteSettings = {
|
||||
shown_groups: string[]
|
||||
/** 每个分区下的分组顺序:分区 id → 分组 id 列表 */
|
||||
group_order: Record<string, string[]>
|
||||
/** 新模型:侧栏隐藏的分组 key */
|
||||
tree_hidden: string[]
|
||||
/** 新模型:每级分组的顺序,键为父分组 key(根级用空串) */
|
||||
tree_order: Record<string, string[]>
|
||||
}
|
||||
sections: SectionSettings
|
||||
profile: ProfileSettings
|
||||
@@ -85,6 +89,8 @@ export function defaultSettings(): SiteSettings {
|
||||
hidden_groups: [],
|
||||
shown_groups: [],
|
||||
group_order: {},
|
||||
tree_hidden: [],
|
||||
tree_order: {},
|
||||
},
|
||||
sections: { hidden: [], order: [], titles: {}, limits: {} },
|
||||
profile: {
|
||||
|
||||
Reference in New Issue
Block a user