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