- 移除前端localStorage依赖,改用后端SQLite作为唯一数据源 - 新增getWanchuanConfig和saveWanchuanConfig函数用于配置读写 - 添加getBoundKnowledgeBase函数统一获取绑定知识库信息 - 支持桌面应用端口变化时正确读取配置 refactor(settings): 重构万川平台配置管理逻辑 - 移除localStorage配置存储,改为后端API调用 - 实现配置自动恢复和防抖保存机制 - 添加token过期自动重登功能 - 优化知识库选择和连接状态管理 fix(knowledge): 修复知识库上传异步问题 - 将getBoundKnowledgeBase调用改为await异步处理 - 统一各页面的知识库信息获取方式 - 修正上传接口datasetId使用逻辑 feat(electron): 添加chatlog.exe存在性检查 - 新增ensureChatlogExe函数验证执行文件存在 - 防止杀毒软件误删导致的ENONENT错误 - 提供用户友好的错误提示和解决方案
1192 lines
50 KiB
JavaScript
1192 lines
50 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Plus, Trash2, Sparkles, RefreshCw, ChevronRight, Loader, Settings2, X, Download, Upload } from 'lucide-react'
|
||
import dayjs from 'dayjs'
|
||
import {
|
||
getGroups,
|
||
createGroup,
|
||
patchGroup,
|
||
initGroup,
|
||
deleteGroup,
|
||
getTopics,
|
||
getTopic,
|
||
createTopic,
|
||
deleteTopic,
|
||
summarizeTopic,
|
||
addTopicMessage,
|
||
removeTopicMessage,
|
||
getTask,
|
||
getChatrooms,
|
||
getChatlog,
|
||
getChatroomMembers,
|
||
} from '../api'
|
||
import MessageBubble from '../components/MessageBubble'
|
||
import ReportDocumentView from '../components/ReportDocumentView'
|
||
import { exportWordDoc } from '../utils/wordExport'
|
||
import { uploadToKnowledgeBase, getWanchuanKnowledgeBases, getBoundKnowledgeBase } from '../api/wanchuan'
|
||
|
||
const TYPE_MAP = {
|
||
1: 0,
|
||
3: 1,
|
||
34: 2,
|
||
43: 3,
|
||
47: 5,
|
||
49: 7,
|
||
10000: 80,
|
||
10002: 81,
|
||
}
|
||
|
||
const ANALYZE_PRESETS = [
|
||
{ label: '今天', getDates: () => [dayjs().startOf('day'), dayjs()] },
|
||
{ label: '昨天', getDates: () => [dayjs().subtract(1, 'day').startOf('day'), dayjs().subtract(1, 'day').endOf('day')] },
|
||
{ label: '近7天', getDates: () => [dayjs().subtract(7, 'day').startOf('day'), dayjs()] },
|
||
{ label: '近30天', getDates: () => [dayjs().subtract(30, 'day').startOf('day'), dayjs()] },
|
||
]
|
||
|
||
function convertTopicMsgType(rawType, subType) {
|
||
if (Number(rawType) === 49 && Number(subType) === 62) return 82
|
||
return TYPE_MAP[Number(rawType)] ?? 0
|
||
}
|
||
|
||
function toMsgBubbleFormat(m, index) {
|
||
const rawType = Number(m.type ?? 1)
|
||
const subType = Number(m.sub_type ?? m.subType ?? 0)
|
||
const isFile = Boolean(m.is_file) || (rawType === 49 && subType === 6)
|
||
const fileMd5 = m.file_md5 || m.fileMd5 || m.contents?.md5 || ''
|
||
const fileName = m.file_name || m.fileName || m.contents?.title || ''
|
||
const displayName = (
|
||
m.sender_name ||
|
||
m.senderName ||
|
||
m.SenderName ||
|
||
m.groupNickname ||
|
||
m.accountName ||
|
||
m.sender ||
|
||
'未知'
|
||
)
|
||
|
||
return {
|
||
id: String(m.seq || m.id || index),
|
||
sender: m.sender || '',
|
||
accountName: displayName,
|
||
groupNickname: displayName,
|
||
timestamp: m.create_time ? dayjs(m.create_time).unix() : (m.timestamp || 0),
|
||
type: convertTopicMsgType(rawType, subType),
|
||
content: m.content || '',
|
||
subType,
|
||
mediaKey: m.media_key || m.mediaKey || '',
|
||
voiceKey: m.voice_key || m.voiceKey || '',
|
||
mediaMd5: m.media_key || m.mediaMd5 || '',
|
||
mediaPath: m.mediaPath || '',
|
||
emojiUrl: m.contents?.cdnurl || m.emojiUrl || '',
|
||
linkTitle: isFile ? '' : (m.link_title || m.linkTitle || m.contents?.title || ''),
|
||
linkDesc: isFile ? '' : (m.link_desc || m.linkDesc || m.contents?.desc || ''),
|
||
linkUrl: isFile ? '' : (m.link_url || m.linkUrl || m.contents?.url || m.content || ''),
|
||
linkThumb: isFile ? '' : (m.link_thumb || m.linkThumb || m.contents?.thumbUrl || ''),
|
||
linkSource: isFile ? '' : (m.link_source || m.linkSource || m.contents?.sourceName || ''),
|
||
quote: m.quote || null,
|
||
isFile,
|
||
fileName,
|
||
fileMd5,
|
||
fileUrl: m.file_url || m.fileUrl || (fileMd5 ? `/api/files/${encodeURIComponent(fileMd5)}?filename=${encodeURIComponent(fileName || fileMd5)}` : ''),
|
||
}
|
||
}
|
||
|
||
function topicMsgPreview(m) {
|
||
return m.content || m.link_title || m.linkTitle || m.file_name || m.fileName || m.contents?.title || m.quote?.content || '[媒体消息]'
|
||
}
|
||
|
||
function compactText(value, max = 28) {
|
||
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||
if (!text) return ''
|
||
return text.length > max ? `${text.slice(0, max)}...` : text
|
||
}
|
||
|
||
export default function TopicsPage({ sessions = [], onToast }) {
|
||
const [groups, setGroups] = useState([])
|
||
const [selectedGroup, setSelectedGroup] = useState(null)
|
||
const [topics, setTopics] = useState([])
|
||
const [selectedTopic, setSelectedTopic] = useState(null)
|
||
const [topicDetail, setTopicDetail] = useState(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [newTopicTitle, setNewTopicTitle] = useState('')
|
||
|
||
const [showAddGroup, setShowAddGroup] = useState(false)
|
||
const [selectedTalker, setSelectedTalker] = useState('')
|
||
const [addingGroup, setAddingGroup] = useState(false)
|
||
const [deletingGroupId, setDeletingGroupId] = useState(null)
|
||
const [addGroupKeyword, setAddGroupKeyword] = useState('')
|
||
const [groupSearchResults, setGroupSearchResults] = useState([])
|
||
const [groupSearchLoading, setGroupSearchLoading] = useState(false)
|
||
const [groupSearchError, setGroupSearchError] = useState('')
|
||
const [showGroupDropdown, setShowGroupDropdown] = useState(false)
|
||
const groupSearchSeqRef = useRef(0)
|
||
|
||
const [hoveredGroupId, setHoveredGroupId] = useState(null)
|
||
const [showMsgManager, setShowMsgManager] = useState(false)
|
||
const [allMsgs, setAllMsgs] = useState([])
|
||
const [msgSearch, setMsgSearch] = useState('')
|
||
const [msgLoading, setMsgLoading] = useState(false)
|
||
const [msgStartDate, setMsgStartDate] = useState(dayjs().subtract(7, 'day').startOf('day').format('YYYY-MM-DDTHH:mm'))
|
||
const [msgEndDate, setMsgEndDate] = useState(dayjs().format('YYYY-MM-DDTHH:mm'))
|
||
const [msgMembers, setMsgMembers] = useState([])
|
||
const [msgAllMembers, setMsgAllMembers] = useState([])
|
||
|
||
const [analyzeStartDate, setAnalyzeStartDate] = useState(dayjs().subtract(7, 'day').startOf('day').format('YYYY-MM-DDTHH:mm'))
|
||
const [analyzeEndDate, setAnalyzeEndDate] = useState(dayjs().format('YYYY-MM-DDTHH:mm'))
|
||
const [analyzePreset, setAnalyzePreset] = useState('近7天')
|
||
const [initStateByGroup, setInitStateByGroup] = useState({})
|
||
const [summarizing, setSummarizing] = useState(false)
|
||
const [summarizeProgress, setSummarizeProgress] = useState(null)
|
||
const [showGroupPrompt, setShowGroupPrompt] = useState(false)
|
||
const [groupPromptDraft, setGroupPromptDraft] = useState('')
|
||
const [savingGroupPrompt, setSavingGroupPrompt] = useState(false)
|
||
|
||
|
||
const [uploadingToKb, setUploadingToKb] = useState(false)
|
||
const loadGroups = useCallback(async () => {
|
||
try {
|
||
const data = await getGroups()
|
||
setGroups(Array.isArray(data) ? data : [])
|
||
return Array.isArray(data) ? data : []
|
||
} catch {
|
||
setGroups([])
|
||
return []
|
||
}
|
||
}, [])
|
||
|
||
const resetAddGroupState = useCallback(() => {
|
||
groupSearchSeqRef.current += 1
|
||
setSelectedTalker('')
|
||
setAddGroupKeyword('')
|
||
setGroupSearchResults([])
|
||
setGroupSearchError('')
|
||
setGroupSearchLoading(false)
|
||
setShowGroupDropdown(false)
|
||
}, [])
|
||
|
||
const openAddGroup = useCallback(() => {
|
||
resetAddGroupState()
|
||
setShowAddGroup(true)
|
||
setShowGroupDropdown(true)
|
||
}, [resetAddGroupState])
|
||
|
||
const closeAddGroup = useCallback(() => {
|
||
setShowAddGroup(false)
|
||
resetAddGroupState()
|
||
}, [resetAddGroupState])
|
||
|
||
useEffect(() => {
|
||
loadGroups()
|
||
}, [loadGroups])
|
||
|
||
const addGroupKeywordText = addGroupKeyword.trim()
|
||
const recentGroupResults = useMemo(() => {
|
||
const monitored = new Set()
|
||
groups.forEach((group) => {
|
||
if (group?.talker) monitored.add(group.talker)
|
||
if (group?.id) monitored.add(group.id)
|
||
})
|
||
|
||
return sessions
|
||
.filter((room) => room?.id && room.isGroup && !monitored.has(room.id))
|
||
.slice()
|
||
.sort((a, b) => (Number(b.lastTime) || 0) - (Number(a.lastTime) || 0))
|
||
.slice(0, 100)
|
||
}, [groups, sessions])
|
||
const displayGroupResults = addGroupKeywordText ? groupSearchResults : recentGroupResults
|
||
|
||
useEffect(() => {
|
||
if (!showAddGroup) return undefined
|
||
|
||
const keyword = addGroupKeywordText
|
||
if (!keyword) {
|
||
groupSearchSeqRef.current += 1
|
||
setGroupSearchResults([])
|
||
setGroupSearchError('')
|
||
setGroupSearchLoading(false)
|
||
return undefined
|
||
}
|
||
|
||
const seq = groupSearchSeqRef.current + 1
|
||
groupSearchSeqRef.current = seq
|
||
setGroupSearchLoading(true)
|
||
setGroupSearchError('')
|
||
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
const res = await getChatrooms(keyword)
|
||
if (groupSearchSeqRef.current !== seq) return
|
||
|
||
const monitored = new Set(groups.map((g) => g.talker))
|
||
const items = (res?.data || [])
|
||
.filter((room) => room?.id && !monitored.has(room.id))
|
||
.slice(0, 100)
|
||
setGroupSearchResults(items)
|
||
} catch (e) {
|
||
if (groupSearchSeqRef.current !== seq) return
|
||
setGroupSearchResults([])
|
||
setGroupSearchError(e?.response?.data?.detail || e?.message || '搜索失败')
|
||
} finally {
|
||
if (groupSearchSeqRef.current === seq) setGroupSearchLoading(false)
|
||
}
|
||
}, 250)
|
||
|
||
return () => clearTimeout(timer)
|
||
}, [showAddGroup, addGroupKeywordText, groups])
|
||
|
||
const loadTopics = useCallback(async (groupId) => {
|
||
setLoading(true)
|
||
setTopics([])
|
||
setSelectedTopic(null)
|
||
setTopicDetail(null)
|
||
try {
|
||
const data = await getTopics({ group_id: groupId })
|
||
setTopics(Array.isArray(data) ? data : [])
|
||
} catch {
|
||
setTopics([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
const handleSelectGroup = useCallback((group) => {
|
||
setSelectedGroup(group)
|
||
loadTopics(group.id)
|
||
}, [loadTopics])
|
||
|
||
const openGroupPrompt = () => {
|
||
if (!selectedGroup) return
|
||
setGroupPromptDraft(selectedGroup.analysis_prompt || '')
|
||
setShowGroupPrompt(true)
|
||
}
|
||
|
||
const handleSaveGroupPrompt = async () => {
|
||
if (!selectedGroup || savingGroupPrompt) return
|
||
setSavingGroupPrompt(true)
|
||
try {
|
||
const updated = await patchGroup(selectedGroup.id, { analysis_prompt: groupPromptDraft })
|
||
const groupData = updated?.data ?? updated
|
||
setGroups((prev) => prev.map((g) => (g.id === selectedGroup.id ? { ...g, ...groupData } : g)))
|
||
setSelectedGroup((prev) => prev ? { ...prev, ...groupData } : prev)
|
||
setShowGroupPrompt(false)
|
||
onToast?.('AI 分析提示词已保存')
|
||
} catch (e) {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '保存提示词失败', 'error')
|
||
} finally {
|
||
setSavingGroupPrompt(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteGroup = async (e, group) => {
|
||
e.stopPropagation()
|
||
if (deletingGroupId) return
|
||
if (!window.confirm(`确定删除“${group.name || group.talker}”吗?\n相关话题和报告也会一起删除。`)) return
|
||
|
||
setDeletingGroupId(group.id)
|
||
try {
|
||
await deleteGroup(group.id)
|
||
setGroups((prev) => prev.filter((g) => g.id !== group.id))
|
||
if (selectedGroup?.id === group.id) {
|
||
setSelectedGroup(null)
|
||
setTopics([])
|
||
setSelectedTopic(null)
|
||
setTopicDetail(null)
|
||
}
|
||
resetAddGroupState()
|
||
onToast?.('群聊已删除')
|
||
} catch (e2) {
|
||
onToast?.(e2?.response?.data?.detail || e2?.message || '删除失败', 'error')
|
||
} finally {
|
||
setDeletingGroupId(null)
|
||
}
|
||
}
|
||
|
||
const handleAddGroup = async () => {
|
||
if (!selectedTalker || addingGroup) return
|
||
const result = groupSearchResults.find((s) => s.id === selectedTalker)
|
||
const session = result || sessions.find((s) => s.id === selectedTalker)
|
||
|
||
setAddingGroup(true)
|
||
try {
|
||
const created = await createGroup(selectedTalker, session?.name || addGroupKeyword || selectedTalker)
|
||
onToast?.('群聊已添加')
|
||
closeAddGroup()
|
||
const nextGroups = await loadGroups()
|
||
const added = nextGroups.find((g) => g.id === created?.id || g.talker === selectedTalker) || created
|
||
if (added?.id) handleSelectGroup(added)
|
||
} catch (e) {
|
||
if (e?.response?.status === 409) {
|
||
onToast?.('该群聊已在监控列表中', 'error')
|
||
loadGroups()
|
||
} else {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '添加失败', 'error')
|
||
}
|
||
} finally {
|
||
setAddingGroup(false)
|
||
}
|
||
}
|
||
|
||
const applyAnalyzePreset = (preset) => {
|
||
const [start, end] = preset.getDates()
|
||
setAnalyzeStartDate(start.format('YYYY-MM-DDTHH:mm'))
|
||
setAnalyzeEndDate(end.format('YYYY-MM-DDTHH:mm'))
|
||
setAnalyzePreset(preset.label)
|
||
}
|
||
|
||
const handleInitGroup = async (group) => {
|
||
try {
|
||
const cfg = await fetch('/api/settings').then((r) => r.json())
|
||
if (!cfg.ai_api_key) {
|
||
onToast?.('请先配置 AI API Key', 'error')
|
||
return
|
||
}
|
||
if (!cfg.ai_model) {
|
||
onToast?.('请先配置话题分析模型', 'error')
|
||
return
|
||
}
|
||
} catch {
|
||
onToast?.('读取 AI 设置失败', 'error')
|
||
return
|
||
}
|
||
|
||
const startTime = dayjs(analyzeStartDate).unix()
|
||
const endTime = dayjs(analyzeEndDate).unix()
|
||
if (!startTime || !endTime || endTime <= startTime) {
|
||
onToast?.('请选择有效的分析时间范围', 'error')
|
||
return
|
||
}
|
||
|
||
setInitStateByGroup((prev) => ({
|
||
...prev,
|
||
[group.id]: { taskId: null, status: 'running', progress: null },
|
||
}))
|
||
|
||
let taskId = null
|
||
try {
|
||
const res = await initGroup(group.id, { startTime, endTime })
|
||
taskId = res?.data?.task_id ?? res?.task_id
|
||
onToast?.('AI 分析已开始')
|
||
} catch (e) {
|
||
const detail = e?.response?.data?.detail || e?.message
|
||
onToast?.(detail || 'AI 分析启动失败', 'error')
|
||
setInitStateByGroup((prev) => {
|
||
const next = { ...prev }
|
||
delete next[group.id]
|
||
return next
|
||
})
|
||
return
|
||
}
|
||
|
||
if (!taskId) {
|
||
setTimeout(() => {
|
||
loadTopics(group.id)
|
||
setInitStateByGroup((prev) => {
|
||
const next = { ...prev }
|
||
delete next[group.id]
|
||
return next
|
||
})
|
||
}, 5000)
|
||
return
|
||
}
|
||
|
||
setInitStateByGroup((prev) => ({
|
||
...prev,
|
||
[group.id]: { taskId, status: 'running', progress: null },
|
||
}))
|
||
|
||
let netFails = 0
|
||
const poll = setInterval(async () => {
|
||
try {
|
||
const task = await getTask(taskId)
|
||
const taskData = task?.data ?? task
|
||
netFails = 0
|
||
|
||
let progress = null
|
||
try {
|
||
progress = taskData.progress ? JSON.parse(taskData.progress) : null
|
||
} catch {
|
||
progress = null
|
||
}
|
||
|
||
setInitStateByGroup((prev) => ({
|
||
...prev,
|
||
[group.id]: { taskId, status: taskData.status || 'running', progress },
|
||
}))
|
||
|
||
if (taskData.status === 'done') {
|
||
clearInterval(poll)
|
||
setSelectedTopic(null)
|
||
setTopicDetail(null)
|
||
loadTopics(group.id)
|
||
onToast?.('AI 分析完成')
|
||
setTimeout(() => {
|
||
setInitStateByGroup((prev) => {
|
||
const next = { ...prev }
|
||
delete next[group.id]
|
||
return next
|
||
})
|
||
}, 800)
|
||
} else if (taskData.status === 'error') {
|
||
clearInterval(poll)
|
||
onToast?.(`AI 分析失败:${taskData.error || '未知错误'}`, 'error')
|
||
setInitStateByGroup((prev) => {
|
||
const next = { ...prev }
|
||
delete next[group.id]
|
||
return next
|
||
})
|
||
}
|
||
} catch {
|
||
netFails += 1
|
||
if (netFails >= 5) {
|
||
clearInterval(poll)
|
||
setInitStateByGroup((prev) => {
|
||
const next = { ...prev }
|
||
delete next[group.id]
|
||
return next
|
||
})
|
||
}
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
const handleSelectTopic = async (topic) => {
|
||
setSelectedTopic(topic)
|
||
setTopicDetail(null)
|
||
try {
|
||
const detail = await getTopic(topic.id)
|
||
setTopicDetail(detail)
|
||
} catch {
|
||
setTopicDetail(null)
|
||
}
|
||
}
|
||
|
||
const handleCreateTopic = async () => {
|
||
if (!newTopicTitle.trim() || !selectedGroup) return
|
||
try {
|
||
await createTopic(selectedGroup.id, newTopicTitle.trim())
|
||
setNewTopicTitle('')
|
||
onToast?.('话题已创建')
|
||
loadTopics(selectedGroup.id)
|
||
} catch (e) {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '创建话题失败', 'error')
|
||
}
|
||
}
|
||
|
||
const handleDeleteTopic = async (topic) => {
|
||
try {
|
||
await deleteTopic(topic.id)
|
||
onToast?.('话题已删除')
|
||
setSelectedTopic(null)
|
||
setTopicDetail(null)
|
||
loadTopics(selectedGroup.id)
|
||
} catch (e) {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '删除话题失败', 'error')
|
||
}
|
||
}
|
||
|
||
const handleOpenMsgManager = async () => {
|
||
if (!selectedTopic || !selectedGroup) return
|
||
setShowMsgManager(true)
|
||
setMsgLoading(true)
|
||
setAllMsgs([])
|
||
setMsgSearch('')
|
||
|
||
const start = dayjs().subtract(7, 'day').startOf('day')
|
||
const end = dayjs()
|
||
setMsgStartDate(start.format('YYYY-MM-DDTHH:mm'))
|
||
setMsgEndDate(end.format('YYYY-MM-DDTHH:mm'))
|
||
setMsgMembers([])
|
||
|
||
try {
|
||
const [msgsRes, membersRes] = await Promise.all([
|
||
getChatlog({ talker: selectedGroup.talker, startTime: start.unix(), endTime: end.unix(), limit: 200, offset: 0 }),
|
||
getChatroomMembers(selectedGroup.talker),
|
||
])
|
||
setAllMsgs(msgsRes.data?.messages || [])
|
||
setMsgAllMembers(membersRes.data || [])
|
||
} catch (e) {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '加载消息失败', 'error')
|
||
} finally {
|
||
setMsgLoading(false)
|
||
}
|
||
}
|
||
|
||
const fetchMsgs = async () => {
|
||
if (!selectedGroup) return
|
||
setMsgLoading(true)
|
||
try {
|
||
const res = await getChatlog({
|
||
talker: selectedGroup.talker,
|
||
startTime: dayjs(msgStartDate).unix(),
|
||
endTime: dayjs(msgEndDate).unix(),
|
||
senders: msgMembers,
|
||
keyword: msgSearch,
|
||
limit: 200,
|
||
offset: 0,
|
||
})
|
||
setAllMsgs(res.data?.messages || [])
|
||
} catch (e) {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '搜索消息失败', 'error')
|
||
} finally {
|
||
setMsgLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleAddMsg = async (msg) => {
|
||
try {
|
||
await addTopicMessage(selectedTopic.id, Number(msg.id), selectedGroup.talker)
|
||
const detail = await getTopic(selectedTopic.id)
|
||
setTopicDetail(detail)
|
||
} catch (e) {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '添加消息失败', 'error')
|
||
}
|
||
}
|
||
|
||
const handleRemoveMsg = async (seq) => {
|
||
try {
|
||
await removeTopicMessage(selectedTopic.id, seq)
|
||
const detail = await getTopic(selectedTopic.id)
|
||
setTopicDetail(detail)
|
||
} catch (e) {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '移除消息失败', 'error')
|
||
}
|
||
}
|
||
|
||
const handleSummarize = async () => {
|
||
if (!selectedTopic) return
|
||
if (uploadingToKb) return
|
||
setSummarizing(true)
|
||
setSummarizeProgress(null)
|
||
try {
|
||
const res = await summarizeTopic(selectedTopic.id)
|
||
onToast?.('AI 报告生成已开始')
|
||
const taskId = res?.data?.task_id ?? res?.task_id
|
||
|
||
if (!taskId) {
|
||
setTimeout(() => {
|
||
handleSelectTopic(selectedTopic)
|
||
setSummarizing(false)
|
||
}, 5000)
|
||
return
|
||
}
|
||
|
||
let tries = 0
|
||
let handled = false
|
||
const poll = setInterval(async () => {
|
||
if (handled) return
|
||
tries += 1
|
||
try {
|
||
const task = await getTask(taskId)
|
||
const taskData = task?.data ?? task
|
||
try {
|
||
const progress = taskData.progress ? JSON.parse(taskData.progress) : null
|
||
if (progress) setSummarizeProgress(progress)
|
||
} catch {
|
||
// Ignore malformed progress payloads.
|
||
}
|
||
if (taskData.status === 'done') {
|
||
handled = true
|
||
clearInterval(poll)
|
||
handleSelectTopic(selectedTopic)
|
||
onToast?.('AI 报告生成完成')
|
||
// 自动上传知识库已关闭:报告生成完成后不再自动同步到万川知识库。
|
||
// 如需恢复,可在此处重新调用 uploadToKnowledgeBaseIfBound(selectedTopic.title, reportContent)。
|
||
setSummarizeProgress(null)
|
||
setSummarizing(false)
|
||
return
|
||
}
|
||
if (taskData.status === 'error') {
|
||
handled = true
|
||
clearInterval(poll)
|
||
setSummarizeProgress(null)
|
||
setSummarizing(false)
|
||
handleSelectTopic(selectedTopic)
|
||
onToast?.(taskData.error || 'AI 报告生成失败,请检查模型/API 后重试', 'error')
|
||
return
|
||
}
|
||
if (tries > 60) {
|
||
handled = true
|
||
clearInterval(poll)
|
||
setSummarizeProgress(null)
|
||
setSummarizing(false)
|
||
handleSelectTopic(selectedTopic)
|
||
onToast?.('AI 报告生成超时,请稍后重试', 'error')
|
||
}
|
||
} catch (e) {
|
||
if (tries > 60) {
|
||
handled = true
|
||
clearInterval(poll)
|
||
setSummarizeProgress(null)
|
||
setSummarizing(false)
|
||
handleSelectTopic(selectedTopic)
|
||
onToast?.(e?.response?.data?.detail || e?.message || '获取 AI 报告进度失败', 'error')
|
||
}
|
||
}
|
||
}, 3000)
|
||
} catch (e) {
|
||
onToast?.(e?.response?.data?.detail || e?.message || '报告生成失败', 'error')
|
||
setSummarizing(false)
|
||
setSummarizeProgress(null)
|
||
}
|
||
}
|
||
|
||
const handleExportReport = async () => {
|
||
const content = topicDetail?.knowledge_doc?.content || ''
|
||
if (!selectedTopic || !content) {
|
||
onToast?.('暂无可导出的报告内容', 'error')
|
||
return
|
||
}
|
||
try {
|
||
await exportWordDoc(selectedTopic.title || '话题报告', content)
|
||
onToast?.('Word 文档已导出')
|
||
} catch (e) {
|
||
onToast?.(e?.message || 'Word 导出失败', 'error')
|
||
}
|
||
}
|
||
|
||
// 上传报告到已绑定的万川知识库
|
||
const uploadToKnowledgeBaseIfBound = async (reportTitle, content) => {
|
||
if (uploadingToKb) return
|
||
setUploadingToKb(true)
|
||
try {
|
||
const bound = await getBoundKnowledgeBase()
|
||
if (!bound) return
|
||
// 上传接口必须使用 datasetId
|
||
const datasetId = bound.kbDatasetId || bound.id
|
||
if (!bound.platformUrl || !datasetId) return
|
||
|
||
const safeTitle = (reportTitle || 'report').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_').slice(0, 50)
|
||
const file = new File([blob], `${safeTitle}.md`, { type: 'text/markdown' })
|
||
|
||
// 上传接口使用 datasetId
|
||
await uploadToKnowledgeBase(bound.platformUrl, datasetId, file)
|
||
onToast?.('报告已同步上传到知识库')
|
||
} catch (e) {
|
||
console.error('[上传知识库失败]', e?.message || e)
|
||
onToast?.(`报告生成成功,但上传到知识库失败: ${e.message}`, 'error')
|
||
} finally {
|
||
setUploadingToKb(false)
|
||
}
|
||
}
|
||
|
||
const selectedInitState = selectedGroup ? initStateByGroup[selectedGroup.id] : null
|
||
const analyzing = selectedInitState?.status === 'running'
|
||
|
||
return (
|
||
<>
|
||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', height: '100%' }}>
|
||
<div style={{ width: 220, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<span style={{ fontWeight: 600, fontSize: 13 }}>群聊</span>
|
||
<button className="btn btn-ghost" style={{ padding: '2px 8px', fontSize: 12 }} onClick={openAddGroup}>
|
||
<Plus size={13} /> 添加
|
||
</button>
|
||
</div>
|
||
|
||
{showAddGroup && (
|
||
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||
<div style={{ position: 'relative', marginBottom: 6 }}>
|
||
<input
|
||
className="filter-input"
|
||
style={{ width: '100%', boxSizing: 'border-box' }}
|
||
placeholder="搜索群聊名称..."
|
||
value={addGroupKeyword}
|
||
onChange={(e) => {
|
||
setAddGroupKeyword(e.target.value)
|
||
setSelectedTalker('')
|
||
setShowGroupDropdown(true)
|
||
}}
|
||
onFocus={() => setShowGroupDropdown(true)}
|
||
/>
|
||
{showGroupDropdown && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: '100%',
|
||
left: 0,
|
||
right: 0,
|
||
zIndex: 100,
|
||
background: 'var(--bg-surface)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 6,
|
||
maxHeight: 220,
|
||
overflowY: 'auto',
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||
}}>
|
||
{!addGroupKeywordText && (
|
||
<div style={{ padding: '8px 10px', fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>最近群聊</div>
|
||
)}
|
||
{groupSearchLoading && (
|
||
<div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>正在搜索...</div>
|
||
)}
|
||
{groupSearchError && (
|
||
<div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--danger)' }}>{groupSearchError}</div>
|
||
)}
|
||
{!groupSearchLoading && !groupSearchError && displayGroupResults.map((room) => (
|
||
<div
|
||
key={room.id}
|
||
onClick={() => {
|
||
setSelectedTalker(room.id)
|
||
setAddGroupKeyword(room.name || room.id)
|
||
setShowGroupDropdown(false)
|
||
}}
|
||
style={{
|
||
padding: '8px 10px',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
background: selectedTalker === room.id ? 'var(--bg-overlay)' : 'var(--bg-surface)',
|
||
borderBottom: '1px solid var(--border)',
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.background = selectedTalker === room.id ? 'var(--bg-overlay)' : 'var(--bg-surface)' }}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{room.name || room.id}</div>
|
||
{!addGroupKeywordText && room.lastTime > 0 && (
|
||
<div style={{ color: 'var(--text-muted)', fontSize: 10, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||
{dayjs.unix(room.lastTime).fromNow()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ marginTop: 2, color: 'var(--text-muted)', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{room.id}</div>
|
||
{!addGroupKeywordText && room.lastContent && (
|
||
<div style={{ marginTop: 3, color: 'var(--text-muted)', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{compactText(room.lastContent, 32)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{addGroupKeywordText && !groupSearchLoading && !groupSearchError && groupSearchResults.length === 0 && (
|
||
<div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>没有找到匹配的群聊。</div>
|
||
)}
|
||
{!addGroupKeywordText && !groupSearchLoading && !groupSearchError && recentGroupResults.length === 0 && (
|
||
<div style={{ padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>暂无可添加的最近群聊。</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<button className="btn btn-primary" style={{ flex: 1, fontSize: 12 }} disabled={!selectedTalker || addingGroup} onClick={handleAddGroup}>
|
||
{addingGroup ? '添加中...' : '确定'}
|
||
</button>
|
||
<button className="btn btn-ghost" style={{ fontSize: 12 }} disabled={addingGroup} onClick={closeAddGroup}>取消</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||
{groups.length === 0 && (
|
||
<div style={{ padding: 20, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||
暂无监控群聊。
|
||
</div>
|
||
)}
|
||
{groups.map((group) => (
|
||
<div
|
||
key={group.id}
|
||
onClick={() => handleSelectGroup(group)}
|
||
onMouseEnter={() => setHoveredGroupId(group.id)}
|
||
onMouseLeave={() => setHoveredGroupId(null)}
|
||
style={{
|
||
padding: '10px 14px',
|
||
cursor: 'pointer',
|
||
borderBottom: '1px solid var(--border)',
|
||
background: selectedGroup?.id === group.id ? 'var(--surface-2)' : 'transparent',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{group.name || group.talker}
|
||
</div>
|
||
</div>
|
||
{hoveredGroupId === group.id ? (
|
||
<button
|
||
className="btn btn-ghost"
|
||
onClick={(e) => handleDeleteGroup(e, group)}
|
||
disabled={deletingGroupId === group.id}
|
||
title="删除群聊"
|
||
style={{ color: 'var(--danger)', padding: '2px 4px', borderRadius: 4, flexShrink: 0 }}
|
||
>
|
||
{deletingGroupId === group.id ? <Loader size={13} /> : <Trash2 size={13} />}
|
||
</button>
|
||
) : (
|
||
<ChevronRight size={13} color="var(--text-muted)" />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ width: 260, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
{!selectedGroup ? (
|
||
<div className="empty-state" style={{ flex: 1 }}>
|
||
<div className="empty-state-icon">AI</div>
|
||
<div className="empty-state-title">请选择群聊</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
|
||
<div style={{ fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{selectedGroup.name || selectedGroup.talker}
|
||
</div>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ fontSize: 12, padding: '4px 6px', flexShrink: 0 }}
|
||
onClick={openGroupPrompt}
|
||
title="AI 分析提示词"
|
||
>
|
||
<Settings2 size={12} />
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 8 }}>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>分析所选时间段全部消息</div>
|
||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||
{ANALYZE_PRESETS.map((preset) => (
|
||
<div
|
||
key={preset.label}
|
||
className={`chip ${analyzePreset === preset.label ? 'active' : ''}`}
|
||
style={{ fontSize: 11, padding: '3px 8px' }}
|
||
onClick={() => applyAnalyzePreset(preset)}
|
||
>
|
||
{preset.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 11 }}>
|
||
<input
|
||
type="datetime-local"
|
||
className="filter-input"
|
||
value={analyzeStartDate}
|
||
onChange={(e) => { setAnalyzeStartDate(e.target.value); setAnalyzePreset('') }}
|
||
style={{ fontSize: 11, flex: 1, minWidth: 0 }}
|
||
/>
|
||
<span style={{ color: 'var(--text-muted)' }}>至</span>
|
||
<input
|
||
type="datetime-local"
|
||
className="filter-input"
|
||
value={analyzeEndDate}
|
||
onChange={(e) => { setAnalyzeEndDate(e.target.value); setAnalyzePreset('') }}
|
||
style={{ fontSize: 11, flex: 1, minWidth: 0 }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ flex: 1, fontSize: 12 }}
|
||
onClick={() => handleInitGroup(selectedGroup)}
|
||
disabled={analyzing}
|
||
>
|
||
{analyzing ? <><Loader size={12} /> 分析中...</> : <><Sparkles size={12} /> AI 分析</>}
|
||
</button>
|
||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => loadTopics(selectedGroup.id)}>
|
||
<RefreshCw size={12} />
|
||
</button>
|
||
</div>
|
||
|
||
{analyzing && (
|
||
<div style={{ marginTop: 8 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>
|
||
<span>分析中...</span>
|
||
<span>
|
||
{selectedInitState.progress
|
||
? `${selectedInitState.progress.processed ?? 0} / ${selectedInitState.progress.total > 0 ? selectedInitState.progress.total : '?'}`
|
||
: '准备中...'}
|
||
</span>
|
||
</div>
|
||
<div style={{ height: 4, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
|
||
<div style={{
|
||
height: '100%',
|
||
width: selectedInitState.progress && selectedInitState.progress.total > 0
|
||
? `${Math.min(100, (selectedInitState.progress.processed / selectedInitState.progress.total) * 100)}%`
|
||
: '0%',
|
||
background: 'var(--accent)',
|
||
borderRadius: 2,
|
||
transition: 'width 0.5s ease',
|
||
}} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 6 }}>
|
||
<input
|
||
className="filter-input"
|
||
style={{ flex: 1, fontSize: 12 }}
|
||
placeholder="新建手动话题..."
|
||
value={newTopicTitle}
|
||
onChange={(e) => setNewTopicTitle(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleCreateTopic()}
|
||
/>
|
||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={handleCreateTopic}>
|
||
<Plus size={13} />
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||
{loading && (
|
||
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>加载中...</div>
|
||
)}
|
||
{!loading && topics.length === 0 && (
|
||
<div style={{ padding: 20, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||
暂无话题。
|
||
</div>
|
||
)}
|
||
{topics.map((topic) => (
|
||
<div
|
||
key={topic.id}
|
||
onClick={() => handleSelectTopic(topic)}
|
||
style={{
|
||
padding: '10px 14px',
|
||
cursor: 'pointer',
|
||
borderBottom: '1px solid var(--border)',
|
||
background: selectedTopic?.id === topic.id ? 'var(--surface-2)' : 'transparent',
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{topic.title}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
|
||
{dayjs(topic.created_at).format('MM-DD HH:mm')} / {topic.status}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ padding: '2px 4px', opacity: 0.5 }}
|
||
onClick={(e) => { e.stopPropagation(); handleDeleteTopic(topic) }}
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
{!selectedTopic ? (
|
||
<div className="empty-state" style={{ flex: 1 }}>
|
||
<div className="empty-state-icon">话题</div>
|
||
<div className="empty-state-title">请选择话题</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div style={{ fontWeight: 600, fontSize: 15 }}>{selectedTopic.title}</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn btn-ghost" style={{ borderColor: 'rgba(99,102,241,0.4)', color: 'var(--accent-light)' }} onClick={handleOpenMsgManager}>
|
||
<Settings2 size={13} /> 管理消息
|
||
</button>
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ borderColor: 'rgba(99,102,241,0.4)', color: 'var(--accent-light)' }}
|
||
onClick={handleSummarize}
|
||
disabled={summarizing}
|
||
>
|
||
{summarizing ? <Loader size={13} /> : <Sparkles size={13} />}
|
||
{summarizing ? '生成中...' : 'AI 报告'}
|
||
</button>
|
||
{summarizing && (
|
||
<div style={{ width: 180 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--text-muted)', marginBottom: 3 }}>
|
||
<span>处理中...</span>
|
||
<span>{summarizeProgress ? `${summarizeProgress.processed ?? 0}/${summarizeProgress.total > 0 ? summarizeProgress.total : '?'}` : '准备中...'}</span>
|
||
</div>
|
||
<div style={{ height: 3, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
|
||
<div style={{
|
||
height: '100%',
|
||
width: summarizeProgress && summarizeProgress.total > 0
|
||
? `${Math.min(100, (summarizeProgress.processed / summarizeProgress.total) * 100)}%`
|
||
: '30%',
|
||
background: 'var(--accent)',
|
||
borderRadius: 2,
|
||
transition: 'width 0.5s ease',
|
||
}} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
|
||
{topicDetail?.knowledge_doc && (
|
||
<div style={{ marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600 }}>报告</div>
|
||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={handleExportReport}>
|
||
<Download size={13} /> 导出 Word
|
||
</button>
|
||
</div>
|
||
<ReportDocumentView content={topicDetail.knowledge_doc.content} />
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6 }}>
|
||
更新于 {dayjs(topicDetail.knowledge_doc.updated_at).format('YYYY-MM-DD HH:mm')}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{topicDetail && (
|
||
<div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8, fontWeight: 600 }}>
|
||
关联消息({topicDetail.messages?.length ?? 0} 条)
|
||
</div>
|
||
{(topicDetail.messages || []).length === 0 && (
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>该话题暂无消息。</div>
|
||
)}
|
||
{(topicDetail.messages || []).map((message, index) => (
|
||
<MessageBubble key={message.seq || index} msg={toMsgBubbleFormat(message, index)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!topicDetail && (
|
||
<div style={{ textAlign: 'center', padding: 20, color: 'var(--text-muted)', fontSize: 13 }}>加载中...</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{showMsgManager && selectedTopic && (
|
||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ background: '#ffffff', borderRadius: 12, width: 900, maxWidth: '95vw', height: '80vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
|
||
<div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||
<span style={{ fontWeight: 600, fontSize: 15 }}>{selectedTopic.title} / 消息管理</span>
|
||
<button onClick={() => setShowMsgManager(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<input type="datetime-local" className="filter-input" value={msgStartDate} onChange={(e) => setMsgStartDate(e.target.value)} style={{ fontSize: 12 }} />
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>至</span>
|
||
<input type="datetime-local" className="filter-input" value={msgEndDate} onChange={(e) => setMsgEndDate(e.target.value)} style={{ fontSize: 12 }} />
|
||
<select className="filter-input" style={{ fontSize: 12 }} value={msgMembers[0] || ''} onChange={(e) => setMsgMembers(e.target.value ? [e.target.value] : [])}>
|
||
<option value="">全部成员</option>
|
||
{msgAllMembers.map((member) => (
|
||
<option key={member.platformId} value={member.platformId}>{member.accountName}</option>
|
||
))}
|
||
</select>
|
||
<input className="filter-input" style={{ fontSize: 12, flex: 1, minWidth: 120 }} placeholder="搜索消息..." value={msgSearch} onChange={(e) => setMsgSearch(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fetchMsgs()} />
|
||
<button className="btn btn-primary btn-sm" onClick={fetchMsgs} style={{ fontSize: 12 }}>搜索</button>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||
<div style={{ flex: 1, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{ padding: '8px 14px', fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||
聊天记录消息
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||
{msgLoading && <div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>加载中...</div>}
|
||
{!msgLoading && (() => {
|
||
const topicSeqs = new Set((topicDetail?.messages || []).map((m) => Number(m.seq)))
|
||
return allMsgs.map((message) => (
|
||
<div key={message.id} style={{ display: 'flex', alignItems: 'flex-start', borderBottom: '1px solid var(--border)' }}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<MessageBubble msg={message} />
|
||
</div>
|
||
<div style={{ flexShrink: 0, padding: '12px 10px' }}>
|
||
{topicSeqs.has(Number(message.id)) ? (
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
style={{ fontSize: 12, color: 'var(--danger)', borderColor: 'rgba(243,139,168,0.4)' }}
|
||
title="从话题中移除"
|
||
onClick={() => handleRemoveMsg(Number(message.id))}
|
||
>
|
||
移除
|
||
</button>
|
||
) : (
|
||
<button className="btn btn-ghost btn-sm" style={{ fontSize: 12 }} onClick={() => handleAddMsg(message)}>添加</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ width: 340, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{ padding: '8px 14px', fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||
话题消息
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
|
||
{(topicDetail?.messages || []).length === 0 && (
|
||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>暂无已选消息。</div>
|
||
)}
|
||
{(topicDetail?.messages || []).map((message) => (
|
||
<div key={message.seq} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 14px', borderBottom: '1px solid var(--border)' }}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>{message.sender_name || message.sender}</div>
|
||
<div style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{topicMsgPreview(message)}
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-ghost btn-sm" style={{ flexShrink: 0, fontSize: 12, color: 'var(--danger)' }} onClick={() => handleRemoveMsg(message.seq)}>移除</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showGroupPrompt && selectedGroup && (
|
||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ background: '#fff', borderRadius: 10, width: 620, maxWidth: '92vw', boxShadow: '0 8px 32px rgba(0,0,0,0.2)', overflow: 'hidden' }}>
|
||
<div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 600, fontSize: 15 }}>AI 分析提示词</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{selectedGroup.name || selectedGroup.talker}</div>
|
||
</div>
|
||
<button onClick={() => setShowGroupPrompt(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
<div style={{ padding: 18 }}>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||
留空则使用设置页的全局默认提示词;填写后只对当前群生效。
|
||
</div>
|
||
<textarea
|
||
value={groupPromptDraft}
|
||
placeholder="例如:这个群主要分析某企业的设备售后问题,请按设备型号、故障现象、处理进度归类;不要把日常闲聊单独生成售后话题。"
|
||
onChange={(e) => setGroupPromptDraft(e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
minHeight: 180,
|
||
boxSizing: 'border-box',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 8,
|
||
padding: '10px 12px',
|
||
fontSize: 13,
|
||
lineHeight: 1.6,
|
||
resize: 'vertical',
|
||
outline: 'none',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
/>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14 }}>
|
||
<button className="btn btn-ghost" onClick={() => setShowGroupPrompt(false)} disabled={savingGroupPrompt}>取消</button>
|
||
<button className="btn btn-primary" onClick={handleSaveGroupPrompt} disabled={savingGroupPrompt}>
|
||
{savingGroupPrompt ? <Loader size={13} /> : <Settings2 size={13} />}
|
||
{savingGroupPrompt ? '保存中...' : '保存提示词'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|