diff --git a/HANDOFF.md b/HANDOFF.md index 9251012..7e5e4b1 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -100,7 +100,7 @@ README.md 运行、路由、库结构、登录说明 | 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 | | 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 | | 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 | -| 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片即新增一张表并自动选中;表格内开关/条数即时保存,标题失焦保存 | +| 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片弹窗输入标题;卡片可拖动排序(影响首页顺序);表格内开关/条数即时保存,标题用「保存」按钮 | | 页头 | 启用开关、速度、条目、标签与色调 | | 资料 | 头像 URL、名称、角色、简介、位置 | | 系统 | 修改密码、下载备份 | diff --git a/docs/ADMIN-PLAN.md b/docs/ADMIN-PLAN.md index 905f968..b27afed 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -147,6 +147,14 @@ DELETE /api/items/:id ## 11. 实施记录 +### P15(2026-10-31,卡片新建弹窗、标题保存/删除、拖动排序) + +- 新建卡片改为弹窗:输入标题 → 确认新建 / 取消关闭;创建后自动选中新表; +- 卡片标题右侧增加「保存」和「删除」按钮,标题按 Enter 或保存按钮提交; +- 左侧卡片支持 HTML5 拖动排序,拖放后按顺序写入 `card.sort`,刷新后首页卡片顺序一致; +- 标签栏与内容之间补 20px 间距;左侧卡片增加拖动手柄和提示; +- 验证:弹窗取消/确认、保存/删除按钮、拖动 `sadf`/新卡到首位后 `/api/catalog2` 顺序变化。 + ### P14(2026-10-31,首页卡片改为侧栏 + 表格) - 首页卡片不再纵向堆叠:左侧是卡片列表(名称 + 已选分组数),右侧是当前卡片的分组表格(分组名称 / 预览条数 / 展示开关); diff --git a/stash-web/src/App.css b/stash-web/src/App.css index ec11096..52d882e 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -979,6 +979,7 @@ .admin-tabs { overflow: visible; + margin-bottom: var(--su-space-5); } .admin-create { @@ -1287,6 +1288,7 @@ a.entry-href:hover { grid-template-columns: 220px minmax(0, 1fr); gap: var(--su-space-4); align-items: start; + margin-top: var(--su-space-4); } .cards-side { @@ -1304,6 +1306,10 @@ a.entry-href:hover { margin: var(--su-space-2) 0 var(--su-space-3); } +.cards-side-new > button { + width: 100%; +} + .cards-side-list { display: flex; flex-direction: column; @@ -1313,7 +1319,6 @@ a.entry-href:hover { .cards-side-item { display: flex; align-items: center; - justify-content: space-between; gap: var(--su-space-2); padding: var(--su-space-2) var(--su-space-3); border: none; @@ -1325,6 +1330,23 @@ a.entry-href:hover { 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 { background-color: var(--su-bg-card); } @@ -1336,6 +1358,7 @@ a.entry-href:hover { } .cards-side-title { + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1347,6 +1370,11 @@ a.entry-href:hover { font-weight: 400; } +.cards-drag-hint { + display: block; + margin-top: var(--su-space-3); +} + .cards-main { min-width: 0; } diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index de75a5e..43f3be0 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -1242,8 +1242,12 @@ function CardsPanel({ const options = useMemo(() => flattenCardsGroups(groups), [groups]) const [activeId, setActiveId] = useState(cards[0]?.id ?? null) const [pendingId, setPendingId] = useState(null) + const [newOpen, setNewOpen] = useState(false) const [newTitle, setNewTitle] = useState('') - const [busy, setBusy] = useState(false) + const [newBusy, setNewBusy] = useState(false) + const [dragId, setDragId] = useState(null) + const [overId, setOverId] = useState(null) + const [reordering, setReordering] = useState(false) useEffect(() => { if (cards.length === 0) { @@ -1264,81 +1268,170 @@ function CardsPanel({ const active = cards.find((card) => card.id === activeId) ?? cards[0] const create = async () => { - setBusy(true) + setNewBusy(true) const result = await createCard(newTitle.trim()) - setBusy(false) + setNewBusy(false) if (!result.ok) { toast.error(result.error) return } setNewTitle('') + setNewOpen(false) setPendingId(result.id) toast.success('卡片已创建') 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 ( -
- - -
- {active === undefined ? ( - 新建一张卡片开始配置。 - ) : ( - - )} -
-
+ + + ) } @@ -1402,6 +1495,7 @@ function CardTable({ setTitle(card.title) return } + toast.success('卡片已保存') onReload() } @@ -1413,9 +1507,14 @@ function CardTable({ value={title} aria-label={`${card.title} 标题`} onChange={(event) => setTitle(event.target.value)} - onBlur={() => void saveTitle()} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveTitle() + }} /> +