feat(admin): 跳转规范化、侧栏子分类与归类管理

- 条目保存时规范化 href(裸域名补 https,拒绝 javascript:/data: 等协议),详情按钮按解析结果置灰/跳转
- settings.sidebar 增加 hidden_groups / shown_groups / group_order,侧栏 Tab 支持分组显隐与排序
- 新增 PATCH /api/categories/{id} 与「归类」Tab:编辑类型/标题/图标/色条/条数;改类型自动迁移条目与首页卡片
- 测试 54 passed(href 规范化 3 例、分类更新 3 例)
This commit is contained in:
2026-10-30 12:33:52 +08:00
parent 00e0360c85
commit 94e16b783c
13 changed files with 891 additions and 47 deletions
+22
View File
@@ -958,6 +958,28 @@
min-width: 0;
}
/* 侧栏里的子分类(分组):缩进一层,弱化文字 */
.admin-nav-group-row {
padding-left: var(--su-space-6);
}
.admin-nav-group-row .admin-item-title {
color: var(--su-text-sub);
font-size: var(--su-font-size-sm);
}
/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */
.admin-category-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto;
align-items: center;
gap: var(--su-space-2);
}
.admin-category-row > * {
min-width: 0;
}
/* 删除按钮:可删的用危险色,不可删的用弱化色(都取主题令牌) */
.admin-item-actions .admin-remove {
color: var(--su-color-danger);
+8 -4
View File
@@ -30,6 +30,7 @@ import { useCatalog } from './useCatalog'
import { useViewRoute } from './useViewRoute'
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
import {
applySidebarGroups,
applySidebarSettings,
defaultSettings,
fetchSettings,
@@ -81,12 +82,15 @@ export default function App() {
)
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
// 再按 /admin 里的顺序与显隐设置重排一遍
// 再按 /admin 里的顺序、显隐与分组设置重排一遍
const sidebarCategories = useMemo(
() =>
applySidebarSettings(
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
applySidebarGroups(
applySidebarSettings(
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
settings.settings.sidebar,
),
settings.settings.sidebar,
),
+242 -27
View File
@@ -26,6 +26,7 @@ import {
fetchSettings,
saveSettings,
TONE_OPTIONS,
updateCategory,
type HeaderItem,
type ProfileSettings,
type SectionSettings,
@@ -40,11 +41,24 @@ import {
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
* 保存用 `version` 乐观锁,冲突时提示刷新。
*/
type CategoryGroupSummary = {
id: string
name: string
/** 数据库里的默认显隐;缺省视为显示 */
showInSidebar?: boolean
}
type CategorySummary = {
id: string
title: string
kind?: 'apps' | 'posters' | 'articles'
icon?: string
barColor?: string
previewLimit?: number
/** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */
block?: { title?: string } | null
/** 应用类分区下的分组(子分类) */
groups?: CategoryGroupSummary[]
}
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
@@ -173,6 +187,7 @@ export function AdminPage() {
items={[
{ key: 'header', label: '页头滚动信息' },
{ key: 'sections', label: '首页卡片' },
{ key: 'categories', label: '归类' },
{ key: 'sidebar', label: '侧栏导航' },
{ key: 'profile', label: '资料' },
{ key: 'system', label: '系统' },
@@ -194,6 +209,12 @@ export function AdminPage() {
onDelete={requestDelete}
onReload={load}
/>
) : tab === 'categories' ? (
<CategoryManager
categories={categories}
onReload={load}
onDelete={requestDelete}
/>
) : tab === 'sidebar' ? (
<SidebarForm
categories={categories}
@@ -211,20 +232,23 @@ export function AdminPage() {
<SystemPanel />
)}
<Space gap="sm" justify="end" className="admin-actions">
<Button
variant="secondary"
onClick={() => {
setDraft(clone(draft))
void load()
}}
>
撤销改动
</Button>
<Button loading={saving} icon={<Icon name="check_line" />} onClick={save}>
保存
</Button>
</Space>
{/* 「归类」每行自己保存,不参与整页设置的保存/撤销 */}
{tab !== 'categories' && (
<Space gap="sm" justify="end" className="admin-actions">
<Button
variant="secondary"
onClick={() => {
setDraft(clone(draft))
void load()
}}
>
撤销改动
</Button>
<Button loading={saving} icon={<Icon name="check_line" />} onClick={save}>
保存
</Button>
</Space>
)}
</>
)}
</Card>
@@ -409,28 +433,95 @@ function SidebarForm({
onChange({ ...value, hidden, order: ordered.map((category) => category.id) })
}
const orderedGroups = (category: CategorySummary) => {
const ranks = new Map(
(value.group_order[category.id] ?? []).map((id, index) => [id, index] as const),
)
return [...(category.groups ?? [])].sort((a, b) => {
const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (left === right) return 0
return left - right
})
}
const groupVisible = (category: CategorySummary, group: CategoryGroupSummary) => {
const key = `${category.id}/${group.id}`
return (
!value.hidden_groups.includes(key) &&
(group.showInSidebar !== false || value.shown_groups.includes(key))
)
}
const toggleGroup = (
category: CategorySummary,
group: CategoryGroupSummary,
visible: boolean,
) => {
const key = `${category.id}/${group.id}`
const hidden_groups = visible
? value.hidden_groups.filter((entry) => entry !== key)
: [...new Set([...value.hidden_groups, key])]
const shown_groups = visible
? [...new Set([...value.shown_groups, key])]
: value.shown_groups.filter((entry) => entry !== key)
onChange({ ...value, hidden_groups, shown_groups })
}
const moveGroup = (category: CategorySummary, index: number, step: -1 | 1) => {
const groups = orderedGroups(category)
const target = index + step
if (target < 0 || target >= groups.length) return
const ids = groups.map((group) => group.id)
const [id] = ids.splice(index, 1)
ids.splice(target, 0, id)
onChange({ ...value, group_order: { ...value.group_order, [category.id]: ids } })
}
return (
<Space direction="column" gap="sm" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。应用类分区下的子分类(分组)可以单独显隐与排序。
</Text>
{ordered.map((category, index) => {
const visible = !value.hidden.includes(category.id)
const groups = category.kind === 'apps' ? orderedGroups(category) : []
return (
<div className="admin-item-row admin-nav-row" key={category.id}>
<Switch
checked={visible}
onChange={(next) => toggle(category.id, next)}
label={`显示 ${category.title}`}
/>
<Text className="admin-item-title">{category.title}</Text>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={() => onDelete(category)}
/>
<div className="admin-nav-block" key={category.id}>
<div className="admin-item-row admin-nav-row">
<Switch
checked={visible}
onChange={(next) => toggle(category.id, next)}
label={`显示 ${category.title}`}
/>
<Text className="admin-item-title">{category.title}</Text>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={() => onDelete(category)}
/>
</div>
{groups.map((group, groupIndex) => (
<div
className="admin-item-row admin-nav-row admin-nav-group-row"
key={group.id}
>
<Switch
checked={groupVisible(category, group)}
onChange={(next) => toggleGroup(category, group, next)}
label={`显示 ${group.name}`}
/>
<Text className="admin-item-title">{group.name}</Text>
<IconButtonGroup
onUp={() => moveGroup(category, groupIndex, -1)}
onDown={() => moveGroup(category, groupIndex, 1)}
/>
</div>
))}
</div>
)
})}
@@ -618,6 +709,130 @@ function CategoryCreator({ onCreated }: { onCreated: () => void }) {
)
}
// ---------- 归类(分类管理) ----------
const KIND_OPTIONS = [
{ value: 'apps', label: '应用' },
{ value: 'posters', label: '海报' },
{ value: 'articles', label: '图文' },
]
function CategoryManager({
categories,
onReload,
onDelete,
}: {
categories: CategorySummary[]
onReload: () => void
onDelete: (card: CategorySummary) => void
}) {
return (
<Space direction="column" gap="sm" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
决定每个分区归到哪一类(应用 / 海报 / 图文),以及标题、图标、色条与预览条数。改类型会自动迁移条目:切到「应用」会建默认分组并把条目挪进去。
</Text>
{categories.map((category) => (
<CategoryRow
key={category.id}
category={category}
onReload={onReload}
onDelete={onDelete}
/>
))}
{categories.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
<CategoryCreator onCreated={onReload} />
</Space>
)
}
function CategoryRow({
category,
onReload,
onDelete,
}: {
category: CategorySummary
onReload: () => void
onDelete: (card: CategorySummary) => void
}) {
const [title, setTitle] = useState(category.title)
const [kind, setKind] = useState(category.kind ?? 'apps')
const [icon, setIcon] = useState(category.icon ?? '')
const [color, setColor] = useState(category.barColor ?? '')
const [limit, setLimit] = useState(
category.previewLimit === undefined ? '' : String(category.previewLimit),
)
const [busy, setBusy] = useState(false)
const save = async () => {
setBusy(true)
const result = await updateCategory(category.id, {
title,
kind,
icon,
barColor: color,
previewLimit: limit.trim() === '' ? undefined : Number(limit),
})
setBusy(false)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(`已保存「${title}」`)
onReload()
}
return (
<div className="admin-category-row">
<Input
value={title}
placeholder="标题"
aria-label={`${category.title} 标题`}
onChange={(event) => setTitle(event.target.value)}
/>
<Select
options={KIND_OPTIONS}
value={kind}
onChange={(next) => setKind(next as typeof kind)}
/>
<Input
value={icon}
placeholder="图标"
aria-label={`${category.title} 图标`}
onChange={(event) => setIcon(event.target.value)}
/>
<Input
value={color}
placeholder="#3858f6"
aria-label={`${category.title} 色条`}
onChange={(event) => setColor(event.target.value)}
/>
<Input
type="number"
value={limit}
placeholder="条数"
aria-label={`${category.title} 预览条数`}
onChange={(event) => setLimit(event.target.value)}
/>
<Button variant="secondary" size="sm" loading={busy} onClick={save}>
保存
</Button>
<Button
variant="ghost"
size="sm"
className="admin-remove"
icon={<Icon name="delete_2_line" />}
aria-label="删除"
onClick={() => onDelete(category)}
/>
</div>
)
}
// ---------- 资料 ----------
function ProfileForm({
@@ -13,6 +13,7 @@ import {
} from '@stash/ui'
import { useEffect, useState } from 'react'
import type { ItemDetailModel } from '../../data/detail'
import { resolveHref } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { RelatedList } from './RelatedList'
@@ -49,6 +50,8 @@ export function ItemDetailView({
}: ItemDetailViewProps) {
const [views, setViews] = useState(model.views)
const [likes, setLikes] = useState(model.likes)
// 历史数据可能是裸域名或占位符 #;统一解析成可跳转地址后再渲染
const jumpHref = resolveHref(model.href)
const [liked, setLiked] = useState(() => {
try {
return window.localStorage.getItem(`stash-liked:${itemKey}`) === '1'
@@ -202,14 +205,14 @@ export function ItemDetailView({
/>
</Space>
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
{model.href === '#' ? (
<button type="button" className="item-jump" disabled title="暂无跳转地址">
{/* 没有真实地址(空 / # / 不安全协议)时置灰,避免打开错误页面 */}
{jumpHref === undefined ? (
<button type="button" className="item-jump" disabled title="暂无跳转地址,可在编辑里添加">
跳转到
<Icon name="a-external_link_line1" />
</button>
) : (
<a className="item-jump" href={model.href} target="_blank" rel="noreferrer">
<a className="item-jump" href={jumpHref} target="_blank" rel="noreferrer">
跳转到
<Icon name="a-external_link_line1" />
</a>
@@ -188,11 +188,15 @@ export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps)
/>
</FormField>
<FormField label="跳转地址" htmlFor="edit-href">
<FormField
label="跳转地址"
htmlFor="edit-href"
hint="可填 https://、www. 开头的域名或站内路径 /foo;留空则不显示跳转"
>
<Input
id="edit-href"
value={form.href}
placeholder="https://…"
placeholder="https://… 或 /project/"
onChange={(event) => update({ href: event.target.value })}
/>
</FormField>
@@ -38,10 +38,8 @@ export function CategoryNav({
const items: MenuItem[] = [
{ key: HOME_KEY, label: '首页' },
...categories.map((category) => {
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.showInSidebar !== false)
: []
// 分组已由 App 按 /admin 的侧栏设置过滤、排序(含 show_in_sidebar 覆盖)
const groups = category.kind === 'apps' ? category.groups : []
return {
key: categoryKey(category.id),
+36 -2
View File
@@ -28,8 +28,7 @@ export type ItemDetailModel = {
}
/** 把应用条目转成详情模型 */
export function appDetailModel(item: AppItem): ItemDetailModel {
return {
export function appDetailModel(item: AppItem): ItemDetailModel { return {
title: item.name,
cover: item.cover,
shape: 'square',
@@ -73,3 +72,38 @@ export function posterToRelatedItem(item: PosterItem): RelatedItem {
cover: item.cover,
}
}
/**
* 把库里的 `href` 解析成真正可跳转的地址。
*
* 兼容历史数据里没写协议的裸域名(`www.zhihu.com` → `https://…`);
* 空值、`#` 与不安全/看不懂的协议返回 `undefined`,由页面把按钮置灰。
*/
export function resolveHref(raw: string | undefined): string | undefined {
const value = raw?.trim() ?? ''
if (value === '' || value === '#') return undefined
// 站内路径;`//host` 是协议相对地址,按外链补 https
if (value.startsWith('/') && !value.startsWith('//')) return value
if (/^https?:\/\//i.test(value)) return value
if (value.startsWith('//')) return `https:${value}`
// 其他协议(javascript: / data: / mailto: …)一律不当作跳转
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return undefined
return looksLikeDomain(value) ? `https://${value}` : undefined
}
function looksLikeDomain(value: string): boolean {
const [hostPort = ''] = value.split('/')
const [host = ''] = hostPort.split(':')
if (!host.includes('.')) return false
const parts = host.split('.')
const tld = parts.pop() ?? ''
return (
tld.length >= 2 &&
/^[a-z]+$/i.test(tld) &&
parts.every((part) => /^[a-z0-9-]+$/i.test(part))
)
}
+80 -1
View File
@@ -4,6 +4,8 @@
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
*/
import type { Category } from './data/types'
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
export type HeaderItem = {
@@ -39,6 +41,12 @@ export type SiteSettings = {
sidebar: {
hidden: string[]
order: string[]
/** 隐藏的分组:`分区id/分组id` */
hidden_groups: string[]
/** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */
shown_groups: string[]
/** 每个分区下的分组顺序:分区 id → 分组 id 列表 */
group_order: Record<string, string[]>
}
sections: SectionSettings
profile: ProfileSettings
@@ -71,7 +79,13 @@ export function defaultSettings(): SiteSettings {
{ text: '{status}', tag: '状态', tone: 'success' },
],
},
sidebar: { hidden: [], order: [] },
sidebar: {
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
@@ -151,6 +165,42 @@ export function applySectionSettings<T extends { id: string }>(
return orderAndFilter(categories, sections)
}
/**
* 侧栏子分类(分组):按设置过滤、排序。
*
* 数据库里的 `show_in_sidebar` 是默认值;`shown_groups` 可以把它强行打开,
* `hidden_groups` 则再隐藏一层。
*/
export function applySidebarGroups(
categories: Category[],
sidebar: SiteSettings['sidebar'],
): Category[] {
const hidden = new Set(sidebar.hidden_groups)
const shown = new Set(sidebar.shown_groups)
return categories.map((category) => {
if (category.kind !== 'apps') return category
const ranks = new Map(
(sidebar.group_order[category.id] ?? []).map((id, index) => [id, index] as const),
)
const groups = category.groups
.filter((group) => {
const key = `${category.id}/${group.id}`
return !hidden.has(key) && (group.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)
return { ...category, groups }
})
}
/** 卡片标题:设置里的覆盖值优先 */
export function sectionTitle(
id: string,
@@ -223,3 +273,32 @@ export async function deleteCategory(id: string): Promise<SimpleResult> {
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
}
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 : '网络错误' }
}
}