feat(admin): 卡片新建弹窗、标题保存/删除与拖动排序

- 新建卡片改为弹窗输入标题,确认新建/取消关闭,创建后自动选中
- 卡片标题右侧增加「保存」「删除」按钮
- 左侧卡片支持拖拽排序,顺序写入 card.sort 并影响首页卡片顺序
- 标签栏与内容增加间距,卡片列表加拖动手柄与提示
- Playwright 验证:弹窗取消/确认、按钮、拖动后 API 顺序变化
This commit is contained in:
2026-10-31 13:57:34 +08:00
parent 2c126b410c
commit 8d094175fd
4 changed files with 198 additions and 61 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ README.md 运行、路由、库结构、登录说明
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 | | 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 | | 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 |
| 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 | | 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 |
| 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片即新增一张表并自动选中;表格内开关/条数即时保存,标题失焦保存 | | 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片弹窗输入标题;卡片可拖动排序(影响首页顺序);表格内开关/条数即时保存,标题用「保存」按钮 |
| 页头 | 启用开关、速度、条目、标签与色调 | | 页头 | 启用开关、速度、条目、标签与色调 |
| 资料 | 头像 URL、名称、角色、简介、位置 | | 资料 | 头像 URL、名称、角色、简介、位置 |
| 系统 | 修改密码、下载备份 | | 系统 | 修改密码、下载备份 |
+8
View File
@@ -147,6 +147,14 @@ DELETE /api/items/:id
## 11. 实施记录 ## 11. 实施记录
### P15(2026-10-31,卡片新建弹窗、标题保存/删除、拖动排序)
- 新建卡片改为弹窗:输入标题 → 确认新建 / 取消关闭;创建后自动选中新表;
- 卡片标题右侧增加「保存」和「删除」按钮,标题按 Enter 或保存按钮提交;
- 左侧卡片支持 HTML5 拖动排序,拖放后按顺序写入 `card.sort`,刷新后首页卡片顺序一致;
- 标签栏与内容之间补 20px 间距;左侧卡片增加拖动手柄和提示;
- 验证:弹窗取消/确认、保存/删除按钮、拖动 `sadf`/新卡到首位后 `/api/catalog2` 顺序变化。
### P14(2026-10-31,首页卡片改为侧栏 + 表格) ### P14(2026-10-31,首页卡片改为侧栏 + 表格)
- 首页卡片不再纵向堆叠:左侧是卡片列表(名称 + 已选分组数),右侧是当前卡片的分组表格(分组名称 / 预览条数 / 展示开关); - 首页卡片不再纵向堆叠:左侧是卡片列表(名称 + 已选分组数),右侧是当前卡片的分组表格(分组名称 / 预览条数 / 展示开关);
+29 -1
View File
@@ -979,6 +979,7 @@
.admin-tabs { .admin-tabs {
overflow: visible; overflow: visible;
margin-bottom: var(--su-space-5);
} }
.admin-create { .admin-create {
@@ -1287,6 +1288,7 @@ a.entry-href:hover {
grid-template-columns: 220px minmax(0, 1fr); grid-template-columns: 220px minmax(0, 1fr);
gap: var(--su-space-4); gap: var(--su-space-4);
align-items: start; align-items: start;
margin-top: var(--su-space-4);
} }
.cards-side { .cards-side {
@@ -1304,6 +1306,10 @@ a.entry-href:hover {
margin: var(--su-space-2) 0 var(--su-space-3); margin: var(--su-space-2) 0 var(--su-space-3);
} }
.cards-side-new > button {
width: 100%;
}
.cards-side-list { .cards-side-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1313,7 +1319,6 @@ a.entry-href:hover {
.cards-side-item { .cards-side-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--su-space-2); gap: var(--su-space-2);
padding: var(--su-space-2) var(--su-space-3); padding: var(--su-space-2) var(--su-space-3);
border: none; border: none;
@@ -1325,6 +1330,23 @@ a.entry-href:hover {
cursor: pointer; cursor: pointer;
} }
.cards-drag-handle {
flex: none;
color: var(--su-text-placeholder);
font-size: var(--su-font-size-sm);
line-height: 1;
cursor: grab;
}
.cards-side-item.is-dragging {
opacity: 0.45;
}
.cards-side-item.is-over {
outline: 1px dashed var(--su-color-primary);
outline-offset: -1px;
}
.cards-side-item:hover { .cards-side-item:hover {
background-color: var(--su-bg-card); background-color: var(--su-bg-card);
} }
@@ -1336,6 +1358,7 @@ a.entry-href:hover {
} }
.cards-side-title { .cards-side-title {
flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -1347,6 +1370,11 @@ a.entry-href:hover {
font-weight: 400; font-weight: 400;
} }
.cards-drag-hint {
display: block;
margin-top: var(--su-space-3);
}
.cards-main { .cards-main {
min-width: 0; min-width: 0;
} }
+160 -59
View File
@@ -1242,8 +1242,12 @@ function CardsPanel({
const options = useMemo(() => flattenCardsGroups(groups), [groups]) const options = useMemo(() => flattenCardsGroups(groups), [groups])
const [activeId, setActiveId] = useState<number | null>(cards[0]?.id ?? null) const [activeId, setActiveId] = useState<number | null>(cards[0]?.id ?? null)
const [pendingId, setPendingId] = useState<number | null>(null) const [pendingId, setPendingId] = useState<number | null>(null)
const [newOpen, setNewOpen] = useState(false)
const [newTitle, setNewTitle] = useState('') const [newTitle, setNewTitle] = useState('')
const [busy, setBusy] = useState(false) const [newBusy, setNewBusy] = useState(false)
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
const [reordering, setReordering] = useState(false)
useEffect(() => { useEffect(() => {
if (cards.length === 0) { if (cards.length === 0) {
@@ -1264,81 +1268,170 @@ function CardsPanel({
const active = cards.find((card) => card.id === activeId) ?? cards[0] const active = cards.find((card) => card.id === activeId) ?? cards[0]
const create = async () => { const create = async () => {
setBusy(true) setNewBusy(true)
const result = await createCard(newTitle.trim()) const result = await createCard(newTitle.trim())
setBusy(false) setNewBusy(false)
if (!result.ok) { if (!result.ok) {
toast.error(result.error) toast.error(result.error)
return return
} }
setNewTitle('') setNewTitle('')
setNewOpen(false)
setPendingId(result.id) setPendingId(result.id)
toast.success('卡片已创建') toast.success('卡片已创建')
onReload() onReload()
} }
const reorder = async (targetId: number) => {
const from = dragId
setDragId(null)
setOverId(null)
if (from === null || from === targetId) return
const ids = cards.map((card) => card.id)
const start = ids.indexOf(from)
if (start < 0) return
ids.splice(start, 1)
const to = ids.indexOf(targetId)
ids.splice(to, 0, from)
setReordering(true)
const results = await Promise.all(ids.map((id, index) => updateCard(id, { sort: index })))
setReordering(false)
if (results.some((result) => !result.ok)) {
toast.error('排序保存失败')
onReload()
return
}
toast.success('顺序已保存')
onReload()
}
return ( return (
<div className="cards-manager"> <>
<aside className="cards-side"> <div className="cards-manager">
<Text size="sm" tone="muted"> <aside className="cards-side">
卡片 <Text size="sm" tone="muted">
</Text> 卡片
<div className="cards-side-new"> </Text>
<div className="cards-side-new">
<Button
size="sm"
icon={<Icon name="add_line" />}
onClick={() => {
setNewTitle('')
setNewOpen(true)
}}
>
新建卡片
</Button>
</div>
<div className="cards-side-list">
{cards.map((card) => (
<button
key={card.id}
type="button"
draggable={!reordering}
className={[
'cards-side-item',
active?.id === card.id ? 'is-active' : '',
dragId === card.id ? 'is-dragging' : '',
overId === card.id && dragId !== null && dragId !== card.id ? 'is-over' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setActiveId(card.id)}
onDragStart={(event) => {
setDragId(card.id)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(card.id))
}}
onDragOver={(event) => {
event.preventDefault()
if (overId !== card.id) setOverId(card.id)
}}
onDrop={(event) => {
event.preventDefault()
void reorder(card.id)
}}
onDragEnd={() => {
setDragId(null)
setOverId(null)
}}
>
<span className="cards-drag-handle" aria-hidden="true">
⋮⋮
</span>
<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>
{cards.length > 1 && (
<Text size="sm" tone="muted" className="cards-drag-hint">
拖动卡片可调整首页顺序
</Text>
)}
</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>
<Modal
open={newOpen}
onClose={() => {
if (!newBusy) setNewOpen(false)
}}
title="新建卡片"
size="sm"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={() => setNewOpen(false)}>
取消
</Button>
<Button
loading={newBusy}
disabled={newTitle.trim() === ''}
icon={<Icon name="check_line" />}
onClick={() => void create()}
>
确认新建
</Button>
</Space>
}
>
<FormField label="卡片标题" htmlFor="new-card-title">
<Input <Input
id="new-card-title"
value={newTitle} value={newTitle}
placeholder="新卡片标题" placeholder="如 常用推荐"
aria-label="新卡片标题"
onChange={(event) => setNewTitle(event.target.value)} onChange={(event) => setNewTitle(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && newTitle.trim() !== '') void create() if (event.key === 'Enter' && newTitle.trim() !== '') void create()
}} }}
/> />
<Button </FormField>
size="sm" </Modal>
icon={<Icon name="add_line" />} </>
loading={busy}
disabled={newTitle.trim() === ''}
aria-label="新建卡片"
onClick={() => void create()}
>
新建
</Button>
</div>
<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>
) )
} }
@@ -1402,6 +1495,7 @@ function CardTable({
setTitle(card.title) setTitle(card.title)
return return
} }
toast.success('卡片已保存')
onReload() onReload()
} }
@@ -1413,9 +1507,14 @@ function CardTable({
value={title} value={title}
aria-label={`${card.title} 标题`} aria-label={`${card.title} 标题`}
onChange={(event) => setTitle(event.target.value)} onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()} onKeyDown={(event) => {
if (event.key === 'Enter') void saveTitle()
}}
/> />
</div> </div>
<Button variant="secondary" size="sm" onClick={() => void saveTitle()}>
保存
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1423,7 +1522,9 @@ function CardTable({
icon={<Icon name="delete_2_line" />} icon={<Icon name="delete_2_line" />}
aria-label="删除卡片" aria-label="删除卡片"
onClick={() => onDelete(card.id, card.title)} onClick={() => onDelete(card.id, card.title)}
/> >
删除
</Button>
</div> </div>
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">