feat(admin): 首页卡片改为左侧列表 + 右侧分组表格
- 左侧卡片列表切换,右侧当前卡片的分组表格(分组/条数/展示) - 新建卡片即新建一张表,创建后自动选中 - 表格内开关与条数即时保存,标题失焦保存 - 去掉纵向堆叠与卡片级保存按钮 - Playwright 验证:切换、创建、开关落库
This commit is contained in:
+1
-1
@@ -100,7 +100,7 @@ README.md 运行、路由、库结构、登录说明
|
|||||||
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 |
|
||||||
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 |
|
| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 |
|
||||||
| 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 |
|
| 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 |
|
||||||
| 首页卡片 | 新建/改名/删除卡片;分组开关在右侧、勾选即保存,标题与条数失焦保存(无每卡保存按钮),可混合格式 |
|
| 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片即新增一张表并自动选中;表格内开关/条数即时保存,标题失焦保存 |
|
||||||
| 页头 | 启用开关、速度、条目、标签与色调 |
|
| 页头 | 启用开关、速度、条目、标签与色调 |
|
||||||
| 资料 | 头像 URL、名称、角色、简介、位置 |
|
| 资料 | 头像 URL、名称、角色、简介、位置 |
|
||||||
| 系统 | 修改密码、下载备份 |
|
| 系统 | 修改密码、下载备份 |
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ DELETE /api/items/:id
|
|||||||
|
|
||||||
## 11. 实施记录
|
## 11. 实施记录
|
||||||
|
|
||||||
|
### P14(2026-10-31,首页卡片改为侧栏 + 表格)
|
||||||
|
|
||||||
|
- 首页卡片不再纵向堆叠:左侧是卡片列表(名称 + 已选分组数),右侧是当前卡片的分组表格(分组名称 / 预览条数 / 展示开关);
|
||||||
|
- 「新建卡片」即新增一张表,创建后自动选中;卡片标题、删除在表格上方;
|
||||||
|
- 表格内开关、条数即时保存,未选分组显示 —;侧栏卡片可一键切换;
|
||||||
|
- 验证:切换卡片表格同步、新建后自动选中、表格开关即时写入 `card_group`、无卡片级保存按钮。
|
||||||
|
|
||||||
### P13(2026-10-31,首页卡片按侧边栏逻辑)
|
### P13(2026-10-31,首页卡片按侧边栏逻辑)
|
||||||
|
|
||||||
- 每张卡片的分组勾选改为与侧边栏一致:标题在左、开关在右、勾选/取消即时保存(`PUT /api/cards/{id}/groups`),去掉每张卡的「保存」按钮;
|
- 每张卡片的分组勾选改为与侧边栏一致:标题在左、开关在右、勾选/取消即时保存(`PUT /api/cards/{id}/groups`),去掉每张卡的「保存」按钮;
|
||||||
|
|||||||
+89
-8
@@ -1281,19 +1281,100 @@ a.entry-href:hover {
|
|||||||
min-width: 0;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--su-space-2);
|
gap: var(--su-space-2);
|
||||||
|
margin-bottom: var(--su-space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-group-row {
|
.cards-table-wrap {
|
||||||
display: grid;
|
margin-top: var(--su-space-2);
|
||||||
grid-template-columns: minmax(0, 1fr) 100px auto;
|
}
|
||||||
align-items: center;
|
|
||||||
gap: var(--su-space-2);
|
.cards-col-limit {
|
||||||
padding: 2px 0;
|
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 > * {
|
.admin-category-row > * {
|
||||||
|
|||||||
@@ -1240,63 +1240,109 @@ function CardsPanel({
|
|||||||
onDelete: (id: number, title: string) => void
|
onDelete: (id: number, title: string) => void
|
||||||
}) {
|
}) {
|
||||||
const options = useMemo(() => flattenCardsGroups(groups), [groups])
|
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)
|
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 () => {
|
const create = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
const result = await createCard(title.trim())
|
const result = await createCard(newTitle.trim())
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error(result.error)
|
toast.error(result.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTitle('')
|
setNewTitle('')
|
||||||
|
setPendingId(result.id)
|
||||||
toast.success('卡片已创建')
|
toast.success('卡片已创建')
|
||||||
onReload()
|
onReload()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
<div className="cards-manager">
|
||||||
<Text size="sm" tone="muted">
|
<aside className="cards-side">
|
||||||
卡片只是展示组合:新建卡片后勾选要展示的分组(可混合格式),开关和条数修改后即时保存。
|
<Text size="sm" tone="muted">
|
||||||
</Text>
|
卡片
|
||||||
|
</Text>
|
||||||
<div className="admin-new-node-row">
|
<div className="cards-side-new">
|
||||||
<div style={{ flex: 1, minWidth: 220 }}>
|
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={newTitle}
|
||||||
placeholder="新卡片标题"
|
placeholder="新卡片标题"
|
||||||
aria-label="新卡片标题"
|
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>
|
</div>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
icon={<Icon name="add_line" />}
|
|
||||||
loading={busy}
|
|
||||||
disabled={title.trim() === ''}
|
|
||||||
onClick={() => void create()}
|
|
||||||
>
|
|
||||||
新建卡片
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cards.map((card) => (
|
<div className="cards-side-list">
|
||||||
<CardRow
|
{cards.map((card) => (
|
||||||
key={card.id}
|
<button
|
||||||
card={card}
|
key={card.id}
|
||||||
options={options}
|
type="button"
|
||||||
onReload={onReload}
|
className={`cards-side-item${active?.id === card.id ? ' is-active' : ''}`}
|
||||||
onDelete={onDelete}
|
onClick={() => setActiveId(card.id)}
|
||||||
/>
|
>
|
||||||
))}
|
<span className="cards-side-title">{card.title}</span>
|
||||||
</Space>
|
<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,
|
card,
|
||||||
options,
|
options,
|
||||||
onReload,
|
onReload,
|
||||||
@@ -1360,8 +1406,8 @@ function CardRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-section-block">
|
<div>
|
||||||
<div className="card-title-row">
|
<div className="cards-table-head">
|
||||||
<div className="admin-node-title">
|
<div className="admin-node-title">
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
@@ -1380,43 +1426,61 @@ function CardRow({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginLeft: 18 }}>
|
<Text size="sm" tone="muted">
|
||||||
{options.map((option) => {
|
开关和条数修改后即时保存;条数留空使用默认。
|
||||||
const checked = option.key in selected
|
</Text>
|
||||||
return (
|
|
||||||
<div className="card-group-row" key={option.key}>
|
<div className="group-table-wrap cards-table-wrap">
|
||||||
<Text
|
<table className="group-table cards-table">
|
||||||
className="admin-item-title"
|
<thead>
|
||||||
style={{ paddingLeft: option.depth * 18 }}
|
<tr>
|
||||||
>
|
<th>分组名称</th>
|
||||||
{option.title}
|
<th className="cards-col-limit">预览条数</th>
|
||||||
</Text>
|
<th className="group-col-status">展示</th>
|
||||||
{checked ? (
|
</tr>
|
||||||
<Input
|
</thead>
|
||||||
type="number"
|
<tbody>
|
||||||
value={selected[option.key]}
|
{options.map((option) => {
|
||||||
placeholder="条数"
|
const checked = option.key in selected
|
||||||
aria-label={`${option.title} 条数`}
|
return (
|
||||||
onChange={(event) =>
|
<tr key={option.key}>
|
||||||
setSelected((current) => ({
|
<td>
|
||||||
...current,
|
<div style={{ paddingLeft: option.depth * 18 }}>
|
||||||
[option.key]: event.target.value,
|
<span className="group-name">{option.title}</span>
|
||||||
}))
|
</div>
|
||||||
}
|
</td>
|
||||||
onBlur={() => void persistGroups(selected)}
|
<td>
|
||||||
/>
|
{checked ? (
|
||||||
) : (
|
<Input
|
||||||
<span />
|
type="number"
|
||||||
)}
|
value={selected[option.key]}
|
||||||
<Switch
|
placeholder="默认"
|
||||||
checked={checked}
|
aria-label={`${option.title} 条数`}
|
||||||
onChange={(next) => toggle(option.key, next)}
|
onChange={(event) =>
|
||||||
label={`卡片包含 ${option.title}`}
|
setSelected((current) => ({
|
||||||
disabled={busy}
|
...current,
|
||||||
/>
|
[option.key]: event.target.value,
|
||||||
</div>
|
}))
|
||||||
)
|
}
|
||||||
})}
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user