feat(admin): 首页卡片按侧边栏逻辑交互

- 分组勾选:标题在左、开关在右,勾选即保存,去掉每卡保存按钮
- 卡片标题与每组条数改为失焦保存
- 保留删除卡片按钮
- Playwright 验证:无保存按钮、开关右置、勾选/条数/改名即时落库
This commit is contained in:
2026-10-31 11:35:48 +08:00
parent 87b3c3b35a
commit c1a4da5458
4 changed files with 76 additions and 47 deletions
+15
View File
@@ -1281,6 +1281,21 @@ a.entry-href:hover {
min-width: 0;
}
/* 首页卡片:标题行 + 分组勾选行(开关在右) */
.card-title-row {
display: flex;
align-items: center;
gap: var(--su-space-2);
}
.card-group-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 100px auto;
align-items: center;
gap: var(--su-space-2);
padding: 2px 0;
}
.admin-category-row > * {
min-width: 0;
}
+54 -46
View File
@@ -1259,7 +1259,7 @@ function CardsPanel({
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
卡片只是展示组合:新建卡片后勾选要展示的分组(可混合格式),每张卡可单独设条数。
卡片只是展示组合:新建卡片后勾选要展示的分组(可混合格式),开关和条数修改后即时保存。
</Text>
<div className="admin-new-node-row">
@@ -1320,24 +1320,9 @@ function CardRow({
)
const [busy, setBusy] = useState(false)
const toggle = (key: string, checked: boolean) => {
setSelected((current) => {
const next = { ...current }
if (checked) next[key] = ''
else delete next[key]
return next
})
}
const save = async () => {
const persistGroups = async (nextSelected: Record<string, string>) => {
setBusy(true)
const titleResult = await updateCard(card.id, { title })
if (!titleResult.ok) {
setBusy(false)
toast.error(titleResult.error)
return
}
const groups = Object.entries(selected).map(([groupKey, limit]) => ({
const groups = Object.entries(nextSelected).map(([groupKey, limit]) => ({
groupKey,
previewLimit: limit.trim() === '' ? undefined : Number(limit),
}))
@@ -1345,25 +1330,46 @@ function CardRow({
setBusy(false)
if (!result.ok) {
toast.error(result.error)
onReload()
return
}
onReload()
}
const toggle = (key: string, checked: boolean) => {
const next = { ...selected }
if (checked) next[key] = ''
else delete next[key]
setSelected(next)
void persistGroups(next)
}
const saveTitle = async () => {
const next = title.trim()
if (next === '' || next === card.title) {
setTitle(card.title)
return
}
const result = await updateCard(card.id, { title: next })
if (!result.ok) {
toast.error(result.error)
setTitle(card.title)
return
}
toast.success('卡片已保存')
onReload()
}
return (
<div className="admin-section-block">
<div className="admin-node-row">
<div className="card-title-row">
<div className="admin-node-title">
<Input
value={title}
aria-label={`${card.title} 标题`}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
</div>
<Button variant="secondary" size="sm" loading={busy} onClick={() => void save()}>
保存
</Button>
<Button
variant="ghost"
size="sm"
@@ -1378,34 +1384,36 @@ function CardRow({
{options.map((option) => {
const checked = option.key in selected
return (
<div className="admin-item-row admin-nav-row" key={option.key}>
<div className="card-group-row" key={option.key}>
<Text
className="admin-item-title"
style={{ paddingLeft: option.depth * 18 }}
>
{option.title}
</Text>
{checked ? (
<Input
type="number"
value={selected[option.key]}
placeholder="条数"
aria-label={`${option.title} 条数`}
onChange={(event) =>
setSelected((current) => ({
...current,
[option.key]: event.target.value,
}))
}
onBlur={() => void persistGroups(selected)}
/>
) : (
<span />
)}
<Switch
checked={checked}
onChange={(next) => toggle(option.key, next)}
label={`卡片包含 ${option.title}`}
disabled={busy}
/>
<Text className="admin-item-title">
{' '.repeat(option.depth)}
{option.title}
</Text>
{checked ? (
<div style={{ width: 100 }}>
<Input
type="number"
value={selected[option.key]}
placeholder="条数"
aria-label={`${option.title} 条数`}
onChange={(event) =>
setSelected((current) => ({
...current,
[option.key]: event.target.value,
}))
}
/>
</div>
) : (
<span />
)}
</div>
)
})}