370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|