refactor(admin): 三张手写表格改用库里的 Table,文字按钮/开关行收进库

- 分组管理 / 数据管理 / 内容卡片 / 本地服务 四处手写 <table> 全部换成 Table size='sm'
- 行内操作(新建子分组 / 编辑 / 删除)改用 TextButton,删除统一 danger 色
- 侧边栏、页头共 7 处开关行改用 ToggleRow
- 空状态改用 Empty
- 删掉随之失效的 CSS:.group-table* / .entry-table* / .cards-table* / .group-action* /
  .sidebar-toggle-row / .admin-item-title 等,App.css 净减 144 行

视觉:表格外框、表头底色、行 hover 统一由 @stash/ui 提供,与接口页一致
This commit is contained in:
2026-11-01 21:59:18 +08:00
parent a67ab7bfe0
commit 4ae69b29fe
2 changed files with 293 additions and 515 deletions
+270 -348
View File
@@ -2,6 +2,7 @@ import {
Brand,
Button,
Card,
Empty,
FormField,
Icon,
ICON_NAMES,
@@ -14,11 +15,14 @@ import {
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Text,
Textarea,
TextButton,
Title,
ToggleRow,
Toaster,
toast,
type IconName,
@@ -578,92 +582,75 @@ function GroupsPanel({
</Button>
</div>
<div className="group-table-wrap">
<table className="group-table">
<thead>
<tr>
<th>分组名称</th>
<th className="group-col-sort">排序权重</th>
<th className="group-col-count">关联网页数</th>
<th className="group-col-status">状态</th>
<th className="group-col-actions">操作</th>
</tr>
</thead>
<tbody>
{rows.map(({ node, level }) => {
const hasChildren = node.children.length > 0
const open = !collapsed.has(node.key)
return (
<tr key={node.key}>
<td>
<div className="group-name-cell" style={{ paddingLeft: level * 20 }}>
{hasChildren ? (
<button
type="button"
className={`group-caret${open ? ' group-caret-open' : ''}`}
aria-label={open ? `折叠 ${node.title}` : `展开 ${node.title}`}
aria-expanded={open}
onClick={() => toggle(node.key)}
/>
) : (
<span className="group-caret group-caret-empty" aria-hidden="true" />
)}
<GroupMark icon={node.icon} />
<span className="group-name">{node.title}</span>
</div>
</td>
<td className="group-sort">{node.sort}</td>
<td className="group-count">{countGroupEntries(node)}</td>
<td>
<Switch
checked={node.enabled !== false}
onChange={(next) => void toggleEnabled(node, next)}
label={`启用 ${node.title}`}
size="sm"
/>
</td>
<td>
<div className="group-actions">
<button
type="button"
className="group-action"
onClick={() => setDialog({ mode: 'child', parent: node })}
>
<Icon name="add_line" />
新建子分组
</button>
<button
type="button"
className="group-action"
onClick={() => setDialog({ mode: 'edit', group: node })}
>
<Icon name="edit_4_line" />
编辑
</button>
<button
type="button"
className="group-action group-action-danger"
onClick={() => onDelete(node.key, node.title)}
>
<Icon name="delete_2_line" />
删除
</button>
</div>
</td>
</tr>
)
})}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="group-empty">
暂无分组数据,点击左侧「新建根分组」
</td>
</tr>
)}
</tbody>
</table>
</div>
<Table
size="sm"
rowKey="key"
columns={[
{ key: 'name', title: '分组名称' },
{ key: 'sort', title: '排序权重', width: 110 },
{ key: 'count', title: '关联网页数', width: 120 },
{ key: 'status', title: '状态', width: 90 },
{ key: 'actions', title: '操作', width: 260, align: 'right' },
]}
rows={rows.map(({ node, level }) => {
const hasChildren = node.children.length > 0
const open = !collapsed.has(node.key)
return {
key: node.key,
name: (
<div className="group-name-cell" style={{ paddingLeft: level * 20 }}>
{hasChildren ? (
<button
type="button"
className={`group-caret${open ? ' group-caret-open' : ''}`}
aria-label={open ? `折叠 ${node.title}` : `展开 ${node.title}`}
aria-expanded={open}
onClick={() => toggle(node.key)}
/>
) : (
<span className="group-caret group-caret-empty" aria-hidden="true" />
)}
<GroupMark icon={node.icon} />
<span className="group-name">{node.title}</span>
</div>
),
sort: node.sort,
count: countGroupEntries(node),
status: (
<Switch
checked={node.enabled !== false}
onChange={(next) => void toggleEnabled(node, next)}
label={`启用 ${node.title}`}
size="sm"
/>
),
actions: (
<div className="group-actions">
<TextButton
icon={<Icon name="add_line" />}
onClick={() => setDialog({ mode: 'child', parent: node })}
>
新建子分组
</TextButton>
<TextButton
icon={<Icon name="edit_4_line" />}
onClick={() => setDialog({ mode: 'edit', group: node })}
>
编辑
</TextButton>
<TextButton
tone="danger"
icon={<Icon name="delete_2_line" />}
onClick={() => onDelete(node.key, node.title)}
>
删除
</TextButton>
</div>
),
}
})}
emptyText={<Empty text="暂无分组数据,点击上方「新建根分组」" />}
/>
<GroupDialog
state={dialog}
@@ -1019,75 +1006,54 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
{group?.title ?? ''} 下共 {rows.length} 条(含子分组)
</Text>
<div className="group-table-wrap">
<table className="group-table entry-table">
<thead>
<tr>
<th>标题</th>
<th className="entry-col-format">格式</th>
<th className="entry-col-href">跳转链接</th>
<th className="entry-col-group">所属分组</th>
<th className="group-col-actions">操作</th>
</tr>
</thead>
<tbody>
{rows.map(({ entry, group: owner }) => (
<tr key={entry.id}>
<td>
<span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>
</td>
<td>
<span className="entry-format">
{FORMAT_LABEL[(entry.format ?? 'apps') as GroupFormat]}
</span>
</td>
<td>
<EntryLink href={entry.href} />
</td>
<td>
<span className="entry-group">{owner.title}</span>
</td>
<td>
<div className="group-actions">
<button
type="button"
className="group-action"
onClick={() => setDialog({ key: entry.id, groupKey: owner.key })}
>
<Icon name="edit_4_line" />
编辑
</button>
<button
type="button"
className="group-action group-action-danger"
onClick={async () => {
const result = await deleteItem(entry.id)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success('条目已删除')
onReload()
}}
>
<Icon name="delete_2_line" />
删除
</button>
</div>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="group-empty">
该分组下还没有内容
</td>
</tr>
)}
</tbody>
</table>
</div>
<Table
size="sm"
rowKey="key"
columns={[
{ key: 'title', title: '标题' },
{ key: 'format', title: '格式', width: 80 },
{ key: 'href', title: '跳转链接', width: '36%' },
{ key: 'group', title: '所属分组', width: 180 },
{ key: 'actions', title: '操作', width: 160, align: 'right' },
]}
rows={rows.map(({ entry, group: owner }) => ({
key: entry.id,
title: <span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>,
format: (
<span className="entry-format">
{FORMAT_LABEL[(entry.format ?? 'apps') as GroupFormat]}
</span>
),
href: <EntryLink href={entry.href} />,
group: <span className="entry-group">{owner.title}</span>,
actions: (
<div className="group-actions">
<TextButton
icon={<Icon name="edit_4_line" />}
onClick={() => setDialog({ key: entry.id, groupKey: owner.key })}
>
编辑
</TextButton>
<TextButton
tone="danger"
icon={<Icon name="delete_2_line" />}
onClick={async () => {
const result = await deleteItem(entry.id)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success('条目已删除')
onReload()
}}
>
删除
</TextButton>
</div>
),
}))}
emptyText={<Empty text="该分组下还没有内容" />}
/>
<EntryDialog
open={dialog !== null}
@@ -1371,33 +1337,27 @@ function SidebarPanel({
<Text size="sm" strong>
侧栏卡片
</Text>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">本地管理员</Text>
<Switch
checked={value.show_profile !== false}
onChange={(visible) => onToggleCard('profile', visible)}
label="显示本地管理员卡片"
disabled={saving}
/>
</div>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">本地服务</Text>
<Switch
checked={value.show_service !== false}
onChange={(visible) => onToggleCard('service', visible)}
label="显示本地服务卡片"
disabled={saving}
/>
</div>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">分组导航</Text>
<Switch
checked={value.show_nav !== false}
onChange={(visible) => onToggleCard('nav', visible)}
label="显示分组导航卡片"
disabled={saving}
/>
</div>
<ToggleRow
label="本地管理员"
checked={value.show_profile !== false}
onChange={(visible) => onToggleCard('profile', visible)}
switchLabel="显示本地管理员卡片"
disabled={saving}
/>
<ToggleRow
label="本地服务"
checked={value.show_service !== false}
onChange={(visible) => onToggleCard('service', visible)}
switchLabel="显示本地服务卡片"
disabled={saving}
/>
<ToggleRow
label="分组导航"
checked={value.show_nav !== false}
onChange={(visible) => onToggleCard('nav', visible)}
switchLabel="显示分组导航卡片"
disabled={saving}
/>
</Space>
)
}
@@ -1634,42 +1594,23 @@ function LocalServiceTable({ projects }: { projects: Project[] }) {
本地服务卡片由托管目录自动生成,随项目扫描更新;后台只能查看,不能删除或改名。
</Text>
<div className="group-table-wrap cards-table-wrap">
<table className="group-table cards-table">
<thead>
<tr>
<th>项目名称</th>
<th className="entry-col-href">访问地址</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.name}>
<td>
<span className="group-name">{project.name}</span>
</td>
<td>
<a
className="entry-href"
href={project.route_path}
target="_blank"
rel="noreferrer"
>
{project.route_path}
</a>
</td>
</tr>
))}
{projects.length === 0 && (
<tr>
<td colSpan={2} className="group-empty">
还没有托管项目
</td>
</tr>
)}
</tbody>
</table>
</div>
<Table
size="sm"
rowKey="name"
columns={[
{ key: 'name', title: '项目名称' },
{ key: 'href', title: '访问地址', width: '50%' },
]}
rows={projects.map((project) => ({
name: <span className="group-name">{project.name}</span>,
href: (
<a className="entry-href" href={project.route_path} target="_blank" rel="noreferrer">
{project.route_path}
</a>
),
}))}
emptyText={<Empty text="还没有托管项目" />}
/>
</div>
)
}
@@ -1795,88 +1736,80 @@ function CardTable({
说明:开关和条数修改后即时保存;条数留空使用默认。
</Text>
<div className="group-table-wrap cards-table-wrap">
<table className="group-table cards-table">
<thead>
<tr>
<th>分组名称</th>
<th className="cards-col-state">状态</th>
<th className="cards-col-limit">预览条数</th>
<th className="cards-col-order">顺序</th>
<th className="group-col-status">展示</th>
</tr>
</thead>
<tbody>
{options.map((option) => {
const checked = option.key in selected
return (
<tr key={option.key}>
<td>
<div style={{ paddingLeft: option.depth * 18 }}>
<span className="group-name">{option.title}</span>
</div>
</td>
<td>
<span className={option.enabled ? 'card-state card-state-on' : 'card-state card-state-off'}>
{option.enabled ? '启用' : '已停用'}
</span>
</td>
<td>
{checked ? (
<Input
type="number"
value={selected[option.key]}
placeholder="默认"
aria-label={`${option.title} 条数`}
onChange={(event) =>
setSelected((current) => ({
...current,
[option.key]: event.target.value,
}))
}
onBlur={() => void persistGroups(selected, order)}
/>
) : (
<span className="entry-href entry-href-empty">—</span>
)}
</td>
<td>
{checked ? (
<div className="card-order-cell">
<span className="card-order-index">{order.indexOf(option.key) + 1}</span>
<IconButton
label={`${option.title} 上移`}
icon={<Icon name="arrow_up_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === 0}
onClick={() => moveGroup(option.key, -1)}
/>
<IconButton
label={`${option.title} 下移`}
icon={<Icon name="arrow_down_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === order.length - 1}
onClick={() => moveGroup(option.key, 1)}
/>
</div>
) : (
<span className="entry-href entry-href-empty">—</span>
)}
</td>
<td>
<Switch
checked={checked}
onChange={(next) => toggle(option.key, next)}
label={`卡片包含 ${option.title}`}
disabled={busy || !option.enabled}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<Table
size="sm"
rowKey="key"
columns={[
{ key: 'name', title: '分组名称' },
{ key: 'state', title: '状态', width: 90 },
{ key: 'limit', title: '预览条数', width: 110 },
{ key: 'order', title: '顺序', width: 120 },
{ key: 'show', title: '展示', width: 90 },
]}
rows={options.map((option) => {
const checked = option.key in selected
return {
key: option.key,
name: (
<div style={{ paddingLeft: option.depth * 18 }}>
<span className="group-name">{option.title}</span>
</div>
),
state: (
<span className={option.enabled ? 'card-state card-state-on' : 'card-state card-state-off'}>
{option.enabled ? '启用' : '已停用'}
</span>
),
limit: checked ? (
<Input
type="number"
value={selected[option.key]}
placeholder="默认"
aria-label={`${option.title} 条数`}
onChange={(event) =>
setSelected((current) => ({
...current,
[option.key]: event.target.value,
}))
}
onBlur={() => void persistGroups(selected, order)}
/>
) : (
<span className="entry-href entry-href-empty">—</span>
),
order: checked ? (
<div className="card-order-cell">
<span className="card-order-index">{order.indexOf(option.key) + 1}</span>
<IconButton
label={`${option.title} 上移`}
icon={<Icon name="arrow_up_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === 0}
onClick={() => moveGroup(option.key, -1)}
/>
<IconButton
label={`${option.title} 下移`}
icon={<Icon name="arrow_down_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === order.length - 1}
onClick={() => moveGroup(option.key, 1)}
/>
</div>
) : (
<span className="entry-href entry-href-empty">—</span>
),
show: (
<Switch
checked={checked}
onChange={(next) => toggle(option.key, next)}
label={`卡片包含 ${option.title}`}
disabled={busy || !option.enabled}
/>
),
}
})}
emptyText={<Empty text="还没有可选的分组" />}
/>
</div>
)
}
@@ -1993,59 +1926,48 @@ function HeaderPanel({
<Text size="sm" tone="muted">
说明:这里控制页头右侧显示哪些图标按钮;关掉的按钮不再出现,也不影响其它页面。
</Text>
<div className="sidebar-toggle-row">
<Text>明暗切换</Text>
<Switch
checked={site.actions.theme}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, theme: next } })
}
label="页头显示明暗切换按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>刷新</Text>
<Switch
checked={site.actions.refresh}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, refresh: next } })
}
label="页头显示刷新按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>站点设置</Text>
<Switch
checked={site.actions.settings}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, settings: next } })
}
label="页头显示站点设置按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>登录 / 退出</Text>
<Switch
checked={site.actions.session}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, session: next } })
}
label="页头显示登录或退出按钮"
/>
</div>
<ToggleRow
label="明暗切换"
checked={site.actions.theme}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, theme: next } })
}
switchLabel="页头显示明暗切换按钮"
/>
<ToggleRow
label="刷新"
checked={site.actions.refresh}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, refresh: next } })
}
switchLabel="页头显示刷新按钮"
/>
<ToggleRow
label="站点设置"
checked={site.actions.settings}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, settings: next } })
}
switchLabel="页头显示站点设置按钮"
/>
<ToggleRow
label="登录 / 退出"
checked={site.actions.session}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, session: next } })
}
switchLabel="页头显示登录或退出按钮"
/>
</Space>
)}
{tab === 'ticker' && (
<Space direction="column" gap="md" align="stretch">
<div className="sidebar-toggle-row">
<Text>显示页头滚动信息</Text>
<Switch
checked={value.enabled}
onChange={(enabled) => onChange({ ...value, enabled })}
label="显示页头滚动信息"
/>
</div>
<ToggleRow
label="显示页头滚动信息"
checked={value.enabled}
onChange={(enabled) => onChange({ ...value, enabled })}
/>
<FormField label="滚动速度" htmlFor="header-speed" hint="秒数越大越慢,范围 8–120">
<Input
id="header-speed"