feat: 新增接口一览页 /interface,滚动信息区域收窄

- 新页面 /interface:按用途分组的接口表(方法 / 路径 / 说明 / 权限),
  用库里的 Card + Table + Tag 排;服务端 SPA 路由加入该路径
- 侧边栏「查看接口」不再直接打开 /api/projects 的原始 JSON,改指到该页面
- 页头滚动信息宽度上限 720px(原来铺满,约减少 1/3)
This commit is contained in:
2026-11-01 20:53:55 +08:00
parent 036551ee09
commit 27a28ab583
6 changed files with 160 additions and 4 deletions
+1 -1
View File
@@ -164,7 +164,7 @@ fn not_found_page(path: &str) -> Response {
const SPA_PREFIXES: &[&str] = &["/c/", "/g/"]; const SPA_PREFIXES: &[&str] = &["/c/", "/g/"];
/// 精确匹配的前端页面(没有实体文件,返回入口页) /// 精确匹配的前端页面(没有实体文件,返回入口页)
const SPA_PAGES: &[&str] = &["/login", "/admin"]; const SPA_PAGES: &[&str] = &["/login", "/admin", "/interface"];
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。 /// 是否为前端路由路径(没有实体文件,应该返回入口页)。
fn is_spa_path(path: &str) -> bool { fn is_spa_path(path: &str) -> bool {
+21
View File
@@ -133,6 +133,11 @@
white-space: nowrap; white-space: nowrap;
} }
/* 滚动信息只占中间约 2/3:原来铺满整条,信息一长就把两边挤没了 */
.header-ticker {
max-width: 720px;
}
.header-actions { .header-actions {
margin-left: auto; margin-left: auto;
} }
@@ -1031,6 +1036,22 @@
gap: var(--su-space-2); gap: var(--su-space-2);
} }
/* 接口一览页 */
.api-docs {
display: flex;
flex-direction: column;
gap: var(--su-space-5);
}
.api-path {
padding: 0 var(--su-space-2);
border-radius: var(--su-radius-xs);
background-color: var(--su-bg-subtle);
color: var(--su-text-sub);
font-family: var(--su-font-mono);
font-size: var(--su-font-size-sm);
}
/* 数据管理:分组下拉与「新建数据」紧挨着 */ /* 数据管理:分组下拉与「新建数据」紧挨着 */
.entry-toolbar { .entry-toolbar {
display: flex; display: flex;
+5 -1
View File
@@ -18,6 +18,7 @@ import { ItemEditor } from './components/content/ItemEditor'
import { PosterGrid } from './components/content/PosterGrid' import { PosterGrid } from './components/content/PosterGrid'
import { SectionCard } from './components/content/SectionCard' import { SectionCard } from './components/content/SectionCard'
import { SiteHeader } from './components/header/SiteHeader' import { SiteHeader } from './components/header/SiteHeader'
import { ApiDocs } from './components/api/ApiDocs'
import { GroupMark } from './components/common/GroupMark' import { GroupMark } from './components/common/GroupMark'
import { LoginDialog } from './components/auth/LoginDialog' import { LoginDialog } from './components/auth/LoginDialog'
import { ProfileCard } from './components/sidebar/ProfileCard' import { ProfileCard } from './components/sidebar/ProfileCard'
@@ -59,9 +60,11 @@ type Route =
| { kind: 'home' } | { kind: 'home' }
| { kind: 'group'; key: string } | { kind: 'group'; key: string }
| { kind: 'item'; key: string; itemId: string } | { kind: 'item'; key: string; itemId: string }
| { kind: 'api' }
function parseRoute(pathname: string): Route { function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean) const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' }
if ( if (
(segments[0] === 'g' || segments[0] === 'c') && (segments[0] === 'g' || segments[0] === 'c') &&
segments[1] !== undefined segments[1] !== undefined
@@ -358,7 +361,7 @@ export default function AppNext() {
}, [manage.groups]) }, [manage.groups])
const activeKey = const activeKey =
route.kind === 'home' route.kind === 'home' || route.kind === 'api'
? 'home' ? 'home'
: route.kind === 'group' : route.kind === 'group'
? `g:${route.key}` ? `g:${route.key}`
@@ -740,6 +743,7 @@ export default function AppNext() {
)} )}
{route.kind === 'home' && renderHome()} {route.kind === 'home' && renderHome()}
{route.kind === 'api' && <ApiDocs />}
{route.kind === 'group' && renderGroupPage(route.key)} {route.kind === 'group' && renderGroupPage(route.key)}
{route.kind === 'item' && renderItemPage(route.key, route.itemId)} {route.kind === 'item' && renderItemPage(route.key, route.itemId)}
</div> </div>
+131
View File
@@ -0,0 +1,131 @@
import { Card, Space, Table, Tag, Text, Title } from '@stash/ui'
/**
* 接口一览:把 `/api/*` 按用途分组列出来。
*
* 页面只做展示,不请求后端——清单跟 `src/server.rs` 的路由表人工对齐,
* 改接口时顺手改这里(文件顶部有分组与权限说明)。
*/
type Endpoint = {
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
path: string
summary: string
/** 需要登录会话 */
auth?: boolean
}
const GROUPS: { title: string; desc: string; items: Endpoint[] }[] = [
{
title: '健康与项目',
desc: '托管目录的扫描结果与实时推送。',
items: [
{ method: 'GET', path: '/api/health', summary: '存活检查,返回服务名与状态' },
{ method: 'GET', path: '/api/projects', summary: '扫描托管目录(每次请求都重新扫描)' },
{ method: 'GET', path: '/api/projects/events', summary: 'SSE:目录变化时推送最新项目列表' },
],
},
{
title: '内容库',
desc: '分区 / 分组 / 条目 / 卡片。写操作都需要登录会话与同源 Origin。',
items: [
{ method: 'GET', path: '/api/catalog', summary: '旧版完整目录(兼容保留)' },
{ method: 'GET', path: '/api/catalog2', summary: '分组树 + 卡片,后台配置用' },
{ method: 'GET', path: '/api/catalog/events', summary: 'SSE:内容库变更时推送「该重拉」' },
{ method: 'POST', path: '/api/nodes', summary: '新建分区 / 分组', auth: true },
{ method: 'PATCH', path: '/api/nodes/{key}', summary: '改标题、所属、图标、排序、启停', auth: true },
{ method: 'DELETE', path: '/api/nodes/{key}', summary: '删除分区 / 分组(级联子节点)', auth: true },
{ method: 'GET', path: '/api/items/{key}', summary: '条目详情' },
{ method: 'POST', path: '/api/items', summary: '新建条目', auth: true },
{ method: 'PATCH', path: '/api/items/{key}', summary: '就地编辑(带 updatedAt 乐观锁)', auth: true },
{ method: 'DELETE', path: '/api/items/{key}', summary: '删除条目', auth: true },
{ method: 'POST', path: '/api/items/{key}/view', summary: '浏览数 +1' },
{ method: 'POST', path: '/api/items/{key}/like', summary: '点赞 / 取消点赞' },
{ method: 'POST', path: '/api/cards', summary: '新建首页卡片', auth: true },
{ method: 'PATCH', path: '/api/cards/{id}', summary: '改卡片标题 / 顺序', auth: true },
{ method: 'DELETE', path: '/api/cards/{id}', summary: '删除卡片', auth: true },
{ method: 'PUT', path: '/api/cards/{id}/groups', summary: '整组替换卡片包含的分组与顺序', auth: true },
{ method: 'POST', path: '/api/categories', summary: '旧版:新建分类', auth: true },
{ method: 'PATCH', path: '/api/categories/{id}', summary: '旧版:改分类', auth: true },
{ method: 'DELETE', path: '/api/categories/{id}', summary: '旧版:删分类', auth: true },
{ method: 'POST', path: '/api/categories/{id}/groups', summary: '旧版:新建分组', auth: true },
{ method: 'PATCH', path: '/api/groups/{id}', summary: '旧版:改分组', auth: true },
{ method: 'DELETE', path: '/api/groups/{id}', summary: '旧版:删分组', auth: true },
],
},
{
title: '站点设置',
desc: '页头、侧边栏、卡片、资料、站点信息等整份 JSON。',
items: [
{ method: 'GET', path: '/api/settings', summary: '读取设置(含 version 乐观锁)' },
{ method: 'PATCH', path: '/api/settings', summary: '整份保存设置', auth: true },
{ method: 'GET', path: '/api/backup', summary: '下载 SQLite 快照', auth: true },
],
},
{
title: '认证',
desc: '单管理员账号;会话 token 只存 sha256,Cookie 为 HttpOnly + SameSite=Strict。',
items: [
{ method: 'GET', path: '/api/auth/session', summary: '当前会话状态(是否登录、是否已设密码)' },
{ method: 'POST', path: '/api/auth/setup', summary: '首次设置管理员密码(仅空库可用)' },
{ method: 'POST', path: '/api/auth/login', summary: '登录;连续失败 5 次锁 60 秒' },
{ method: 'POST', path: '/api/auth/logout', summary: '退出并清掉会话' },
{ method: 'PATCH', path: '/api/auth/password', summary: '改密码(需当前密码)', auth: true },
],
},
]
const METHOD_TONE = {
GET: 'neutral',
POST: 'primary',
PATCH: 'warning',
PUT: 'warning',
DELETE: 'danger',
} as const
export function ApiDocs() {
return (
<div className="api-docs">
<Space direction="column" gap="sm" align="stretch">
<Title level={2}>接口一览</Title>
<Text size="sm" tone="muted">
说明:这是 Stash 自带的 HTTP 接口清单,全部挂在同一个端口上。读接口公开,写接口需要登录会话并校验同源
Origin;带 <Tag tone="primary">需登录</Tag> 标记的即为写操作。
</Text>
</Space>
{GROUPS.map((group) => (
<Card key={group.title} padding="lg">
<Space direction="column" gap="sm" align="stretch">
<Space gap="sm" align="center">
<Title level={4} as="h2">
{group.title}
</Title>
<Text size="sm" tone="muted">
{group.items.length} 个
</Text>
</Space>
<Text size="sm" tone="muted">
{group.desc}
</Text>
<Table
rowKey="id"
columns={[
{ key: 'method', title: '方法', width: 90 },
{ key: 'path', title: '路径', width: 280 },
{ key: 'summary', title: '说明' },
{ key: 'auth', title: '权限', width: 100 },
]}
rows={group.items.map((item) => ({
id: `${item.method} ${item.path}`,
method: <Tag tone={METHOD_TONE[item.method]}>{item.method}</Tag>,
path: <code className="api-path">{item.path}</code>,
summary: item.summary,
auth: item.auth === true ? <Tag tone="primary">需登录</Tag> : <Text tone="muted">公开</Text>,
}))}
/>
</Space>
</Card>
))}
</div>
)
}
@@ -77,7 +77,7 @@ export function SiteHeader({
/> />
{headerEnabled && ( {headerEnabled && (
<Ticker speed={headerSpeed} gap={headerGap} label="运行状态"> <Ticker speed={headerSpeed} gap={headerGap} label="运行状态" className="header-ticker">
{headerItems.map((item, index) => ( {headerItems.map((item, index) => (
<span className="header-ticker-item" key={`${index}-${item.text}`}> <span className="header-ticker-item" key={`${index}-${item.text}`}>
{item.tag !== undefined && item.tag !== null && item.tag !== '' && ( {item.tag !== undefined && item.tag !== null && item.tag !== '' && (
@@ -56,7 +56,7 @@ export function ServicePanel({ projects, live, refreshing, onRefresh }: ServiceP
重新扫描 重新扫描
</Button> </Button>
<a className="link-button" href="/api/projects" target="_blank" rel="noreferrer"> <a className="link-button" href="/interface">
<Icon name="link_3_line" /> <Icon name="link_3_line" />
查看接口 查看接口
</a> </a>