| 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> |
| ) |
| } |
|
|