XHS / frontend /src /pages /CleanedNotesPage.tsx
Trae Bot
Upload Spider_XHS project
c481f8a
import { Alert, Button, Card, Drawer, Empty, Input, Space, Table, Typography } from 'antd'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { ApiError } from '../lib/api'
import { isOrchestratorDbUnavailable, listCleanedNotes, type CleanedNoteRecord } from '../lib/content'
export default function CleanedNotesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<ApiError | null>(null)
const [notes, setNotes] = useState<CleanedNoteRecord[]>([])
const [total, setTotal] = useState(0)
const [drawerOpen, setDrawerOpen] = useState(false)
const [active, setActive] = useState<CleanedNoteRecord | null>(null)
const page = useMemo(() => {
const raw = Number(searchParams.get('page') || '1')
return Number.isFinite(raw) && raw > 0 ? raw : 1
}, [searchParams])
const pageSize = useMemo(() => {
const raw = Number(searchParams.get('page_size') || '20')
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 200) : 20
}, [searchParams])
const query = useMemo(() => String(searchParams.get('query') || '').trim(), [searchParams])
const [queryInput, setQueryInput] = useState(query)
useEffect(() => {
setQueryInput(query)
}, [query])
const setParam = useCallback(
(patch: Record<string, string | undefined>) => {
const next = new URLSearchParams(searchParams)
Object.entries(patch).forEach(([k, v]) => {
const val = String(v || '').trim()
if (val) next.set(k, val)
else next.delete(k)
})
setSearchParams(next)
},
[searchParams, setSearchParams],
)
const loadList = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await listCleanedNotes({
limit: pageSize,
offset: (page - 1) * pageSize,
query: query || undefined,
})
setNotes(res.notes || [])
setTotal(res.total || 0)
} catch (e) {
setError(e as ApiError)
} finally {
setLoading(false)
}
}, [page, pageSize, query])
useEffect(() => {
void loadList()
}, [loadList])
const columns = useMemo(
() => [
{ title: 'ID', dataIndex: 'id', width: 90 },
{
title: '原始作者',
dataIndex: 'raw_author',
width: 160,
render: (v: unknown) => (v ? String(v) : '—'),
},
{
title: '原始 URL',
dataIndex: 'raw_url',
width: 340,
ellipsis: true,
render: (v: unknown) => {
const url = String(v || '').trim()
if (!url) return '—'
return (
<a href={url} target="_blank" rel="noreferrer">
{url}
</a>
)
},
},
{
title: '清洗内容',
dataIndex: 'cleaned_content',
ellipsis: true,
render: (_: unknown, record: CleanedNoteRecord) => {
const text = String(record.cleaned_content || '').trim()
return (
<Space>
<Typography.Text ellipsis style={{ maxWidth: 520 }}>
{text || '—'}
</Typography.Text>
<Button
size="small"
disabled={!text}
onClick={() => {
setActive(record)
setDrawerOpen(true)
}}
>
查看
</Button>
</Space>
)
},
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 200,
render: (v: unknown) => (v ? String(v) : '—'),
},
],
[],
)
const dbMissing = isOrchestratorDbUnavailable(error)
const cleanedText = String(active?.cleaned_content || '').trim()
return (
<div style={{ display: 'grid', gap: 16 }}>
<Typography.Title level={3} style={{ margin: 0 }}>
清洗笔记库
</Typography.Title>
<Card
title="GET /api/v1/content/cleaned-notes"
extra={
<Space wrap>
<Input.Search
allowClear
placeholder="搜索 cleaned_content / author / url / content"
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
onSearch={(value) => setParam({ query: value.trim() || undefined, page: '1' })}
style={{ width: 360 }}
/>
<Button onClick={loadList} disabled={loading}>
刷新
</Button>
</Space>
}
loading={loading}
>
{dbMissing ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div style={{ maxWidth: 680 }}>
<Typography.Text strong>内容库数据库不可用</Typography.Text>
<Typography.Paragraph style={{ marginTop: 8, marginBottom: 0 }}>
服务端返回 503,通常表示 ORCHESTRATOR_DB_PATH 未配置、路径不存在或 DB 尚未初始化。
<br />
处理方式:
<br />
1)配置 ORCHESTRATOR_DB_PATH 指向可读的 sqlite 文件(默认 orchestrator/data/mvp.db)
<br />
2)初始化 DB:python Spider_XHS/orchestrator/db_init.py
</Typography.Paragraph>
</div>
}
/>
) : error ? (
<Alert
type="error"
showIcon
message={error.status ? `HTTP ${error.status}` : '请求失败'}
description={error.message}
style={{ marginBottom: 16 }}
/>
) : null}
<Table
size="middle"
rowKey="id"
columns={columns}
dataSource={notes}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100, 200],
onChange: (nextPage, nextSize) => setParam({ page: String(nextPage), page_size: String(nextSize) }),
}}
/>
</Card>
<Drawer
title={active ? `cleaned_note #${active.id}` : 'cleaned_note'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={720}
>
{active ? (
<div style={{ display: 'grid', gap: 12 }}>
<div>
<Typography.Text type="secondary">raw_note_id:</Typography.Text>
<Typography.Text>{active.raw_note_id ?? '—'}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">raw_author:</Typography.Text>
<Typography.Text>{active.raw_author || '—'}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">raw_url:</Typography.Text>
{active.raw_url ? (
<a href={active.raw_url} target="_blank" rel="noreferrer">
{active.raw_url}
</a>
) : (
<Typography.Text></Typography.Text>
)}
</div>
<div>
<Typography.Text type="secondary">created_at:</Typography.Text>
<Typography.Text>{active.created_at || '—'}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">cleaned_content:</Typography.Text>
<pre
style={{
marginTop: 8,
marginBottom: 0,
padding: 12,
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 520,
overflow: 'auto',
}}
>
{cleanedText || '—'}
</pre>
</div>
</div>
) : null}
</Drawer>
</div>
)
}