feat(admin): 首页卡片改为左侧列表 + 右侧分组表格
- 左侧卡片列表切换,右侧当前卡片的分组表格(分组/条数/展示) - 新建卡片即新建一张表,创建后自动选中 - 表格内开关与条数即时保存,标题失焦保存 - 去掉纵向堆叠与卡片级保存按钮 - Playwright 验证:切换、创建、开关落库
This commit is contained in:
@@ -1240,63 +1240,109 @@ function CardsPanel({
|
||||
onDelete: (id: number, title: string) => void
|
||||
}) {
|
||||
const options = useMemo(() => flattenCardsGroups(groups), [groups])
|
||||
const [title, setTitle] = useState('')
|
||||
const [activeId, setActiveId] = useState<number | null>(cards[0]?.id ?? null)
|
||||
const [pendingId, setPendingId] = useState<number | null>(null)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (cards.length === 0) {
|
||||
setActiveId(null)
|
||||
setPendingId(null)
|
||||
return
|
||||
}
|
||||
if (pendingId !== null && cards.some((card) => card.id === pendingId)) {
|
||||
setActiveId(pendingId)
|
||||
setPendingId(null)
|
||||
return
|
||||
}
|
||||
if (activeId === null || !cards.some((card) => card.id === activeId)) {
|
||||
setActiveId(cards[0].id)
|
||||
}
|
||||
}, [cards, activeId, pendingId])
|
||||
|
||||
const active = cards.find((card) => card.id === activeId) ?? cards[0]
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true)
|
||||
const result = await createCard(title.trim())
|
||||
const result = await createCard(newTitle.trim())
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
setTitle('')
|
||||
setNewTitle('')
|
||||
setPendingId(result.id)
|
||||
toast.success('卡片已创建')
|
||||
onReload()
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||
<Text size="sm" tone="muted">
|
||||
卡片只是展示组合:新建卡片后勾选要展示的分组(可混合格式),开关和条数修改后即时保存。
|
||||
</Text>
|
||||
|
||||
<div className="admin-new-node-row">
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<div className="cards-manager">
|
||||
<aside className="cards-side">
|
||||
<Text size="sm" tone="muted">
|
||||
卡片
|
||||
</Text>
|
||||
<div className="cards-side-new">
|
||||
<Input
|
||||
value={title}
|
||||
value={newTitle}
|
||||
placeholder="新卡片标题"
|
||||
aria-label="新卡片标题"
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && newTitle.trim() !== '') void create()
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<Icon name="add_line" />}
|
||||
loading={busy}
|
||||
disabled={newTitle.trim() === ''}
|
||||
aria-label="新建卡片"
|
||||
onClick={() => void create()}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Icon name="add_line" />}
|
||||
loading={busy}
|
||||
disabled={title.trim() === ''}
|
||||
onClick={() => void create()}
|
||||
>
|
||||
新建卡片
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{cards.map((card) => (
|
||||
<CardRow
|
||||
key={card.id}
|
||||
card={card}
|
||||
options={options}
|
||||
onReload={onReload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
<div className="cards-side-list">
|
||||
{cards.map((card) => (
|
||||
<button
|
||||
key={card.id}
|
||||
type="button"
|
||||
className={`cards-side-item${active?.id === card.id ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveId(card.id)}
|
||||
>
|
||||
<span className="cards-side-title">{card.title}</span>
|
||||
<span className="cards-side-count">{card.groups.length}</span>
|
||||
</button>
|
||||
))}
|
||||
{cards.length === 0 && (
|
||||
<Text size="sm" tone="muted">
|
||||
还没有卡片,先新建一张。
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="cards-main">
|
||||
{active === undefined ? (
|
||||
<Text tone="muted">新建一张卡片开始配置。</Text>
|
||||
) : (
|
||||
<CardTable
|
||||
key={active.id}
|
||||
card={active}
|
||||
options={options}
|
||||
onReload={onReload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardRow({
|
||||
function CardTable({
|
||||
card,
|
||||
options,
|
||||
onReload,
|
||||
@@ -1360,8 +1406,8 @@ function CardRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-section-block">
|
||||
<div className="card-title-row">
|
||||
<div>
|
||||
<div className="cards-table-head">
|
||||
<div className="admin-node-title">
|
||||
<Input
|
||||
value={title}
|
||||
@@ -1380,43 +1426,61 @@ function CardRow({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginLeft: 18 }}>
|
||||
{options.map((option) => {
|
||||
const checked = option.key in selected
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Text size="sm" tone="muted">
|
||||
开关和条数修改后即时保存;条数留空使用默认。
|
||||
</Text>
|
||||
|
||||
<div className="group-table-wrap cards-table-wrap">
|
||||
<table className="group-table cards-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分组名称</th>
|
||||
<th className="cards-col-limit">预览条数</th>
|
||||
<th className="group-col-status">展示</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{options.map((option) => {
|
||||
const checked = option.key in selected
|
||||
return (
|
||||
<tr key={option.key}>
|
||||
<td>
|
||||
<div style={{ paddingLeft: option.depth * 18 }}>
|
||||
<span className="group-name">{option.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{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 className="entry-href entry-href-empty">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(next) => toggle(option.key, next)}
|
||||
label={`卡片包含 ${option.title}`}
|
||||
disabled={busy}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user