feat(admin): 卡片新建弹窗、标题保存/删除与拖动排序
- 新建卡片改为弹窗输入标题,确认新建/取消关闭,创建后自动选中 - 卡片标题右侧增加「保存」「删除」按钮 - 左侧卡片支持拖拽排序,顺序写入 card.sort 并影响首页卡片顺序 - 标签栏与内容增加间距,卡片列表加拖动手柄与提示 - Playwright 验证:弹窗取消/确认、按钮、拖动后 API 顺序变化
This commit is contained in:
+1
-1
@@ -100,7 +100,7 @@ README.md 运行、路由、库结构、登录说明
|
|||||||
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
||||||
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 |
|
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 |
|
||||||
| 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 |
|
| 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 |
|
||||||
| 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片即新增一张表并自动选中;表格内开关/条数即时保存,标题失焦保存 |
|
| 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片弹窗输入标题;卡片可拖动排序(影响首页顺序);表格内开关/条数即时保存,标题用「保存」按钮 |
|
||||||
| 页头 | 启用开关、速度、条目、标签与色调 |
|
| 页头 | 启用开关、速度、条目、标签与色调 |
|
||||||
| 资料 | 头像 URL、名称、角色、简介、位置 |
|
| 资料 | 头像 URL、名称、角色、简介、位置 |
|
||||||
| 系统 | 修改密码、下载备份 |
|
| 系统 | 修改密码、下载备份 |
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user