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

398 lines
16 KiB
TypeScript
Raw Normal View History

2026-03-13 06:40:54 +00:00
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
2026-04-02 12:27:06 +00:00
import { Eye, RefreshCw, Trash2 } from 'lucide-react';
2026-03-13 06:40:54 +00:00
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';
2026-04-03 15:00:08 +00:00
import { PreviewModalShell } from '../../../shared/ui/PreviewModalShell';
2026-03-26 18:09:25 +00:00
import {
createWorkspaceMarkdownComponents,
decorateWorkspacePathsForMarkdown,
resolveWorkspaceDocumentPath,
2026-04-03 15:00:08 +00:00
} from '../../../shared/workspace/workspaceMarkdown';
2026-03-31 04:31:47 +00:00
import './TopicFeedPanel.css';
2026-03-13 06:40:54 +00:00
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>;
2026-03-15 07:14:01 +00:00
deleteSavingById: Record<number, boolean>;
2026-03-13 06:40:54 +00:00
onTopicChange: (value: string) => void;
onRefresh: () => void;
onMarkRead: (itemId: number) => void;
2026-03-15 07:14:01 +00:00
onDeleteItem: (item: TopicFeedItem) => void;
2026-03-13 06:40:54 +00:00
onLoadMore: () => void;
onOpenWorkspacePath: (path: string) => void;
2026-03-26 18:09:25 +00:00
resolveWorkspaceMediaSrc?: (src: string, baseFilePath?: string) => string;
2026-03-13 06:40:54 +00:00
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,
2026-03-15 07:14:01 +00:00
deleteSavingById,
2026-03-13 06:40:54 +00:00
onTopicChange,
onRefresh,
onMarkRead,
2026-03-15 07:14:01 +00:00
onDeleteItem,
2026-03-13 06:40:54 +00:00
onLoadMore,
onOpenWorkspacePath,
2026-03-26 18:09:25 +00:00
resolveWorkspaceMediaSrc,
2026-03-13 06:40:54 +00:00
onOpenTopicSettings,
onDetailOpenChange,
layout = 'compact',
}: TopicFeedPanelProps) {
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();
2026-03-26 18:09:25 +00:00
const detailBaseFilePath = useMemo(() => {
if (!detailItem) return '';
const view = detailItem.view && typeof detailItem.view === 'object' ? detailItem.view as Record<string, unknown> : {};
const candidates = [
view.path,
view.file_path,
view.source_path,
view.workspace_path,
detailItem.source,
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim();
const resolved = resolveWorkspaceDocumentPath(raw);
if (resolved) return resolved;
}
return '';
}, [detailItem]);
const markdownComponents = useMemo(
() => createWorkspaceMarkdownComponents((path) => onOpenWorkspacePath(path), {
baseFilePath: detailBaseFilePath || undefined,
resolveMediaSrc: resolveWorkspaceMediaSrc,
}),
[detailBaseFilePath, onOpenWorkspacePath, resolveWorkspaceMediaSrc],
);
2026-03-13 06:40:54 +00:00
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 ? (
2026-03-15 07:14:01 +00:00
<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'}
>
2026-03-13 06:40:54 +00:00
<Eye size={14} />
</LucentIconButton>
) : null}
2026-03-15 07:14:01 +00:00
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
disabled={Boolean(deleteSavingById[itemId])}
onClick={() => onDeleteItem(item)}
tooltip={isZh ? '删除消息' : 'Delete item'}
aria-label={isZh ? '删除消息' : 'Delete item'}
>
{deleteSavingById[itemId] ? <RefreshCw size={14} className="animate-spin" /> : <Trash2 size={14} />}
</LucentIconButton>
2026-03-13 06:40:54 +00:00
{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(
2026-04-03 15:00:08 +00:00
<PreviewModalShell
2026-04-02 12:27:06 +00:00
closeLabel={isZh ? '关闭详情' : 'Close detail'}
onClose={closeDetail}
subtitle={detailTitle || (isZh ? '原文详情' : 'Raw detail')}
title={isZh ? '主题详情' : 'Topic detail'}
>
2026-03-13 06:40:54 +00:00
<div className="workspace-preview-body markdown">
<div className="workspace-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(detailContent)}
</ReactMarkdown>
</div>
</div>
2026-04-03 15:00:08 +00:00
</PreviewModalShell>,
2026-03-13 06:40:54 +00:00
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>
);
}