fix(admin): 子分组开关即时保存并立即显示分组管理

- 拨动开关即调用 PATCH allowGroups,成功后本地更新分类,无需再点保存
- 侧栏导航的分组列表同样跟随开关显示
- 失败时回滚开关状态
This commit is contained in:
2026-10-30 20:10:07 +08:00
parent 22c73c1145
commit e52db54bdc
3 changed files with 35 additions and 4 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ README.md 运行、路由、库结构、登录说明
| 页头滚动信息 | 启用开关、速度 8–120、条目增删/排序、标签与色调、占位符 `{count}` `{project}` `{status}` | | 页头滚动信息 | 启用开关、速度 8–120、条目增删/排序、标签与色调、占位符 `{count}` `{project}` `{status}` |
| 首页卡片 | 显示/隐藏、标题覆盖、预览条数、顺序、**新增分类**、**删除分类**(不可删的显示灰色按钮) | | 首页卡片 | 显示/隐藏、标题覆盖、预览条数、顺序、**新增分类**、**删除分类**(不可删的显示灰色按钮) |
| 内容 | 弹窗新增应用/海报/图文条目(选分区;开启子分组的分区可选分组或「直属分区」);列表逐条编辑(含换分区/分组)与删除 | | 内容 | 弹窗新增应用/海报/图文条目(选分区;开启子分组的分区可选分组或「直属分区」);列表逐条编辑(含换分区/分组)与删除 |
| 分区 | 分区类型(应用/海报/图文)、标题、图标、色条、预览条数、**「子分组」开关**;开关打开时管理分组与首页卡片预览 | | 分区 | 分区类型(应用/海报/图文)、标题、图标、色条、预览条数、**「子分组」开关(拨动即保存)**;开关打开时管理分组与首页卡片预览 |
| 侧栏导航 | 分类显示/隐藏、顺序、删除/新增;开启子分组的分区可显隐/排序/新建分组;空分组不出现在侧栏 | | 侧栏导航 | 分类显示/隐藏、顺序、删除/新增;开启子分组的分区可显隐/排序/新建分组;空分组不出现在侧栏 |
| 资料 | 头像 URL、名称、角色、简介、位置(应用到侧栏资料卡) | | 资料 | 头像 URL、名称、角色、简介、位置(应用到侧栏资料卡) |
| 系统 | 修改密码(清空所有会话)、下载备份(VACUUM INTO 快照) | | 系统 | 修改密码(清空所有会话)、下载备份(VACUUM INTO 快照) |
+1 -1
View File
@@ -152,7 +152,7 @@ DELETE /api/items/:id
- **与类型解耦**:分区新增 `allow_groups` 开关(迁移 v18);三种类型共用同一模板,开启后可建分组,关闭后平铺; - **与类型解耦**:分区新增 `allow_groups` 开关(迁移 v18);三种类型共用同一模板,开启后可建分组,关闭后平铺;
- **默认值**:老库升级时应用类开启、海报/图文关闭;已有分组的非应用分区自动开启;新建分区按类型给默认值,之后可随时改; - **默认值**:老库升级时应用类开启、海报/图文关闭;已有分组的非应用分区自动开启;新建分区按类型给默认值,之后可随时改;
- **平铺兼容**:关闭开关时读取会把(历史)分组内条目一起平铺,内容不丢;首页卡片若指向分组会自动挪回分区; - **平铺兼容**:关闭开关时读取会把(历史)分组内条目一起平铺,内容不丢;首页卡片若指向分组会自动挪回分区;
- **后台**:「分区」每行新增「子分组」开关;「内容」弹窗的分组选择随开关出现;「侧栏导航」的建组入口同理;空分组不在侧栏显示; - **后台**:「分区」每行新增「子分组」开关,**拨动即保存并立刻出现/收起分组管理**;「内容」弹窗的分组选择随开关出现;「侧栏导航」的建组入口同理;空分组不在侧栏显示;
- 验证:推荐电影(海报)开启开关 → 建组 → 加海报 → 详情按组显示;关闭 → 恢复平铺且条目仍在;`cargo test` 59 passed。 - 验证:推荐电影(海报)开启开关 → 建组 → 加海报 → 详情按组显示;关闭 → 恢复平铺且条目仍在;`cargo test` 59 passed。
### P7(2026-10-30,统一内容模型) ### P7(2026-10-30,统一内容模型)
+33 -2
View File
@@ -130,6 +130,14 @@ export function AdminPage() {
const requestDelete = (card: CategorySummary) => setPendingDelete(card) const requestDelete = (card: CategorySummary) => setPendingDelete(card)
/** 只更新本地分类缓存(不改 draft),用于「子分组」开关即时生效 */
const patchCategory = (id: string, patch: Partial<CategorySummary>) =>
setCategories((current) =>
current.map((category) =>
category.id === id ? { ...category, ...patch } : category,
),
)
const confirmDelete = async () => { const confirmDelete = async () => {
if (pendingDelete === null) return if (pendingDelete === null) return
setDeleting(true) setDeleting(true)
@@ -232,6 +240,7 @@ export function AdminPage() {
categories={categories} categories={categories}
onReload={load} onReload={load}
onDelete={requestDelete} onDelete={requestDelete}
onToggleGroups={(id, allow) => patchCategory(id, { allowGroups: allow })}
/> />
) : tab === 'sidebar' ? ( ) : tab === 'sidebar' ? (
<SidebarForm <SidebarForm
@@ -505,7 +514,7 @@ function SidebarForm({
{ordered.map((category, index) => { {ordered.map((category, index) => {
const visible = !value.hidden.includes(category.id) const visible = !value.hidden.includes(category.id)
const groups = orderedGroups(category) const groups = category.allowGroups === true ? orderedGroups(category) : []
return ( return (
<div className="admin-nav-block" key={category.id}> <div className="admin-nav-block" key={category.id}>
@@ -745,10 +754,12 @@ function CategoryManager({
categories, categories,
onReload, onReload,
onDelete, onDelete,
onToggleGroups,
}: { }: {
categories: CategorySummary[] categories: CategorySummary[]
onReload: () => void onReload: () => void
onDelete: (card: CategorySummary) => void onDelete: (card: CategorySummary) => void
onToggleGroups: (id: string, allow: boolean) => void
}) { }) {
return ( return (
<Space direction="column" gap="lg" align="stretch" className="admin-form"> <Space direction="column" gap="lg" align="stretch" className="admin-form">
@@ -764,6 +775,7 @@ function CategoryManager({
category={category} category={category}
onReload={onReload} onReload={onReload}
onDelete={onDelete} onDelete={onDelete}
onToggleGroups={onToggleGroups}
/> />
{category.allowGroups === true && ( {category.allowGroups === true && (
<> <>
@@ -785,10 +797,12 @@ function CategoryRow({
category, category,
onReload, onReload,
onDelete, onDelete,
onToggleGroups,
}: { }: {
category: CategorySummary category: CategorySummary
onReload: () => void onReload: () => void
onDelete: (card: CategorySummary) => void onDelete: (card: CategorySummary) => void
onToggleGroups: (id: string, allow: boolean) => void
}) { }) {
const [title, setTitle] = useState(category.title) const [title, setTitle] = useState(category.title)
const [kind, setKind] = useState(category.kind ?? 'apps') const [kind, setKind] = useState(category.kind ?? 'apps')
@@ -800,6 +814,23 @@ function CategoryRow({
const [allowGroups, setAllowGroups] = useState(category.allowGroups === true) const [allowGroups, setAllowGroups] = useState(category.allowGroups === true)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
/** 子分组开关即时保存:切完立刻出现 / 收起分组管理 */
const toggleGroups = async (next: boolean) => {
setAllowGroups(next)
setBusy(true)
const result = await updateCategory(category.id, { allowGroups: next })
setBusy(false)
if (!result.ok) {
setAllowGroups(!next)
toast.error(result.error)
return
}
onToggleGroups(category.id, next)
toast.success(next ? '已开启子分组' : '已关闭子分组')
}
const save = async () => { const save = async () => {
setBusy(true) setBusy(true)
const result = await updateCategory(category.id, { const result = await updateCategory(category.id, {
@@ -856,7 +887,7 @@ function CategoryRow({
<div className="admin-toggle"> <div className="admin-toggle">
<Switch <Switch
checked={allowGroups} checked={allowGroups}
onChange={setAllowGroups} onChange={(next) => void toggleGroups(next)}
label={`${category.title} 允许子分组`} label={`${category.title} 允许子分组`}
/> />
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">