From 2c126b410ca1cbc9fbfbd170eb09fb6ba81062e3 Mon Sep 17 00:00:00 2001 From: CNWei Date: Sat, 31 Oct 2026 11:46:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=A6=96=E9=A1=B5=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=94=B9=E4=B8=BA=E5=B7=A6=E4=BE=A7=E5=88=97=E8=A1=A8?= =?UTF-8?q?=20+=20=E5=8F=B3=E4=BE=A7=E5=88=86=E7=BB=84=E8=A1=A8=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左侧卡片列表切换,右侧当前卡片的分组表格(分组/条数/展示) - 新建卡片即新建一张表,创建后自动选中 - 表格内开关与条数即时保存,标题失焦保存 - 去掉纵向堆叠与卡片级保存按钮 - Playwright 验证:切换、创建、开关落库 --- HANDOFF.md | 2 +- docs/ADMIN-PLAN.md | 7 + stash-web/src/App.css | 97 ++++++++- stash-web/src/components/admin/AdminNext.tsx | 210 ++++++++++++------- 4 files changed, 234 insertions(+), 82 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index cd376ae..9251012 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 23c2790..905f968 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -147,6 +147,13 @@ DELETE /api/items/:id ## 11. 实施记录 +### P14(2026-10-31,首页卡片改为侧栏 + 表格) + +- 首页卡片不再纵向堆叠:左侧是卡片列表(名称 + 已选分组数),右侧是当前卡片的分组表格(分组名称 / 预览条数 / 展示开关); +- 「新建卡片」即新增一张表,创建后自动选中;卡片标题、删除在表格上方; +- 表格内开关、条数即时保存,未选分组显示 —;侧栏卡片可一键切换; +- 验证:切换卡片表格同步、新建后自动选中、表格开关即时写入 `card_group`、无卡片级保存按钮。 + ### P13(2026-10-31,首页卡片按侧边栏逻辑) - 每张卡片的分组勾选改为与侧边栏一致:标题在左、开关在右、勾选/取消即时保存(`PUT /api/cards/{id}/groups`),去掉每张卡的「保存」按钮; diff --git a/stash-web/src/App.css b/stash-web/src/App.css index e50d727..ec11096 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -1281,19 +1281,100 @@ a.entry-href:hover { min-width: 0; } -/* 首页卡片:标题行 + 分组勾选行(开关在右) */ -.card-title-row { +/* 首页卡片:左侧卡片列表 + 右侧分组表格 */ +.cards-manager { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: var(--su-space-4); + align-items: start; +} + +.cards-side { + padding: var(--su-space-3); + border: 1px solid var(--su-card-border); + border-radius: var(--su-radius-md); + background-color: var(--su-bg-subtle); +} + +.cards-side-new { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--su-space-2); + margin: var(--su-space-2) 0 var(--su-space-3); +} + +.cards-side-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.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; + border-radius: var(--su-radius-sm); + background: transparent; + color: var(--su-text-main); + font-size: var(--su-font-size-md); + text-align: left; + cursor: pointer; +} + +.cards-side-item:hover { + background-color: var(--su-bg-card); +} + +.cards-side-item.is-active { + background-color: var(--su-bg-card); + color: var(--su-color-primary); + font-weight: 600; +} + +.cards-side-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cards-side-count { + color: var(--su-text-muted); + font-size: var(--su-font-size-sm); + font-weight: 400; +} + +.cards-main { + min-width: 0; +} + +.cards-table-head { display: flex; align-items: center; gap: var(--su-space-2); + margin-bottom: 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; +.cards-table-wrap { + margin-top: var(--su-space-2); +} + +.cards-col-limit { + width: 140px; +} + +.cards-table th, +.cards-table td { + padding: var(--su-space-2) var(--su-space-4); +} + +@media (max-width: 860px) { + .cards-manager { + grid-template-columns: 1fr; + } } .admin-category-row > * { diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index e2cf079..de75a5e 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -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(cards[0]?.id ?? null) + const [pendingId, setPendingId] = useState(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 ( - - - 卡片只是展示组合:新建卡片后勾选要展示的分组(可混合格式),开关和条数修改后即时保存。 - - -
-
+
+
- {cards.map((card) => ( - - ))} - +
+ {cards.map((card) => ( + + ))} + {cards.length === 0 && ( + + 还没有卡片,先新建一张。 + + )} +
+ + +
+ {active === undefined ? ( + 新建一张卡片开始配置。 + ) : ( + + )} +
+
) } -function CardRow({ +function CardTable({ card, options, onReload, @@ -1360,8 +1406,8 @@ function CardRow({ } return ( -
-
+
+
-
- {options.map((option) => { - const checked = option.key in selected - return ( -
- - {option.title} - - {checked ? ( - - setSelected((current) => ({ - ...current, - [option.key]: event.target.value, - })) - } - onBlur={() => void persistGroups(selected)} - /> - ) : ( - - )} - toggle(option.key, next)} - label={`卡片包含 ${option.title}`} - disabled={busy} - /> -
- ) - })} + + 开关和条数修改后即时保存;条数留空使用默认。 + + +
+ + + + + + + + + + {options.map((option) => { + const checked = option.key in selected + return ( + + + + + + ) + })} + +
分组名称预览条数展示
+
+ {option.title} +
+
+ {checked ? ( + + setSelected((current) => ({ + ...current, + [option.key]: event.target.value, + })) + } + onBlur={() => void persistGroups(selected)} + /> + ) : ( + — + )} + + toggle(option.key, next)} + label={`卡片包含 ${option.title}`} + disabled={busy} + /> +
)