dashboard-nanobot/frontend/src/modules/dashboard/topic/TopicFeedPanel.tsx

370 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { Eye, RefreshCw, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../../components/lucent/LucentSelect';
import { createWorkspaceMarkdownComponents, decorateWorkspacePathsForMarkdown } from '../shared/workspaceMarkdown';
export interface TopicFeedItem {
id: number;
bot_id: string;
topic_key: string;
title: string;
content: string;
level: string;
tags: string[];
view: Record<string, unknown>;
source: string;
dedupe_key: string;
is_read: boolean;
created_at?: string;
}
export interface TopicFeedOption {
key: string;
label: string;
}
interface TopicFeedPanelProps {
isZh: boolean;
topicKey: string;
topicOptions: TopicFeedOption[];
topicState?: 'none' | 'inactive' | 'ready';
items: TopicFeedItem[];
loading: boolean;
loadingMore: boolean;
nextCursor: number | null;
error: string;
readSavingById: Record<number, boolean>;
onTopicChange: (value: string) => void;
onRefresh: () => void;
onMarkRead: (itemId: number) => void;
onLoadMore: () => void;
onOpenWorkspacePath: (path: string) => void;
onOpenTopicSettings?: () => void;
onDetailOpenChange?: (open: boolean) => void;
layout?: 'compact' | 'panel';
}
interface TopicSummaryCard {
title: string;
summary: string;
highlights: string[];
snippet: string;
}
interface TopicDetailState {
itemId: number;
fallbackTitle: string;
fallbackContent: string;
}
function formatTopicItemTime(raw: string | undefined, isZh: boolean): string {
const text = String(raw || '').trim();
if (!text) return '-';
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return '-';
try {
return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch {
return dt.toLocaleString();
}
}
function cleanTopicLine(raw: unknown): string {
const text = String(raw || '').trim();
if (!text) return '';
if (/^\|.*\|$/.test(text)) return '';
if (/^[-=:_`~]{3,}$/.test(text)) return '';
return text.replace(/^\s{0,3}(?:[#>*-]+|\d+[.)])\s*/, '').trim();
}
function deriveTopicSummaryCard(item: TopicFeedItem): TopicSummaryCard {
const view = item.view && typeof item.view === 'object' ? item.view : {};
const titleFromView = String(view.title || '').trim();
const summaryFromView = String(view.summary || '').trim();
const snippetFromView = String(view.snippet || '').trim();
const highlightsFromView = Array.isArray(view.highlights)
? view.highlights.map((row) => String(row || '').trim()).filter(Boolean)
: [];
const lines = String(item.content || '')
.split('\n')
.map(cleanTopicLine)
.filter(Boolean);
const title = titleFromView || String(item.title || '').trim() || lines[0] || item.topic_key || 'Topic';
const summaryCandidates = lines.filter((line) => line !== title);
const summary = summaryFromView || summaryCandidates.slice(0, 2).join(' ').slice(0, 220).trim() || title;
const fallbackHighlights = String(item.content || '')
.split('\n')
.map((line) => ({ raw: String(line || '').trim(), cleaned: cleanTopicLine(line) }))
.filter((row) => row.cleaned)
.filter((row) => row.raw.startsWith('-') || row.raw.startsWith('*') || row.cleaned.includes(':') || row.cleaned.includes(''))
.map((row) => row.cleaned.slice(0, 120))
.filter((row, idx, arr) => arr.indexOf(row) === idx)
.slice(0, 3);
const snippetCandidates = summaryCandidates
.filter((line) => line !== summary)
.filter((line) => !fallbackHighlights.includes(line))
.slice(0, 2);
const snippet = snippetFromView || snippetCandidates.join(' ').slice(0, 180).trim();
return {
title,
summary,
highlights: highlightsFromView.length > 0 ? highlightsFromView.slice(0, 3) : fallbackHighlights,
snippet,
};
}
export function TopicFeedPanel({
isZh,
topicKey,
topicOptions,
topicState = 'ready',
items,
loading,
loadingMore,
nextCursor,
error,
readSavingById,
onTopicChange,
onRefresh,
onMarkRead,
onLoadMore,
onOpenWorkspacePath,
onOpenTopicSettings,
onDetailOpenChange,
layout = 'compact',
}: TopicFeedPanelProps) {
const markdownComponents = useMemo(
() => createWorkspaceMarkdownComponents((path) => onOpenWorkspacePath(path)),
[onOpenWorkspacePath],
);
const [detailState, setDetailState] = useState<TopicDetailState | null>(null);
const closeDetail = useCallback(() => setDetailState(null), []);
const detailItem = useMemo(
() => (detailState ? items.find((item) => Number(item.id || 0) === detailState.itemId) || null : null),
[detailState, items],
);
const detailTitle = detailItem
? String(detailItem.title || detailItem.topic_key || detailState?.fallbackTitle || '').trim()
: String(detailState?.fallbackTitle || '').trim();
const detailContent = detailItem
? String(detailItem.content || detailState?.fallbackContent || '').trim()
: String(detailState?.fallbackContent || '').trim();
const portalTarget = useMemo(() => {
if (typeof document === 'undefined') return null;
return document.querySelector('.app-shell[data-theme]') || document.body;
}, []);
useEffect(() => {
if (!detailState) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDetail();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [closeDetail, detailState]);
useEffect(() => {
onDetailOpenChange?.(Boolean(detailState));
}, [detailState, onDetailOpenChange]);
return (
<div className={`ops-topic-feed ${layout === 'panel' ? 'is-panel' : ''}`}>
{topicState === 'ready' ? (
<div className="ops-topic-feed-toolbar">
<LucentSelect value={topicKey || '__all__'} onChange={(e) => onTopicChange(String(e.target.value || '__all__'))}>
<option value="__all__">{isZh ? '全部主题' : 'All Topics'}</option>
{topicOptions.map((row) => (
<option key={row.key} value={row.key}>
{row.label}
</option>
))}
</LucentSelect>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
disabled={loading || loadingMore}
onClick={onRefresh}
tooltip={isZh ? '刷新主题消息' : 'Refresh Topic feed'}
aria-label={isZh ? '刷新主题消息' : 'Refresh Topic feed'}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</LucentIconButton>
</div>
) : null}
{error ? <div className="ops-empty-inline">{error}</div> : null}
<div className={`ops-topic-feed-list ${layout === 'panel' ? 'is-panel' : ''}`}>
{topicState === 'none' ? (
<div className="ops-topic-feed-empty-state">
<div className="ops-topic-feed-empty-title">{isZh ? '还没有配置主题' : 'No topics configured'}</div>
<div className="ops-topic-feed-empty-desc">
{isZh ? '请先到主题设置中新增至少一个主题Bot 的消息才能被路由到这里。' : 'Create at least one topic in settings before feed messages can appear here.'}
</div>
{onOpenTopicSettings ? (
<button className="btn btn-secondary btn-sm" onClick={onOpenTopicSettings}>
{isZh ? '打开主题设置' : 'Open Topic settings'}
</button>
) : null}
</div>
) : topicState === 'inactive' ? (
<div className="ops-topic-feed-empty-state">
<div className="ops-topic-feed-empty-title">{isZh ? '没有启用中的主题' : 'No active topics'}</div>
<div className="ops-topic-feed-empty-desc">
{isZh ? '你已经配置了主题,但当前都处于关闭状态。启用一个主题后,这里才会开始接收消息。' : 'Topics exist, but all of them are disabled. Enable one to start receiving feed items here.'}
</div>
{onOpenTopicSettings ? (
<button className="btn btn-secondary btn-sm" onClick={onOpenTopicSettings}>
{isZh ? '打开主题设置' : 'Open Topic settings'}
</button>
) : null}
</div>
) : loading ? (
<div className="ops-empty-inline">{isZh ? '读取主题消息中...' : 'Loading topic feed...'}</div>
) : items.length === 0 ? (
<div className="ops-empty-inline">{isZh ? '暂无主题消息。' : 'No topic messages.'}</div>
) : (
items.map((item) => {
const itemId = Number(item.id || 0);
const level = String(item.level || 'info').trim().toLowerCase();
const levelText = level === 'warn' ? 'WARN' : level === 'error' ? 'ERROR' : level === 'success' ? 'SUCCESS' : 'INFO';
const unread = !Boolean(item.is_read);
const card = deriveTopicSummaryCard(item);
const rawContent = String(item.content || '').trim();
return (
<article key={`topic-item-${itemId}`} className={`ops-topic-feed-item ${unread ? 'unread' : ''}`}>
<div className="ops-topic-feed-item-head">
<div className="ops-topic-feed-meta">
<span className={`ops-topic-feed-level ${level}`}>{levelText}</span>
<span className="ops-topic-feed-topic-chip mono">{item.topic_key || '-'}</span>
{unread ? <span className="ops-topic-feed-unread-dot" aria-label={isZh ? '未读' : 'Unread'} /> : null}
</div>
<div className="ops-topic-feed-meta-right">
<span className="ops-topic-feed-source-chip">{item.source || 'mcp'}</span>
<span className="ops-topic-feed-time">{formatTopicItemTime(item.created_at, isZh)}</span>
</div>
</div>
<div className="ops-topic-card-shell">
<div className="ops-topic-card-title">{card.title}</div>
<div className="ops-topic-card-summary">{card.summary}</div>
{card.highlights.length > 0 ? (
<div className="ops-topic-card-highlights">
{card.highlights.map((line) => (
<div key={`${itemId}-${line}`} className="ops-topic-card-highlight">
<span className="ops-topic-card-bullet" />
<span>{line}</span>
</div>
))}
</div>
) : null}
{card.snippet ? <div className="ops-topic-card-snippet">{card.snippet}</div> : null}
{rawContent ? null : null}
</div>
{(item.tags || []).length > 0 ? (
<div className="ops-topic-feed-tags">
{(item.tags || []).map((tag) => (
<span key={`${itemId}-${tag}`} className="ops-topic-feed-tag mono">
{tag}
</span>
))}
</div>
) : null}
<div className="ops-topic-feed-item-foot">
<span className={`ops-topic-read-state ${unread ? 'is-unread' : 'is-read'}`}>
{unread ? (isZh ? '新消息' : 'New') : (isZh ? '已读' : 'Read')}
</span>
<div className="ops-topic-feed-item-actions">
{rawContent ? (
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setDetailState({ itemId, fallbackTitle: card.title, fallbackContent: rawContent })}
tooltip={isZh ? '查看详情' : 'View details'}
aria-label={isZh ? '查看详情' : 'View details'}
>
<Eye size={14} />
</LucentIconButton>
) : null}
{unread ? (
<button
className="btn btn-secondary btn-sm"
disabled={Boolean(readSavingById[itemId])}
onClick={() => onMarkRead(itemId)}
>
{readSavingById[itemId] ? (isZh ? '处理中...' : 'Saving...') : (isZh ? '标记已读' : 'Mark read')}
</button>
) : null}
</div>
</div>
</article>
);
})
)}
</div>
{detailState && portalTarget
? createPortal(
<div className="modal-mask" onClick={closeDetail}>
<div className="modal-card modal-preview" onClick={(event) => event.stopPropagation()}>
<div className="modal-title-row workspace-preview-header">
<div className="workspace-preview-header-text">
<h3>{isZh ? '主题详情' : 'Topic detail'}</h3>
<span className="modal-sub">{detailTitle || (isZh ? '原文详情' : 'Raw detail')}</span>
</div>
<div className="workspace-preview-header-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={closeDetail}
tooltip={isZh ? '关闭详情' : 'Close detail'}
aria-label={isZh ? '关闭详情' : 'Close detail'}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="workspace-preview-body markdown">
<div className="workspace-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(detailContent)}
</ReactMarkdown>
</div>
</div>
</div>
</div>,
portalTarget,
)
: null}
{topicState === 'ready' && (items.length > 0 || nextCursor) ? (
<div className="row-between">
<span className="field-label">
{items.length > 0 ? (isZh ? `${items.length}` : `${items.length} items`) : ''}
</span>
{nextCursor ? (
<button className="btn btn-secondary btn-sm" disabled={loadingMore} onClick={onLoadMore}>
{loadingMore ? (isZh ? '加载中...' : 'Loading...') : (isZh ? '加载更多' : 'Load more')}
</button>
) : (
<span />
)}
</div>
) : null}
</div>
);
}