Initial upload for secondary development

This commit is contained in:
2026-06-08 19:00:03 +08:00
commit b913b8c78c
81 changed files with 27139 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { MessageSquare, BookOpen, Bot, Settings, Wifi, Users, Search } from 'lucide-react'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
import ChatlogPage from './pages/ChatlogPage'
import TopicsPage from './pages/TopicsPage'
import KnowledgePage from './pages/KnowledgePage'
import SettingsPage from './pages/SettingsPage'
import { getSessions } from './api'
import './index.css'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const NAV_ITEMS = [
{ id: 'chatlog', label: '聊天记录', icon: MessageSquare },
{ id: 'topics', label: 'AI 话题分析', icon: Bot },
{ id: 'knowledge', label: '报告库', icon: BookOpen },
{ id: 'settings', label: '设置', icon: Settings },
]
// ── 持久化缓存localStorage用于账号指纹检测 ──────
const CACHE_KEY = 'chatlab_sessions_v3'
const ACCOUNT_KEY = 'chatlab_account_fingerprint'
function loadSessionCache() {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (!raw) return []
return JSON.parse(raw)
} catch { return [] }
}
function saveSessionCache(sessions) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(sessions))
} catch {}
}
// 账号指纹取前3个会话 id 拼接,用于检测账号是否切换
function calcFingerprint(sessions) {
return sessions.slice(0, 3).map(s => s.id).join('|')
}
function loadAccountFingerprint() {
try { return localStorage.getItem(ACCOUNT_KEY) || '' } catch { return '' }
}
function saveAccountFingerprint(fp) {
try { localStorage.setItem(ACCOUNT_KEY, fp) } catch {}
}
export default function App() {
const [activeNav, setActiveNav] = useState('chatlog')
// sessions 初始为空数组,不从 localStorage 冷加载,避免旧账号数据闪现
const [sessions, setSessions] = useState([])
const [selectedRoom, setSelectedRoom] = useState(null)
const [toasts, setToasts] = useState([])
const [loadError, setLoadError] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(false)
const [, setTick] = useState(0)
// ── 加载会话列表(含账号指纹检测,账号切换时清空旧缓存) ──
const loadSessions = useCallback((isPolling = false) => {
if (!isPolling) setLoading(true)
getSessions()
.then((res) => {
const list = res?.data || []
list.sort((a, b) => b.lastTime - a.lastTime)
// 账号指纹检测:若与上次不同说明账号已切换,清空旧数据
const newFp = calcFingerprint(list)
const oldFp = loadAccountFingerprint()
if (oldFp && newFp && oldFp !== newFp) {
// 账号已切换:清空缓存、重置选中群
localStorage.removeItem(CACHE_KEY)
setSelectedRoom(null)
}
saveAccountFingerprint(newFp)
setSessions(list)
saveSessionCache(list)
// 首次加载(非轮询)且尚未选中群时,选中第一个
if (!isPolling && list.length > 0 && !selectedRoom) setSelectedRoom(list[0])
})
.catch((e) => {
if (isPolling) return // 轮询失败静默处理
const status = e?.response?.status
const detail = e?.response?.data?.detail || e?.response?.data?.error || ''
if (!e?.response) {
setLoadError('无法连接服务,请确认已启动 chatlog server端口 5030和 FastAPI端口 8000')
} else if (status >= 500) {
setLoadError(detail || `FastAPI 服务内部错误 (${status}),请检查后端日志`)
} else {
setLoadError(detail || `请求失败:${e.message || status || '未知错误'}`)
}
console.error('[App] getSessions failed', e)
})
.finally(() => { if (!isPolling) setLoading(false) })
}, []) // eslint-disable-line
useEffect(() => {
loadSessions(false)
}, []) // eslint-disable-line
// ── 每 30 秒轮询一次,检测账号是否切换 ──
useEffect(() => {
const pollId = setInterval(() => loadSessions(true), 30_000)
return () => clearInterval(pollId)
}, [loadSessions])
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 60_000)
return () => clearInterval(id)
}, [])
// ── 显示列表:搜索过滤 ──
const displaySessions = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return sessions
return sessions.filter(s =>
s.name?.toLowerCase().includes(q) ||
s.id?.toLowerCase().includes(q)
)
}, [sessions, searchQuery])
const addToast = (message, type = 'success') => {
const id = Date.now()
setToasts((t) => [...t, { id, message, type }])
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3000)
}
// SSE 新消息到达时更新对应会话的 lastTime/lastContent
const handleNewMessage = useCallback((roomId, msg) => {
setSessions(prev => {
const next = prev.map(s =>
s.id === roomId
? { ...s, lastTime: msg.timestamp, lastContent: msg.content || '' }
: s
)
next.sort((a, b) => b.lastTime - a.lastTime)
saveSessionCache(next)
return next
})
}, [])
return (
<div className="app-shell">
{/* ─── Sidebar ─────────────────────────────── */}
<aside className="sidebar">
{/* Logo */}
<div className="sidebar-logo">
<div className="sidebar-logo-icon">
<img src="/company-logo.jpg" alt="" />
</div>
<span className="sidebar-logo-text">灵泽万川ChatLab</span>
<span className="sidebar-logo-version">MVP</span>
</div>
{/* Nav */}
<nav className="sidebar-nav">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
return (
<div
key={item.id}
className={`nav-item ${activeNav === item.id ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`}
onClick={() => !item.disabled && setActiveNav(item.id)}
style={item.disabled ? { opacity: 0.4, cursor: 'not-allowed' } : {}}
title={item.disabled ? '开发中...' : ''}
>
<Icon size={16} />
{item.label}
{item.badge && (
<span style={{
marginLeft: 'auto', fontSize: 10,
padding: '1px 5px', background: 'var(--warning-dim)',
color: 'var(--warning)', borderRadius: 6, fontWeight: 600,
}}>
{item.badge}
</span>
)}
</div>
)
})}
</nav>
{/* Session / Room List */}
<div className="room-list">
<div className="room-list-header">最近会话</div>
{/* 搜索框 */}
<div style={{
padding: '6px 10px 8px',
borderBottom: '1px solid var(--border)',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'var(--surface-2)',
border: '1px solid var(--border)',
borderRadius: 7, padding: '4px 8px',
}}>
<Search size={12} color="var(--text-muted)" style={{ flexShrink: 0 }} />
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索群 / 联系人..."
style={{
flex: 1, background: 'transparent', border: 'none',
outline: 'none', fontSize: 12,
color: 'var(--text-primary)',
}}
/>
{searchQuery && (
<span
onClick={() => setSearchQuery('')}
style={{ cursor: 'pointer', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1 }}
>×</span>
)}
</div>
</div>
{loadError && (
<div style={{ padding: '12px', fontSize: 12, color: 'var(--danger)', lineHeight: 1.5 }}>
{loadError}
</div>
)}
{!loadError && sessions.length === 0 && loading && (
<div style={{ padding: '12px', fontSize: 12, color: 'var(--text-muted)' }}>
加载中...
</div>
)}
{!loadError && sessions.length > 0 && displaySessions.length === 0 && (
<div style={{ padding: '16px 12px', fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
未找到匹配的会话
</div>
)}
{displaySessions.map((room) => (
<div
key={room.id}
className={`room-item ${selectedRoom?.id === room.id ? 'active' : ''}`}
onClick={() => { setSelectedRoom(room); setActiveNav('chatlog') }}
id={`room-${room.id}`}
>
<div className="room-avatar" style={{ background: getRoomColor(room.id) }}>
{room.isGroup ? <Users size={14} /> : (room.name?.slice(0, 2) || '??')}
</div>
<div className="room-info">
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 4 }}>
<div className="room-name" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{room.name}
</div>
{room.lastTime > 0 && (
<div style={{ fontSize: 10, color: 'var(--text-muted)', flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs.unix(room.lastTime).fromNow()}
</div>
)}
</div>
<div className="room-meta" style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>
{room.lastContent
? truncate(room.lastContent, 18)
: (room.isGroup ? '群聊' : '私聊')}
</div>
</div>
</div>
))}
</div>
</aside>
{/* ─── Main ─────────────────────────────────── */}
<div className="main-content">
{/* Topbar */}
<div className="topbar">
<div>
<div className="topbar-title">
{selectedRoom ? selectedRoom.name : '聊天记录检索'}
</div>
{selectedRoom && (
<div className="topbar-subtitle">
{selectedRoom.isGroup ? '微信群聊' : '私聊'} · {selectedRoom.id}
</div>
)}
</div>
<div className="topbar-actions">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-muted)' }}>
<Wifi size={13} />
chatlog API: 127.0.0.1:5030
</div>
</div>
</div>
{/* Page Content */}
{activeNav === 'chatlog' && (
<ChatlogPage room={selectedRoom} onToast={addToast} onNewMessage={handleNewMessage} />
)}
{activeNav === 'topics' && (
<TopicsPage sessions={sessions} onToast={addToast} />
)}
{activeNav === 'knowledge' && (
<KnowledgePage onToast={addToast} />
)}
{activeNav === 'settings' && (
<SettingsPage />
)}
</div>
{/* ─── Toasts ──────────────────────────────── */}
<div className="toast-container">
{toasts.map((t) => (
<div key={t.id} className={`toast ${t.type}`}>
{t.type === 'success' ? '✅' : '⚠️'} {t.message}
</div>
))}
</div>
</div>
)
}
const ROOM_COLORS = ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#3b82f6']
function getRoomColor(id) {
if (!id) return ROOM_COLORS[0]
let hash = 0
for (let c of id) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff
return ROOM_COLORS[Math.abs(hash) % ROOM_COLORS.length]
}
function truncate(str, maxLen) {
if (!str) return ''
const clean = str.replace(/\n/g, ' ')
return clean.length > maxLen ? clean.slice(0, maxLen) + '...' : clean
}

View File

@@ -0,0 +1,279 @@
/**
* API 接口层 — 对接 chatlog_fastAPI 业务层8000 端口,通过 vite proxy 转发)
*
* FastAPI 路由routers/search.py
* GET /api/search/chatrooms?keyword=&limit=&offset=
* GET /api/search/members?talker=&time=
* GET /api/search?talker=&time=&sender=&keyword=&page=&page_size=
*
* chatlog 底层字段说明FastAPI 透传不做字段转换):
* 群聊 : name / nickName / remark / owner / users
* 消息 : seq / time(ISO) / sender / senderName / talker / talkerName / type / subType / content
* 成员 : userName / displayName / msgCount / lastSpeakTime
*/
import axios from 'axios'
import dayjs from 'dayjs'
const api = axios.create({ timeout: 30000 })
api.interceptors.response.use(
(res) => res.data,
(err) => {
console.error('[API Error]', err.response?.data || err.message)
return Promise.reject(err)
}
)
// ─────────────────────────────────────────────
// 群聊列表
// FastAPI: GET /api/search/chatrooms?keyword=&limit=&offset=
// 返回: { total, items: [ { Name, NickName, Remark, Owner, Users } ] }
// ─────────────────────────────────────────────
export async function getChatrooms(keyword = '') {
const raw = await api.get('/api/search/chatrooms', { params: { keyword, limit: 100, offset: 0 } })
const items = Array.isArray(raw) ? raw : (raw.items || [])
const rooms = items.map((r) => ({
id: r.name || r.Name,
name: r.nickName || r.NickName || r.remark || r.Remark || r.name || r.Name,
memberCount: (r.users || r.Users)?.length ?? 0,
platform: 'wechat',
}))
return { data: rooms }
}
// ─────────────────────────────────────────────
// 会话列表(含最新消息预览和时间,来自微信原生 Session 表)
// FastAPI: GET /api/search/sessions?limit=500
// 返回: [{ userName, nickName, remark, content, nTime, nOrder }]
// ─────────────────────────────────────────────
export async function getSessions(keyword = '') {
const raw = await api.get('/api/search/sessions', { params: { keyword, limit: 500 } })
const items = Array.isArray(raw) ? raw : (raw.items || [])
const sessions = items.map((r) => ({
id: r.userName,
name: r.nickName || r.remark || r.userName,
platform: 'wechat',
lastContent: r.content || '',
lastTime: r.nTime ? dayjs(r.nTime).unix() : 0,
nOrder: r.nOrder || 0,
isGroup: r.userName?.endsWith('@chatroom'),
}))
return { data: sessions.filter(s => s.isGroup) }
}
// ─────────────────────────────────────────────
// 群成员列表(含发言统计)
// FastAPI: GET /api/search/members?talker=&time=
// 返回: { members: [{userName, displayName, msgCount, lastSpeakTime}], total }
// ─────────────────────────────────────────────
export async function getChatroomMembers(roomId) {
const raw = await api.get('/api/search/members', { params: { talker: roomId } })
const members = (raw.members || []).map((m) => ({
platformId: m.userName,
accountName: m.displayName || m.userName,
groupNickname: m.displayName,
}))
return { data: members }
}
// ─────────────────────────────────────────────
// 存量聊天记录(核心接口)
// FastAPI: GET /api/search?talker=&time=&sender=&keyword=&page=&page_size=
// time 格式: "YYYY-MM-DD,YYYY-MM-DD"(逗号分隔)
// limit/offset → page/page_sizepage 从 1 开始)
// 返回: { total, items: [ { Seq, Time, Sender, SenderName, Talker, TalkerName, Type, Content } ] }
// ─────────────────────────────────────────────
export async function getChatlog({
talker,
startTime,
endTime,
senders = [],
keyword = '',
limit = 100,
offset = 0,
}) {
let time = ''
if (startTime && endTime) {
const fmt = (unix) => dayjs.unix(unix).format('YYYY-MM-DD')
time = `${fmt(startTime)},${fmt(endTime)}`
}
const page = Math.floor(offset / limit) + 1
const page_size = limit
const params = {
talker,
page,
page_size,
...(time ? { time } : {}),
...(keyword ? { keyword } : {}),
...(senders.length > 0 ? { sender: senders.join(',') } : {}),
}
const raw = await api.get('/api/search', { params })
const items = raw.items || []
const messages = items.map((m) => {
const isFile = Number(m.type) === 49 && Number(m.subType) === 6
const fileMd5 = isFile ? (m.contents?.md5 || '') : ''
const fileName = isFile ? (m.contents?.title || m.contents?.fileName || m.contents?.filename || '') : ''
return {
id: String(m.seq),
sender: m.sender || '',
accountName: m.senderName || m.sender || '',
groupNickname: m.senderName || '',
timestamp: m.time ? dayjs(m.time).unix() : 0,
type: convertMsgType(m.type, m.subType),
content: m.content || '',
subType: m.subType,
talker: m.talker,
talkerName: m.talkerName,
// 媒体文件标识chatlog contents 字段各类型 key 不同)
// 图片: md5/rawmd5 → chatlog 按 md5 查库
// 视频: pathWindows 反斜杠需转成正斜杠让 handleMedia 走 findPath 分支)
// 语音: contents.voice = ServerID → /voice/{serverid}
// 文件: contents.md5 → /file/{md5}
// 表情包: contents.cdnurl = 外部 CDN 直链
mediaKey: m.contents?.rawmd5 || m.contents?.md5 || m.contents?.path?.replace(/\\/g, '/') || '',
voiceKey: m.contents?.voice || '', // 语音专用 ServerID key
mediaMd5: m.contents?.md5 || '',
mediaPath: (m.contents?.path || '').replace(/\\/g, '/'),
emojiUrl: m.contents?.cdnurl || '', // 表情包 CDN 直链
linkTitle: isFile ? '' : (m.contents?.title || ''), // 链接/公众号卡片
linkDesc: isFile ? '' : (m.contents?.desc || ''),
linkUrl: isFile ? '' : (m.contents?.url || m.content || ''),
linkThumb: isFile ? '' : (m.contents?.thumbUrl || ''),
linkSource: isFile ? '' : (m.contents?.sourceName || ''),
quote: m.quote || null,
isFile,
fileName,
fileMd5,
fileUrl: fileMd5 ? `/api/files/${encodeURIComponent(fileMd5)}?filename=${encodeURIComponent(fileName || fileMd5)}` : '',
}
})
return {
data: {
messages,
total: raw.total ?? messages.length,
hasMore: (offset + limit) < (raw.total ?? 0),
},
}
}
// ─────────────────────────────────────────────
// Webhook 实时推送SSE 方式)
// ─────────────────────────────────────────────
export function subscribeWebhook(talker, callback) {
const url = `/api/sse/chatlog?talker=${encodeURIComponent(talker)}`
let es
try {
es = new EventSource(url)
es.onmessage = (e) => {
try {
const raw = JSON.parse(e.data)
const isFile = Number(raw.type) === 49 && Number(raw.subType) === 6
const fileMd5 = isFile ? (raw.contents?.md5 || '') : ''
const fileName = isFile ? (raw.contents?.title || raw.contents?.fileName || raw.contents?.filename || '') : ''
const msg = {
id: String(raw.seq || Date.now()),
sender: raw.sender || '',
accountName: raw.senderName || raw.sender || '',
groupNickname: raw.senderName || '',
timestamp: raw.time ? dayjs(raw.time).unix() : dayjs().unix(),
type: convertMsgType(raw.type, raw.subType),
content: raw.content || '',
subType: raw.subType,
talker: raw.talker,
talkerName: raw.talkerName,
mediaKey: raw.contents?.rawmd5 || raw.contents?.md5 || raw.contents?.path?.replace(/\\/g, '/') || '',
voiceKey: raw.contents?.voice || '',
mediaMd5: raw.contents?.md5 || '',
mediaPath: (raw.contents?.path || '').replace(/\\/g, '/'),
emojiUrl: raw.contents?.cdnurl || '',
linkTitle: isFile ? '' : (raw.contents?.title || ''),
linkDesc: isFile ? '' : (raw.contents?.desc || ''),
linkUrl: isFile ? '' : (raw.contents?.url || raw.content || ''),
linkThumb: isFile ? '' : (raw.contents?.thumbUrl || ''),
linkSource: isFile ? '' : (raw.contents?.sourceName || ''),
quote: raw.quote || null,
isFile,
fileName,
fileMd5,
fileUrl: fileMd5 ? `/api/files/${encodeURIComponent(fileMd5)}?filename=${encodeURIComponent(fileName || fileMd5)}` : '',
}
callback(msg)
} catch {
// 忽略解析错误
}
}
es.onerror = () => {
console.warn('[SSE] 连接失败或断开Webhook 实时推送不可用')
}
} catch {
console.warn('[SSE] EventSource 创建失败')
}
return () => {
es?.close()
}
}
export function triggerMockWebhook() {
console.info('[Webhook] 真实模式下无法模拟推送,请通过 chatlog 发送新消息触发')
}
// ─────────────────────────────────────────────
// Groups监控群组管理
// ─────────────────────────────────────────────
export const getGroups = () => api.get('/api/groups')
export const createGroup = (talker, name) => api.post('/api/groups', { talker, name })
export const patchGroup = (groupId, body) => api.patch(`/api/groups/${groupId}`, body)
export const initGroup = (groupId, { startTime, endTime }) =>
api.post(`/api/groups/${groupId}/init`, { start_time: startTime, end_time: endTime })
export const getGroupTask = (groupId) => api.get(`/api/groups/${groupId}/task`)
export const deleteGroup = (groupId) => api.delete(`/api/groups/${groupId}`)
// ─────────────────────────────────────────────
// TopicsAI 话题)
// ─────────────────────────────────────────────
export const getTopics = (params) => api.get('/api/topics', { params })
export const getTopic = (id) => api.get(`/api/topics/${id}`)
export const createTopic = (group_id, title) => api.post('/api/topics', { group_id, title })
export const patchTopic = (id, body) => api.patch(`/api/topics/${id}`, body)
export const deleteTopic = (id) => api.delete(`/api/topics/${id}`)
export const summarizeTopic = (id) => api.post(`/api/topics/${id}/summarize`)
export const addTopicMessage = (id, msg_seq, talker) => api.post(`/api/topics/${id}/messages`, { msg_seq, talker })
export const removeTopicMessage = (id, seq) => api.delete(`/api/topics/${id}/messages/${seq}`)
// ─────────────────────────────────────────────
// Knowledge知识库
// ─────────────────────────────────────────────
export const getKnowledge = (keyword) => api.get('/api/knowledge', { params: keyword ? { keyword } : {} })
export const getKnowledgeDoc = (id) => api.get(`/api/knowledge/${id}`)
export const patchKnowledge = (id, content) => api.patch(`/api/knowledge/${id}`, { content })
// ─────────────────────────────────────────────
// Tasks
// ─────────────────────────────────────────────
export const getTask = (id) => api.get(`/api/tasks/${id}`)
// ─────────────────────────────────────────────
// 消息类型映射
// chatlog Type: 1=文字 3=图片 34=语音 43=视频 49=分享 10000=系统
// ─────────────────────────────────────────────
function convertMsgType(rawType, subType) {
if (Number(rawType) === 49 && Number(subType) === 62) return 82 // 拍了拍
const map = {
1: 0, // 文字
3: 1, // 图片
34: 2, // 语音
43: 3, // 视频
47: 5, // 表情包
49: 7, // 分享/链接/文件
10000: 80, // 系统消息
10002: 81, // 撤回
}
return map[rawType] ?? 99
}

View File

@@ -0,0 +1,119 @@
/**
* Mock 数据层
* MVP 阶段使用此文件模拟后端 API等真实接口文档确认后替换 api/client.js 即可
*/
import dayjs from 'dayjs'
// ── Mock 群聊列表 ──────────────────────────────
export const MOCK_CHATROOMS = [
{ id: 'room_001', name: '设备售后技术群', memberCount: 47, platform: 'wechat' },
{ id: 'room_002', name: '弯管机项目组', memberCount: 12, platform: 'wechat' },
{ id: 'room_003', name: '华南区售后', memberCount: 23, platform: 'wechat' },
{ id: 'room_004', name: '客服协调群', memberCount: 8, platform: 'wechat' },
]
// ── Mock 成员列表 ──────────────────────────────
export const MOCK_MEMBERS = {
room_001: [
{ platformId: 'u001', accountName: '张三', groupNickname: '调机师傅-张三' },
{ platformId: 'u002', accountName: '李四', groupNickname: '售后一组' },
{ platformId: 'u003', accountName: '王五', groupNickname: '王五' },
{ platformId: 'u004', accountName: '赵六', groupNickname: '技术总监' },
{ platformId: 'u005', accountName: '孙七', groupNickname: '孙工' },
{ platformId: 'u006', accountName: '周八', groupNickname: '周老板' },
{ platformId: 'me', accountName: '我', groupNickname: '(本机账号)' },
],
room_002: [
{ platformId: 'u001', accountName: '张三', groupNickname: '张三' },
{ platformId: 'u004', accountName: '赵六', groupNickname: '赵工' },
{ platformId: 'me', accountName: '我', groupNickname: '我' },
],
room_003: [
{ platformId: 'u002', accountName: '李四', groupNickname: '华南李四' },
{ platformId: 'u005', accountName: '孙七', groupNickname: '孙七' },
{ platformId: 'me', accountName: '我', groupNickname: '我' },
],
room_004: [
{ platformId: 'u006', accountName: '周八', groupNickname: '周总' },
{ platformId: 'me', accountName: '我', groupNickname: '我' },
],
}
// ── Mock 消息生成 ──────────────────────────────
const now = dayjs()
function makeMsg(sender, accountName, groupNickname, minutesAgo, content, type = 0) {
return {
id: `${sender}_${minutesAgo}`,
sender,
accountName,
groupNickname,
timestamp: now.subtract(minutesAgo, 'minute').unix(),
type,
content,
}
}
export const MOCK_MESSAGES = {
room_001: [
makeMsg('u001', '张三', '调机师傅-张三', 180, '那台弯管机又报警了,客户那边急着要货'),
makeMsg('u002', '李四', '售后一组', 178, '我这边也遇到了,注塑机模具对不上,顶针位置偏了'),
makeMsg('u001', '张三', '调机师傅-张三', 177, '报的是什么错误码?'),
makeMsg('u003', '王五', '王五', 176, '@李四 你先检查一下导柱有没有松动'),
makeMsg('u001', '张三', '调机师傅-张三', 174, 'E-1023伺服过载报警'),
makeMsg('u002', '李四', '售后一组', 173, '导柱没问题啊,刚紧过'),
makeMsg('u003', '王五', '王五', 171, '那看看顶针有没有弯,之前有台机子就是这样'),
makeMsg('u004', '赵六', '技术总监', 168, '@张三 E-1023 是伺服过载,你先检查一下电机温度,再看看机械负载有没有异常'),
makeMsg('u001', '张三', '调机师傅-张三', 165, '温度正常36度我再看看机械那边'),
makeMsg('u004', '赵六', '技术总监', 162, '重点检查导轨润滑这个型号的机器用的是23号导轨油很多现场都缺油'),
makeMsg('u001', '张三', '调机师傅-张三', 158, '找到了!导轨这段完全干了,润滑油嘴堵了,加完油重启好了'),
makeMsg('u004', '赵六', '技术总监', 155, '好这个问题做个记录。E-1023 80%是润滑问题,以后优先检查这里'),
makeMsg('u002', '李四', '售后一组', 150, '顶针换了,位置好了,谢谢王五'),
makeMsg('u003', '王五', '王五', 148, '好的好的,这个顶针弯了之后看起来不明显,要拿千分表测'),
makeMsg('u005', '孙七', '孙工', 120, '今天那个广州客户发来消息说液压系统压力不够标准是16MPa实测只有11MPa'),
makeMsg('u004', '赵六', '技术总监', 118, '检查溢流阀,旋钮可能被碰到调小了。调回去之前先确认液压油液位'),
makeMsg('u005', '孙七', '孙工', 115, '液压油液位正常溢流阀找到了调回16MPa好了'),
makeMsg('u006', '周八', '周老板', 90, '大家注意,下周一华东区有个验厂,需要两个技术人员配合,谁有空?'),
makeMsg('u001', '张三', '调机师傅-张三', 88, '我可以,周一下午没安排'),
makeMsg('u004', '赵六', '技术总监', 85, '我跟张三去,先联系对方工厂准备材料'),
makeMsg('me', '我', '(本机账号)', 60, '刚查了下那个广州客户的机器是定制款液压系统压力设定是18MPa不是16MPa是客户特殊要求的'),
makeMsg('u004', '赵六', '技术总监', 58, '对的,这个机器台账里有注记。要存到知识库里'),
makeMsg('u002', '李四', '售后一组', 30, '收到,我来整理一下今天几个问题的解决记录'),
makeMsg('u001', '张三', '调机师傅-张三', 15, '好,我补充一下弯管机那台的细节'),
makeMsg('me', '我', '(本机账号)', 5, '刚又来一个新报警WG-50CNC 出现 F-2055有人知道这个码吗'),
],
room_002: [
makeMsg('u001', '张三', '张三', 300, 'WG-38CNC 那台调好了弯管角度偏差在±0.3度以内'),
makeMsg('u004', '赵六', '赵工', 295, '好,符合客户要求。出货前再跑一遍程序确认'),
makeMsg('u001', '张三', '张三', 290, '已经跑了,没问题了'),
makeMsg('me', '我', '我', 60, '客户那边反馈说回弹补偿不够120度弯出来实际是122度'),
makeMsg('u004', '赵六', '赵工', 55, '增加2度补偿试试在参数页面改 K02 参数'),
makeMsg('me', '我', '我', 50, '改完了现在是121度还差一点'),
makeMsg('u004', '赵六', '赵工', 45, '再加0.5度,如果还不够这批料可能弹性模量偏高,要换料'),
],
room_003: [
makeMsg('u002', '李四', '华南李四', 240, '深圳那个客户要下周来验收,设备已经调好了'),
makeMsg('u005', '孙七', '孙七', 235, '验收要准备什么材料?'),
makeMsg('u002', '李四', '华南李四', 230, '合格证、操作手册、维修手册、参数表,还有现场演示视频'),
makeMsg('me', '我', '我', 45, '广州那个液压问题解决了吗?'),
makeMsg('u002', '李四', '华南李四', 40, '解决了溢流阀调回来就好了。那个机器是定制款压力18MPa'),
],
room_004: [
makeMsg('u006', '周八', '周总', 180, '下周一验厂,张三和赵工去,行程确认一下'),
makeMsg('me', '我', '我', 175, '好的,已经记下来了'),
makeMsg('u006', '周八', '周总', 50, '还有季度报告,这周五之前交'),
makeMsg('me', '我', '我', 45, '收到,我来汇总'),
],
}
// ── Webhook 增量消息池 ─────────────────────────
// 模拟 webhook 可能推入的增量消息
export const MOCK_WEBHOOK_MESSAGES = {
room_001: [
{ sender: 'u003', accountName: '王五', groupNickname: '王五', content: '这个需求挺有意思的F-2055 我查过,是刀具磨损检测报警' },
{ sender: 'u004', accountName: '赵六', groupNickname: '技术总监', content: 'F-2055 对,检查一下刀具刃口,如果磨损超标就换刀' },
{ sender: 'u001', accountName: '张三', groupNickname: '调机师傅-张三', content: '换完刀清一下报警就好了,这个很常见' },
{ sender: 'u005', accountName: '孙七', groupNickname: '孙工', content: '好的,我来处理' },
],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,337 @@
import { useState, useRef, useEffect } from 'react'
import { Sparkles, X, Copy, Check, Loader, Download } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const MEDIA_TYPE_MAP = { 1: 'image', 2: 'voice', 3: 'video' }
function quoteContext(msg) {
if (!msg.quote?.content) return ''
const sender = msg.quote.sender_name || msg.quote.sender || '未知'
const seq = msg.quote.seq ? ` seq=${msg.quote.seq}` : ''
return `[引用消息${seq}] ${sender}: ${msg.quote.content}`
}
// 调用 chatlog 内置 AI 解析单条媒体消息,返回文字描述,失败则返回 null
async function parseOneMedia(msg) {
const aiType = MEDIA_TYPE_MAP[msg.type]
// 语音用 voiceKeyServerID图片/视频用 mediaKeymd5
const key = msg.type === 2
? (msg.voiceKey || msg.mediaKey || '')
: (msg.mediaKey || '')
if (!aiType || !key) return null
try {
const res = await fetch('/api/ai/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: aiType, key }),
})
if (!res.ok) return null
const data = await res.json()
return data.text || data.result || null
} catch {
return null
}
}
// 把 messages 列表转成发给 AI 的纯文本(含媒体解析结果)
async function buildContext(messages, onProgress) {
const lines = []
let mediaCount = 0
const mediaMessages = messages.filter(m => {
if (!MEDIA_TYPE_MAP[m.type]) return false
return m.type === 2 ? (m.voiceKey || m.mediaKey) : m.mediaKey
})
// 先解析所有媒体(并发,最多 5 个同时)
const mediaResults = new Map()
const chunks = []
for (let i = 0; i < mediaMessages.length; i += 5) chunks.push(mediaMessages.slice(i, i + 5))
for (const chunk of chunks) {
const results = await Promise.all(chunk.map(m => parseOneMedia(m)))
chunk.forEach((m, i) => { if (results[i]) mediaResults.set(m.id, results[i]) })
mediaCount += chunk.length
onProgress(Math.min(90, Math.round((mediaCount / Math.max(mediaMessages.length, 1)) * 80)))
}
// 组装上下文文本
for (const m of messages) {
const sender = m.accountName || m.sender || '未知'
const typeLabel = { 0: '', 1: '[图片]', 2: '[语音]', 3: '[视频]', 5: '[表情]', 7: '[链接]', 80: '[系统]', 81: '[撤回]' }[m.type] || `[类型${m.type}]`
const mediaDesc = mediaResults.get(m.id)
const quoteText = quoteContext(m)
if (m.type === 0 && m.content) {
lines.push(`${sender}: ${m.content}${quoteText}`)
} else if (mediaDesc) {
lines.push(`${sender} ${typeLabel}: ${mediaDesc}${quoteText}`)
} else if (m.content && m.type !== 0) {
lines.push(`${sender} ${typeLabel}: ${m.content}${quoteText}`)
} else {
lines.push(`${sender} ${typeLabel}${quoteText}`)
}
}
return { context: lines.join('\n'), parsedMedia: mediaResults.size }
}
/**
* AI 总结面板
* 先解析媒体(图片/语音/视频),再把全部内容一起送给 AI 总结
*/
export default function AISummaryPanel({ messages, roomName, onClose }) {
const [phase, setPhase] = useState('idle') // idle | parsing | loading | streaming | done | error
const [parseProgress, setParseProgress] = useState(0)
const [parsedMedia, setParsedMedia] = useState(0)
const [content, setContent] = useState('')
const [copied, setCopied] = useState(false)
const abortRef = useRef(null)
useEffect(() => {
handleGenerate()
return () => abortRef.current?.abort()
}, []) // eslint-disable-line
const handleGenerate = async () => {
setContent('')
setParseProgress(0)
setParsedMedia(0)
const controller = new AbortController()
abortRef.current = controller
try {
// ── 第一步:解析媒体 ──
const mediaMessages = messages.filter(m => {
if (!MEDIA_TYPE_MAP[m.type]) return false
return m.type === 2 ? (m.voiceKey || m.mediaKey) : m.mediaKey
})
if (mediaMessages.length > 0) {
setPhase('parsing')
const { context, parsedMedia: count } = await buildContext(messages, setParseProgress)
setParsedMedia(count)
setParseProgress(100)
// 稍等一下让用户看到 100%
await new Promise(r => setTimeout(r, 300))
await streamSummary(context, roomName, controller, setPhase, setContent)
} else {
// 没有媒体,直接总结文本
const textContext = messages
.filter(m => (m.type === 0 && m.content) || m.quote?.content)
.map(m => `${m.accountName || m.sender}: ${m.content || ''}${quoteContext(m)}`)
.join('\n')
await streamSummary(textContext, roomName, controller, setPhase, setContent)
}
} catch (e) {
if (e.name === 'AbortError') return
setContent(`**生成失败**: ${e.message}`)
setPhase('error')
}
}
const handleCopy = () => {
navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const mediaTotal = messages.filter(m => {
if (!MEDIA_TYPE_MAP[m.type]) return false
return m.type === 2 ? (m.voiceKey || m.mediaKey) : m.mediaKey
}).length
return (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 200, backdropFilter: 'blur(4px)',
}}>
<div style={{
width: 720, maxWidth: '92vw', maxHeight: '82vh',
background: 'var(--bg-elevated)', border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-lg)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '14px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0,
}}>
<Sparkles size={16} color="var(--accent-light)" />
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
AI 知识总结
</span>
<span style={{
fontSize: 11, color: 'var(--text-muted)',
background: 'var(--bg-overlay)', padding: '2px 8px', borderRadius: 10,
}}>
{messages.length} 条消息 · {roomName}
{mediaTotal > 0 && ` · ${mediaTotal} 条媒体`}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
{phase === 'done' && (
<button className="btn btn-ghost btn-sm" onClick={handleGenerate}>
<Sparkles size={12} /> 重新生成
</button>
)}
{content && (
<button className="btn btn-ghost btn-sm" onClick={handleCopy}>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? '已复制' : '复制'}
</button>
)}
{content && (
<button className="btn btn-ghost btn-sm" onClick={() => {
const blob = new Blob([content], { type: 'text/markdown' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${roomName || 'summary'}_AI总结.md`
a.click()
URL.revokeObjectURL(a.href)
}}>
<Download size={12} /> 导出 MD
</button>
)}
<button
onClick={onClose}
style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}
>
<X size={18} />
</button>
</div>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '18px 22px' }}>
{/* 媒体解析进度 */}
{phase === 'parsing' && (
<div style={{ padding: '40px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--text-muted)', marginBottom: 16 }}>
<div className="loading-spinner" />
正在解析媒体内容图片 / 语音 / 视频...
<span style={{ color: 'var(--accent-light)', fontWeight: 500 }}>
{parseProgress}%
</span>
</div>
<div style={{
height: 4, background: 'var(--bg-overlay)', borderRadius: 4, overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 4,
background: 'var(--accent)',
width: `${parseProgress}%`,
transition: 'width 0.3s',
}} />
</div>
<div style={{ marginTop: 8, fontSize: 11.5, color: 'var(--text-muted)' }}>
{mediaTotal} 条媒体已解析 {parsedMedia}
</div>
</div>
)}
{phase === 'loading' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--text-muted)', padding: '40px 0' }}>
<div className="loading-spinner" />
正在连接 AI 服务...
</div>
)}
{(phase === 'streaming' || phase === 'done' || phase === 'error') && (
<div style={{ lineHeight: 1.8 }} className="ai-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content || ' '}
</ReactMarkdown>
{phase === 'streaming' && (
<span style={{
display: 'inline-block',
width: 8, height: 16,
background: 'var(--accent)', borderRadius: 2,
animation: 'pulse 0.8s infinite',
verticalAlign: 'middle', marginLeft: 2,
}} />
)}
</div>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 18px', borderTop: '1px solid var(--border)', flexShrink: 0,
fontSize: 11.5, color: 'var(--text-muted)',
}}>
{phase === 'parsing' && <><Loader size={12} style={{ animation: 'spin 1s linear infinite' }} /> 解析媒体中{parseProgress}%...</>}
{phase === 'loading' && <><Loader size={12} style={{ animation: 'spin 1s linear infinite' }} /> 连接中...</>}
{phase === 'streaming' && <><Sparkles size={12} color="var(--accent)" /> 生成中...</>}
{phase === 'done' && <><Check size={12} color="var(--success)" /> 生成完成{parsedMedia > 0 && `(含 ${parsedMedia} 条媒体内容)`}</>}
{phase === 'error' && <><X size={12} color="var(--danger)" /> 生成失败</>}
<span style={{ marginLeft: 'auto' }}>chatlog AI · 媒体感知总结</span>
</div>
</div>
{/* Markdown 样式 */}
<style>{`
.ai-markdown h1, .ai-markdown h2, .ai-markdown h3 {
color: var(--text-primary); margin: 14px 0 6px; font-weight: 600;
}
.ai-markdown h1 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.ai-markdown h2 { font-size: 15px; }
.ai-markdown h3 { font-size: 13.5px; }
.ai-markdown p { color: var(--text-primary); margin: 6px 0; font-size: 13.5px; }
.ai-markdown ul, .ai-markdown ol { padding-left: 20px; color: var(--text-primary); font-size: 13.5px; }
.ai-markdown li { margin: 3px 0; }
.ai-markdown table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; }
.ai-markdown th { background: var(--bg-overlay); color: var(--text-secondary); padding: 7px 10px; text-align: left; border: 1px solid var(--border); }
.ai-markdown td { padding: 6px 10px; border: 1px solid var(--border); color: var(--text-primary); }
.ai-markdown tr:nth-child(even) td { background: var(--bg-surface); }
.ai-markdown code { background: var(--bg-overlay); padding: 1px 5px; border-radius: 4px; font-size: 12px; color: var(--accent-light); }
.ai-markdown blockquote { border-left: 3px solid var(--accent); padding: 6px 12px; background: var(--accent-dim); border-radius: 0 6px 6px 0; margin: 8px 0; }
.ai-markdown strong { color: var(--text-primary); font-weight: 600; }
.ai-markdown hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
`}</style>
</div>
)
}
// 流式请求 AI 总结接口
async function streamSummary(context, roomName, controller, setPhase, setContent) {
setPhase('loading')
const resp = await fetch('/api/ai/summarize/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context, room_name: roomName }),
signal: controller.signal,
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.detail || `HTTP ${resp.status}`)
}
setPhase('streaming')
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const json = JSON.parse(line.slice(6))
if (json.done) { setPhase('done'); return }
else if (json.error) throw new Error(json.error)
else if (json.delta) setContent(prev => prev + json.delta)
} catch (e) {
if (e.message !== 'Unexpected end of JSON input') throw e
}
}
}
setPhase('done')
}

View File

@@ -0,0 +1,131 @@
import { useState, useRef, useEffect } from 'react'
import { Users, Check, X } from 'lucide-react'
/**
* 多选成员选择器
* 支持搜索、已选 Tag 展示、点击外部关闭
*/
export default function MemberSelector({ members = [], selected = [], onChange }) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef(null)
// 点击外部关闭
useEffect(() => {
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const filtered = members.filter((m) => {
if (!search) return true
return (
m.accountName?.includes(search) ||
m.groupNickname?.includes(search) ||
m.platformId?.includes(search)
)
})
const toggle = (id) => {
if (selected.includes(id)) {
onChange(selected.filter((s) => s !== id))
} else {
onChange([...selected, id])
}
}
const getMember = (id) => members.find((m) => m.platformId === id)
return (
<div className="member-selector" ref={ref}>
<div
className={`member-selector-trigger ${open ? 'open' : ''}`}
onClick={() => setOpen(!open)}
id="member-selector-trigger"
>
<Users size={14} />
{selected.length === 0 ? (
<span>全部成员</span>
) : (
<span style={{ color: 'var(--accent-light)' }}>已选 {selected.length} </span>
)}
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)', fontSize: 10 }}></span>
</div>
{open && (
<div className="member-selector-dropdown">
{/* 搜索框 */}
<div className="member-search">
<input
className="member-search-input"
placeholder="🔍 搜索成员名称..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
{/* 列表 */}
<div className="member-list-scroll">
{filtered.length === 0 && (
<div style={{ padding: '12px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>
无匹配成员
</div>
)}
{filtered.map((m) => {
const checked = selected.includes(m.platformId)
return (
<div
key={m.platformId}
className={`member-option ${checked ? 'checked' : ''}`}
onClick={() => toggle(m.platformId)}
>
<div className="member-checkbox">
{checked && <Check size={10} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="member-option-name truncate">{m.accountName}</div>
{m.groupNickname && m.groupNickname !== m.accountName && (
<div className="member-option-nick truncate">{m.groupNickname}</div>
)}
</div>
</div>
)
})}
</div>
{/* 已选 + 清空 */}
<div className="member-footer">
<div className="member-selected-tags">
{selected.length === 0 && (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>未选择显示全部</span>
)}
{selected.map((id) => {
const m = getMember(id)
return (
<div key={id} className="member-tag">
{m?.accountName || id}
<span className="member-tag-remove" onClick={(e) => { e.stopPropagation(); toggle(id) }}>
<X size={9} />
</span>
</div>
)
})}
</div>
{selected.length > 0 && (
<button
className="btn btn-ghost btn-sm"
style={{ flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); onChange([]) }}
>
清空
</button>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,696 @@
import { useState, useEffect } from 'react'
import dayjs from 'dayjs'
import { Volume2, Video, FileText, Link, AlertCircle, Type, Play, Download, ExternalLink, Reply } from 'lucide-react'
// key 可能是路径(含 /)或 md5只编码各路径段保留斜杠让 Gin *key 路由正确匹配
const avatarCache = new Map()
function fetchAvatar(wxid) {
if (avatarCache.has(wxid)) return avatarCache.get(wxid)
const p = fetch(`/api/search/avatar?wxid=${encodeURIComponent(wxid)}`)
.then(r => r.json()).then(d => d.url || '').catch(() => '')
avatarCache.set(wxid, p)
return p
}
function mediaUrl(type, key) {
if (!key) return ''
return `/${type}/${key.split('/').map(p => encodeURIComponent(p)).join('/')}`
}
// 调用 chatlog 内置 AI 解析(语音→文字,图片/视频→描述)
async function aiParse(type, key) {
const typeMap = { 1: 'image', 2: 'voice', 3: 'video' }
const aiType = typeMap[type]
if (!aiType || !key) throw new Error('参数不完整')
const res = await fetch('/api/ai/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: aiType, key }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
const detail = err.detail
if (detail && typeof detail === 'object') {
const e = new Error(detail.message || err.message || `HTTP ${res.status}`)
e.diagnostics = detail.diagnostics
throw e
}
throw new Error(err.message || detail || `HTTP ${res.status}`)
}
return res.json() // { text: "..." }
}
function Avatar({ wxid, displayName, color }) {
const [imgUrl, setImgUrl] = useState(null)
const initials = displayName?.slice(0, 2) || wxid?.slice(-2) || '??'
useEffect(() => {
if (!wxid || wxid === 'me') return
fetchAvatar(wxid).then(url => { if (url) setImgUrl(url) })
}, [wxid])
return (
<div className="msg-avatar" style={{ background: color, position: 'relative', overflow: 'hidden' }}>
{initials}
{imgUrl && (
<img
src={imgUrl}
referrerPolicy="no-referrer"
onError={() => setImgUrl(null)}
style={{
position: 'absolute',
top: 0, left: 0, width: '100%', height: '100%',
objectFit: 'cover',
borderRadius: 'inherit',
}}
alt=""
/>
)}
</div>
)
}
export default function MessageBubble({ msg, keyword = '', isNew = false }) {
const isMine = msg.sender === 'me'
const time = dayjs.unix(msg.timestamp).format('HH:mm')
const displayName = msg.groupNickname || msg.accountName
const initials = displayName?.slice(0, 2) || '??'
function highlight(text) {
if (!keyword || !text) return text
const parts = text.split(new RegExp(`(${keyword})`, 'gi'))
return parts.map((part, i) =>
part.toLowerCase() === keyword.toLowerCase()
? <mark key={i}>{part}</mark>
: part
)
}
if (msg.type === 82) {
return (
<div className="msg-row" style={{ justifyContent: 'center' }}>
<span style={{ color: 'var(--text-muted)', fontSize: 12, fontStyle: 'italic' }}>
{msg.content || '[拍了拍]'}
</span>
</div>
)
}
return (
<div className={`msg-row ${isMine ? 'mine' : ''} ${isNew ? 'highlight' : ''}`}>
<Avatar wxid={msg.sender} displayName={displayName} color={getAvatarColor(msg.sender)} />
<div className="msg-body">
<div className="msg-meta">
<span className="msg-sender">{msg.accountName}</span>
{msg.groupNickname && msg.groupNickname !== msg.accountName && (
<span className="msg-nickname">({msg.groupNickname})</span>
)}
<span className="msg-time">{time}</span>
{isNew && <span className="new-badge">新消息</span>}
</div>
<div className="msg-bubble">
<MsgContent msg={msg} highlight={highlight} />
</div>
</div>
</div>
)
}
function MsgContent({ msg, highlight }) {
// 图片/视频用 md5chatlog 按 md5 查库),语音用 voiceKeyServerID
const mediaKey = msg.mediaKey || ''
const voiceKey = msg.voiceKey || ''
const emojiUrl = msg.emojiUrl || ''
const withQuote = (body) => (
<>
<QuoteBlock quote={msg.quote} />
{body}
</>
)
switch (msg.type) {
case 0:
return withQuote(<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{highlight(msg.content)}</span>)
case 1:
return withQuote(<ImageMsg msgKey={mediaKey} msgType={msg.type} />)
case 2:
return withQuote(<VoiceMsg msgKey={voiceKey} content={msg.content} msgType={msg.type} />)
case 3:
return withQuote(<VideoMsg msgKey={mediaKey} mediaPath={msg.mediaPath} msgType={msg.type} />)
case 5:
return withQuote(emojiUrl
? <img src={emojiUrl} alt="表情包" style={{ maxWidth: 120, maxHeight: 120, borderRadius: 4, display: 'block' }} />
: mediaKey
? <ImageMsg msgKey={mediaKey} msgType={msg.type} />
: <MediaTag icon="😄" label="表情包" />)
case 7:
if (msg.isFile || msg.subType === 6) return withQuote(<FileMsg msg={msg} />)
return withQuote(<LinkMsg msg={msg} />)
case 80:
return (
<span style={{ color: 'var(--text-muted)', fontSize: 12, fontStyle: 'italic' }}>
{msg.content || '[系统消息]'}
</span>
)
case 81:
return <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>撤回了一条消息</span>
case 82:
return (
<span style={{ color: 'var(--text-muted)', fontSize: 12, fontStyle: 'italic' }}>
{msg.content || '[拍了拍]'}
</span>
)
default:
return withQuote(<MediaTag icon={<AlertCircle size={13} />} label={getTypeName(msg.type)} />)
}
}
function QuoteBlock({ quote }) {
if (!quote?.content) return null
const sender = quote.sender_name || quote.sender || '引用消息'
return (
<div style={{
marginBottom: 6,
padding: '6px 8px',
borderLeft: '3px solid var(--accent-light)',
background: 'rgba(99,102,241,0.08)',
borderRadius: 4,
maxWidth: 320,
color: 'var(--text-secondary)',
fontSize: 12,
lineHeight: 1.45,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2, color: 'var(--text-muted)' }}>
<Reply size={12} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{sender}</span>
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{quote.content}</div>
</div>
)
}
// ── 文件 ──────────────────────────────────────
function FileMsg({ msg }) {
const [error, setError] = useState('')
const fileName = msg.fileName || msg.linkTitle || msg.content?.replace(/^\[文件\|(.+)\]$/, '$1') || '文件'
const fileMd5 = msg.fileMd5 || msg.mediaMd5 || msg.mediaKey || ''
const fileUrl = msg.fileUrl || (fileMd5 ? `/api/files/${encodeURIComponent(fileMd5)}?filename=${encodeURIComponent(fileName || fileMd5)}` : '')
const ext = getFileExt(fileName)
const openFile = () => {
setError('')
if (!fileUrl) {
setError('缺少文件标识,无法打开原文件')
return
}
const a = document.createElement('a')
a.href = fileUrl
a.download = fileName
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
a.remove()
}
return (
<div style={{
width: 280,
borderRadius: 8,
background: 'var(--surface-2)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', gap: 10, padding: '10px 12px', alignItems: 'center' }}>
<div style={{
width: 38, height: 44, borderRadius: 6,
background: 'rgba(99,102,241,0.12)',
color: 'var(--accent-light)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<FileText size={18} />
<span style={{ fontSize: 9, marginTop: 1, maxWidth: 32, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ext || 'FILE'}
</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
fontWeight: 500,
lineHeight: 1.35,
wordBreak: 'break-word',
}}>
{fileName}
</div>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--text-muted)' }}>
{fileMd5 ? `md5: ${fileMd5.slice(0, 10)}...` : '缺少文件标识'}
</div>
</div>
</div>
<div style={{
borderTop: '1px solid var(--border)',
padding: '7px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}>
<span style={{
fontSize: 11,
color: error ? 'var(--danger)' : 'var(--text-muted)',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}>
{error || (fileUrl ? '原文件' : '不可打开')}
</span>
<button
onClick={openFile}
disabled={!fileUrl}
title="打开或下载原文件"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '4px 8px',
borderRadius: 6,
border: '1px solid var(--border)',
background: '#fff',
color: fileUrl ? 'var(--accent-light)' : 'var(--text-muted)',
cursor: fileUrl ? 'pointer' : 'not-allowed',
fontSize: 11,
flexShrink: 0,
}}
>
<Download size={12} />
下载/打开
</button>
</div>
</div>
)
}
// ── 图片 ──────────────────────────────────────
function ImageMsg({ msgKey, msgType }) {
const [errored, setErrored] = useState(false)
const [enlarged, setEnlarged] = useState(false)
const [aiText, setAiText] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState('')
if (!msgKey) return <MediaTag icon="🖼️" label="图片" />
const url = mediaUrl('image', msgKey)
const thumbUrl = url + '?thumb=1'
const handleAiDesc = async () => {
setAiLoading(true)
setAiError('')
try {
const result = await aiParse(msgType, msgKey)
setAiText(result.text || result.result || JSON.stringify(result))
} catch (e) {
setAiError(e.message)
} finally {
setAiLoading(false)
}
}
return (
<div>
{errored ? (
// 加载失败时仍保留点击 AI 描述的入口
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 12px',
background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 8,
}}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>🖼 图片文件未找到</span>
<AiBtn loading={aiLoading} onClick={handleAiDesc} />
</div>
) : (
<div style={{ display: 'inline-block', position: 'relative' }}>
<img
src={thumbUrl}
alt="图片"
style={{
maxWidth: 220, maxHeight: 220,
borderRadius: 8, cursor: 'pointer',
display: 'block', objectFit: 'cover',
}}
onError={() => setErrored(true)}
onClick={() => setEnlarged(true)}
/>
{/* AI 描述按钮浮在图片右下角 */}
<div style={{ position: 'absolute', bottom: 6, right: 6 }}>
<AiBtn loading={aiLoading} onClick={handleAiDesc} />
</div>
</div>
)}
{/* AI 描述结果 */}
{(aiText || aiError) && (
<div style={{
marginTop: 6, padding: '6px 10px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12, color: aiError ? 'var(--danger)' : 'var(--text-primary)',
maxWidth: 280,
}}>
{aiError ? `AI 识别失败:${aiError}` : aiText}
</div>
)}
{/* 放大预览 */}
{enlarged && (
<div
onClick={() => setEnlarged(false)}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999, cursor: 'zoom-out',
}}
>
<img
src={url}
alt="原图"
style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8, cursor: 'default' }}
onClick={e => e.stopPropagation()}
onError={e => { e.currentTarget.onerror = null; e.currentTarget.src = thumbUrl }}
/>
</div>
)}
</div>
)
}
// ── 语音 ──────────────────────────────────────
function VoiceMsg({ msgKey, content, msgType }) {
const [aiText, setAiText] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState('')
const [audioError, setAudioError] = useState(false)
useEffect(() => {
setAudioError(false)
}, [msgKey])
const handleToText = async () => {
setAiLoading(true)
setAiError('')
try {
const result = await aiParse(msgType, msgKey)
setAiText(result.text || result.result || JSON.stringify(result))
} catch (e) {
setAiError(e.message)
} finally {
setAiLoading(false)
}
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Volume2 size={14} color={msgKey ? 'var(--accent-light)' : 'var(--text-muted)'} />
{msgKey ? (
audioError ? (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>语音文件暂不可用</span>
) : (
<audio
controls
preload="none"
style={{ height: 32, maxWidth: 200 }}
src={mediaUrl('voice', msgKey)}
onError={() => setAudioError(true)}
/>
)
) : (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{content || '语音'}无媒体标识无法播放
</span>
)}
{/* 转文字按钮 */}
<button
onClick={handleToText}
disabled={!msgKey || aiLoading}
title={msgKey ? '调用 AI 语音转文字' : '缺少媒体标识,无法转文字'}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '3px 9px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 12, fontSize: 11, cursor: msgKey ? 'pointer' : 'not-allowed',
color: msgKey ? 'var(--accent-light)' : 'var(--text-muted)',
opacity: (!msgKey || aiLoading) ? 0.6 : 1,
whiteSpace: 'nowrap',
}}
>
<Type size={11} />
{aiLoading ? '识别中...' : '转文字'}
</button>
</div>
{/* 转文字结果 */}
{(aiText || aiError) && (
<div style={{
marginTop: 6, padding: '6px 10px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12,
color: aiError ? 'var(--danger)' : 'var(--text-primary)',
maxWidth: 320,
}}>
{aiError ? `转文字失败:${aiError}` : `"${aiText}"`}
</div>
)}
</div>
)
}
// ── 视频 ──────────────────────────────────────
function VideoMsg({ msgKey, mediaPath, msgType }) {
const [showVideo, setShowVideo] = useState(false)
const [thumbErr, setThumbErr] = useState(false)
const [videoErr, setVideoErr] = useState(false)
const [aiText, setAiText] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiError, setAiError] = useState('')
if (!msgKey && !mediaPath) return <MediaTag icon={<Video size={13} />} label="视频" />
const thumbUrl = msgKey ? mediaUrl('video', msgKey) + '?thumb=1' : ''
const videoUrl = mediaPath ? mediaUrl('video', mediaPath) : mediaUrl('video', msgKey)
const handleAiDesc = async () => {
setAiLoading(true)
setAiError('')
try {
const result = await aiParse(msgType, msgKey)
setAiText(result.text || result.result || JSON.stringify(result))
} catch (e) {
setAiError(e.message)
} finally {
setAiLoading(false)
}
}
return (
<div>
{showVideo && !videoErr ? (
<div style={{ position: 'relative', display: 'inline-block' }}>
<video
controls
autoPlay
style={{ maxWidth: 320, maxHeight: 240, borderRadius: 8, display: 'block' }}
src={videoUrl}
onError={() => setVideoErr(true)}
/>
</div>
) : (
/* 封面 / 播放按钮 */
<div
style={{
position: 'relative', cursor: 'pointer',
width: 200, height: 140,
background: '#111', borderRadius: 8, overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onClick={() => {
if (videoErr) return
setShowVideo(true)
}}
>
{!thumbErr && (
<img
src={thumbUrl}
alt="视频封面"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.7 }}
onError={() => setThumbErr(true)}
/>
)}
{/* 播放 / 错误图标 */}
<div style={{
position: 'relative', zIndex: 1,
width: 44, height: 44, borderRadius: '50%',
background: videoErr ? 'rgba(255,80,80,0.85)' : 'rgba(255,255,255,0.85)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{videoErr
? <AlertCircle size={20} color="#fff" />
: <Play size={20} color="#333" style={{ marginLeft: 3 }} />
}
</div>
{videoErr && (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
background: 'rgba(0,0,0,0.65)', fontSize: 11,
color: '#fff', textAlign: 'center', padding: '4px 0',
}}>
视频加载失败
</div>
)}
{/* AI 描述按钮 */}
<div
style={{ position: 'absolute', top: 6, right: 6, zIndex: 2 }}
onClick={e => { e.stopPropagation(); handleAiDesc() }}
>
<AiBtn loading={aiLoading} />
</div>
</div>
)}
{/* AI 描述结果 */}
{(aiText || aiError) && (
<div style={{
marginTop: 6, padding: '6px 10px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12,
color: aiError ? 'var(--danger)' : 'var(--text-primary)',
maxWidth: 280,
}}>
{aiError ? `AI 识别失败:${aiError}` : aiText}
</div>
)}
</div>
)
}
// ── 链接 ──────────────────────────────────────
function LinkMsg({ msg }) {
const title = msg.linkTitle || '分享链接'
const desc = msg.linkDesc || ''
const url = msg.linkUrl || ''
const thumb = msg.linkThumb || ''
const source = msg.linkSource || ''
return (
<div
onClick={() => url && window.open(url, '_blank')}
style={{
maxWidth: 280, borderRadius: 8, overflow: 'hidden',
background: 'var(--surface-2)', border: '1px solid var(--border)',
cursor: url ? 'pointer' : 'default',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 12px' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 500, lineHeight: 1.4, marginBottom: desc ? 4 : 0,
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>
{title}
</div>
{desc && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{desc}
</div>
)}
</div>
{thumb && (
<img
src={thumb} alt=""
style={{ width: 56, height: 56, borderRadius: 4, objectFit: 'cover', flexShrink: 0 }}
onError={e => { e.currentTarget.style.display = 'none' }}
/>
)}
</div>
<div style={{
borderTop: '1px solid var(--border)', padding: '5px 12px',
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, color: 'var(--text-muted)',
}}>
<Link size={10} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{source || (url ? (() => { try { return new URL(url).hostname } catch { return url.slice(0, 30) } })() : '链接')}
</span>
</div>
</div>
)
}
// ── AI 按钮(图片/视频浮层用) ──────────────
function AiBtn({ loading, onClick }) {
return (
<button
onClick={onClick}
disabled={loading}
title="AI 识别内容"
style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 7px',
background: 'rgba(99,102,241,0.85)', border: 'none',
borderRadius: 10, fontSize: 10.5, cursor: 'pointer',
color: '#fff', opacity: loading ? 0.7 : 1,
}}
>
{loading ? '...' : 'AI'}
</button>
)
}
// ── 通用占位标签 ──────────────────────────────
function MediaTag({ icon, label }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px',
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12, color: 'var(--text-muted)',
}}>
{icon} {label}
</span>
)
}
// ── 工具函数 ──────────────────────────────────
const AVATAR_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f59e0b',
'#10b981', '#3b82f6', '#06b6d4', '#84cc16',
]
function getAvatarColor(id) {
if (id === 'me') return '#6366f1'
let hash = 0
for (let c of (id || '')) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
}
function getFileExt(name) {
const m = String(name || '').match(/\.([a-z0-9]{1,8})(?:\?|#)?$/i)
return m ? m[1].toUpperCase() : ''
}
function getTypeName(type) {
const names = {
1: '图片', 2: '语音', 3: '视频', 4: '文件',
5: '表情包', 7: '链接', 8: '位置',
20: '红包', 21: '转账', 22: '拍一拍',
25: '引用回复', 80: '系统消息', 81: '撤回',
}
return names[type] || `未知(${type})`
}

View File

@@ -0,0 +1,127 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
export const WORD_PAGE_CLASS = 'report-word-page'
export const WORD_PAGE_CSS = `
.${WORD_PAGE_CLASS} {
width: min(100%, 794px);
min-height: 1123px;
margin: 0 auto;
padding: 56px 64px;
box-sizing: border-box;
background: #ffffff;
color: #1f2937;
border: 1px solid #d8dee8;
box-shadow: 0 10px 32px rgba(15, 23, 42, 0.12);
font-family: "Microsoft YaHei", "SimSun", Arial, sans-serif;
font-size: 14px;
line-height: 1.75;
}
.${WORD_PAGE_CLASS} h1 {
margin: 0 0 22px;
padding-bottom: 12px;
border-bottom: 2px solid #111827;
color: #111827;
font-size: 24px;
line-height: 1.35;
text-align: center;
font-weight: 700;
}
.${WORD_PAGE_CLASS} h2 {
margin: 26px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid #d1d5db;
color: #111827;
font-size: 17px;
line-height: 1.45;
font-weight: 700;
}
.${WORD_PAGE_CLASS} h3 {
margin: 18px 0 8px;
color: #1f2937;
font-size: 15px;
line-height: 1.45;
font-weight: 700;
}
.${WORD_PAGE_CLASS} p {
margin: 8px 0;
}
.${WORD_PAGE_CLASS} ul,
.${WORD_PAGE_CLASS} ol {
margin: 8px 0 12px;
padding-left: 24px;
}
.${WORD_PAGE_CLASS} li {
margin: 4px 0;
}
.${WORD_PAGE_CLASS} table {
width: 100%;
margin: 12px 0 16px;
border-collapse: collapse;
table-layout: fixed;
font-size: 13px;
}
.${WORD_PAGE_CLASS} th,
.${WORD_PAGE_CLASS} td {
padding: 8px 10px;
border: 1px solid #9ca3af;
vertical-align: top;
word-break: break-word;
}
.${WORD_PAGE_CLASS} th {
background: #f3f4f6;
color: #111827;
font-weight: 700;
text-align: left;
}
.${WORD_PAGE_CLASS} blockquote {
margin: 10px 0;
padding: 8px 12px;
border-left: 4px solid #9ca3af;
background: #f9fafb;
}
.${WORD_PAGE_CLASS} code {
padding: 1px 4px;
background: #f3f4f6;
border-radius: 3px;
font-family: Consolas, monospace;
font-size: 12px;
}
.${WORD_PAGE_CLASS} hr {
margin: 20px 0;
border: none;
border-top: 1px solid #d1d5db;
}
.${WORD_PAGE_CLASS} img {
display: block;
max-width: 100%;
max-height: 360px;
margin: 10px 0 8px;
padding: 4px;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #f9fafb;
object-fit: contain;
}
@media (max-width: 900px) {
.${WORD_PAGE_CLASS} {
min-height: auto;
padding: 32px 24px;
}
}
`
export default function ReportDocumentView({ content = '' }) {
return (
<>
<style>{WORD_PAGE_CSS}</style>
<article className={WORD_PAGE_CLASS}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content || '(暂无报告内容)'}
</ReactMarkdown>
</article>
</>
)
}

View File

@@ -0,0 +1,817 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ─── Design Tokens ───────────────────────────────────────────────── */
:root {
/* Colors - Light Mode */
--bg-base: #f4f6f9;
--bg-surface: #ffffff;
--bg-elevated: #ffffff;
--bg-overlay: #f0f2f5;
--bg-hover: #eef0f5;
/* 别名:兼容组件中使用的 var(--surface) / var(--surface-2) */
--surface: #ffffff;
--surface-2: #f0f2f5;
--border: rgba(0,0,0,0.08);
--border-strong: rgba(0,0,0,0.14);
--text-primary: #1a1d27;
--text-secondary: #5a6072;
--text-muted: #9ba3b8;
--text-inverse: #ffffff;
/* Brand */
--accent: #6366f1;
--accent-light: #4f46e5;
--accent-dim: rgba(99,102,241,0.10);
--accent-hover: #5254cc;
/* Semantic */
--success: #16a34a;
--success-dim: rgba(22,163,74,0.10);
--warning: #d97706;
--warning-dim: rgba(217,119,6,0.10);
--danger: #dc2626;
--danger-dim: rgba(220,38,38,0.10);
--info: #0284c7;
--info-dim: rgba(2,132,199,0.10);
/* Sidebar */
--sidebar-width: 280px;
--topbar-height: 56px;
/* Spacing */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.10);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.14);
/* Transitions */
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ─── Reset ───────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html { font-size: 14px; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-base);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow: hidden;
height: 100vh;
}
#root { height: 100vh; display: flex; flex-direction: column; }
/* ─── Scrollbar ───────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5e0; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #b0b7c8; }
/* ─── Typography ──────────────────────────────────────────────────── */
h1, h2, h3, h4 { font-weight: 600; letter-spacing: -0.02em; }
code, pre { font-family: 'JetBrains Mono', monospace; }
/* ─── Layout Shell ────────────────────────────────────────────────── */
.app-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ─── Sidebar ─────────────────────────────────────────────────────── */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: #f7f8fb;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-logo {
height: var(--topbar-height);
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-logo-icon {
width: 28px; height: 28px;
background: var(--bg-surface);
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.sidebar-logo-icon img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.sidebar-logo-text {
font-weight: 700;
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
}
.sidebar-logo-version {
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
}
.sidebar-nav {
padding: 12px 10px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent-dim);
color: var(--accent-light);
}
.nav-item svg { opacity: 0.8; flex-shrink: 0; }
.nav-item.active svg { opacity: 1; }
/* ─── Room List (sidebar) ─────────────────────────────────────────── */
.room-list {
flex: 1;
overflow-y: auto;
padding: 8px 10px;
}
.room-list-header {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 6px 4px 4px;
}
.room-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
}
.room-item:hover { background: var(--bg-hover); }
.room-item.active { background: var(--accent-dim); }
.room-avatar {
width: 34px; height: 34px;
border-radius: var(--radius-sm);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
font-size: 14px;
flex-shrink: 0;
color: var(--text-secondary);
font-weight: 600;
}
.room-info { flex: 1; min-width: 0; }
.room-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.room-meta {
font-size: 11px;
color: var(--text-muted);
}
/* ─── Main Content Area ───────────────────────────────────────────── */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-base);
}
/* ─── Topbar ──────────────────────────────────────────────────────── */
.topbar {
height: var(--topbar-height);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
flex-shrink: 0;
}
.topbar-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.topbar-subtitle {
font-size: 12px;
color: var(--text-muted);
}
.topbar-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
/* ─── Filter Bar ──────────────────────────────────────────────────── */
.filter-bar {
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-input, .filter-select {
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 7px 11px;
font-size: 13px;
font-family: inherit;
transition: border-color var(--transition);
outline: none;
min-width: 160px;
}
.filter-input:focus, .filter-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
.filter-input::placeholder { color: var(--text-muted); }
/* Date range */
.date-range {
display: flex;
align-items: center;
gap: 6px;
}
.date-range-sep { color: var(--text-muted); font-size: 12px; }
/* Quick date chips */
.date-chips { display: flex; gap: 4px; }
.chip {
padding: 4px 10px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
}
.chip:hover { border-color: var(--accent); color: var(--accent-light); }
.chip.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent-light); }
/* ─── Message List ────────────────────────────────────────────────── */
.message-area {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.msg-day-divider {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 0 8px;
}
.msg-day-divider-line {
flex: 1;
height: 1px;
background: var(--border);
}
.msg-day-divider-text {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
padding: 2px 10px;
background: var(--bg-overlay);
border-radius: 10px;
}
/* Message bubble */
.msg-row {
display: flex;
gap: 10px;
padding: 4px 6px;
border-radius: var(--radius-md);
transition: background var(--transition);
}
.msg-row:hover { background: #f0f2f7; }
.msg-row.mine { flex-direction: row-reverse; }
.msg-row.highlight { background: rgba(99,102,241,0.06); }
.msg-avatar {
width: 34px; height: 34px;
border-radius: var(--radius-sm);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
color: var(--text-secondary);
align-self: flex-start;
margin-top: 2px;
}
.msg-body { flex: 1; min-width: 0; max-width: 72%; }
.msg-row.mine .msg-body { align-items: flex-end; display: flex; flex-direction: column; }
.msg-meta {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 3px;
}
.msg-row.mine .msg-meta { flex-direction: row-reverse; }
.msg-sender {
font-size: 12px;
font-weight: 600;
color: var(--accent-light);
}
.msg-nickname {
font-size: 11px;
color: var(--text-muted);
}
.msg-time {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.msg-row.mine .msg-time { margin-left: 0; margin-right: auto; }
.msg-bubble {
display: inline-block;
padding: 9px 13px;
border-radius: var(--radius-md);
font-size: 13.5px;
line-height: 1.55;
word-break: break-word;
background: #eef0f5;
color: var(--text-primary);
border: 1px solid var(--border);
max-width: 100%;
}
.msg-row.mine .msg-bubble {
background: var(--accent);
border-color: transparent;
color: #fff;
}
/* New message badge */
.new-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: var(--success-dim);
color: var(--success);
font-weight: 600;
margin-left: 6px;
vertical-align: middle;
}
/* ─── Status Bar ──────────────────────────────────────────────────── */
.status-bar {
height: 34px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
flex-shrink: 0;
}
.status-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--text-muted);
}
.status-dot.connected { background: var(--success); animation: pulse 2s infinite; }
.status-dot.connecting { background: var(--warning); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 11.5px;
color: var(--text-muted);
}
/* ─── Buttons ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all var(--transition);
font-family: inherit;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
/* ─── Empty State ─────────────────────────────────────────────────── */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted);
padding: 40px;
}
.empty-state-icon {
font-size: 40px;
opacity: 0.4;
}
.empty-state-title {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
}
.empty-state-desc {
font-size: 13px;
text-align: center;
line-height: 1.7;
}
/* ─── Loading ─────────────────────────────────────────────────────── */
.loading-spinner {
width: 20px; height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-muted);
font-size: 13px;
}
/* ─── Member Selector Panel ───────────────────────────────────────── */
.member-selector {
position: relative;
}
.member-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 11px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: border-color var(--transition);
min-width: 160px;
}
.member-selector-trigger:hover, .member-selector-trigger.open {
border-color: var(--accent);
}
.member-selector-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 260px;
background: #ffffff;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.member-search {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.member-search-input {
width: 100%;
background: var(--bg-overlay);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 6px 10px;
font-size: 12.5px;
font-family: inherit;
outline: none;
}
.member-search-input:focus { border-color: var(--accent); }
.member-search-input::placeholder { color: var(--text-muted); }
.member-list-scroll {
max-height: 220px;
overflow-y: auto;
padding: 4px;
}
.member-list-section-title {
font-size: 10.5px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 8px 3px;
}
.member-option {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
}
.member-option:hover { background: var(--bg-hover); }
.member-option.checked { background: var(--accent-dim); }
.member-checkbox {
width: 15px; height: 15px;
border: 1.5px solid var(--border-strong);
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
background: transparent;
transition: all var(--transition);
}
.member-option.checked .member-checkbox {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.member-option-name { font-size: 12.5px; color: var(--text-primary); font-weight: 500; }
.member-option-nick { font-size: 11px; color: var(--text-muted); }
.member-footer {
border-top: 1px solid var(--border);
padding: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.member-selected-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
}
.member-tag {
display: flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
background: var(--accent-dim);
border-radius: 10px;
font-size: 11px;
color: var(--accent-light);
}
.member-tag-remove {
cursor: pointer;
opacity: 0.6;
font-size: 11px;
}
.member-tag-remove:hover { opacity: 1; }
/* ─── Webhook test pill ───────────────────────────────────────────── */
.webhook-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: var(--success-dim);
border: 1px solid rgba(34,197,94,0.2);
border-radius: 20px;
font-size: 12px;
color: var(--success);
cursor: pointer;
transition: all var(--transition);
}
.webhook-pill:hover { background: rgba(34,197,94,0.2); }
/* ─── Toast / Notification ────────────────────────────────────────── */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column-reverse;
gap: 8px;
z-index: 9999;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: #ffffff;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 13px;
color: var(--text-primary);
animation: slideIn 0.2s ease-out;
max-width: 320px;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.warning { border-left: 3px solid var(--warning); }
@keyframes slideIn {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ─── Highlight search keyword ────────────────────────────────────── */
mark {
background: rgba(245,158,11,0.3);
color: var(--warning);
border-radius: 2px;
padding: 0 1px;
}
/* ─── Utilities ───────────────────────────────────────────────────── */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.gap-2 { gap: 8px; }
.ml-auto { margin-left: auto; }
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 12px; }
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ─── 消息数据同步进度条 ───────────────────────────────────────────── */
.sync-progress-banner {
margin: 10px 16px 4px;
padding: 10px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
flex-shrink: 0;
}
.sync-progress-info {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.sync-progress-track {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.sync-progress-fill {
height: 100%;
background: var(--accent, #6366f1);
border-radius: 2px;
transition: width 0.5s ease;
min-width: 4px;
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,491 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import dayjs from 'dayjs'
import { Search, RefreshCw, Sparkles, Zap } from 'lucide-react'
import MemberSelector from '../components/MemberSelector'
import MessageBubble from '../components/MessageBubble'
import AISummaryPanel from '../components/AISummaryPanel'
import { getChatlog, getChatroomMembers, subscribeWebhook } from '../api'
const DATE_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 getApiErrorMessage(e, fallback = '未知错误') {
return e?.response?.data?.detail || e?.response?.data?.error || e?.message || fallback
}
const WARMUP_RETRY_LIMIT = 6
const WARMUP_RETRY_DELAY_MS = 1500
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isWarmupError(e) {
const message = getApiErrorMessage(e, '').toLowerCase()
return (
message.includes('自动解密') ||
message.includes('消息索引') ||
message.includes('time range not found') ||
message.includes('message index')
)
}
export default function ChatlogPage({ room, onNewMessage }) {
const [messages, setMessages] = useState([])
const [members, setMembers] = useState([])
const [loading, setLoading] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const [newMsgIds, setNewMsgIds] = useState(new Set())
const [showAI, setShowAI] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const [earliestOffset, setEarliestOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
// 筛选状态
const [startDate, setStartDate] = useState(dayjs().subtract(7, 'day').startOf('day').format('YYYY-MM-DDTHH:mm'))
const [endDate, setEndDate] = useState(dayjs().format('YYYY-MM-DDTHH:mm'))
const [selectedMembers, setSelectedMembers] = useState([])
const [keyword, setKeyword] = useState('')
const [activePreset, setActivePreset] = useState('近7天')
// Webhook / SSE
const [webhookConnected, setWebhookConnected] = useState(false)
const webhookConnectedRef = useRef(false)
const noScrollRef = useRef(false)
const bottomRef = useRef(null)
const scrollAreaRef = useRef(null)
const unsubRef = useRef(null)
// 切换群时重置筛选并自动拉取
useEffect(() => {
if (!room) return
setMessages([])
setHasSearched(false)
setSelectedMembers([])
setErrorMsg('')
setEarliestOffset(0)
setHasMore(false)
// 切群时同时重置日期/关键词/预设为「近7天」防止旧状态污染新群的查询
const newStart = dayjs().subtract(7, 'day').startOf('day').format('YYYY-MM-DDTHH:mm')
const newEnd = dayjs().format('YYYY-MM-DDTHH:mm')
setStartDate(newStart)
setEndDate(newEnd)
setKeyword('')
setActivePreset('近7天')
// 切换群时断开旧 SSE如果之前已连接则自动重连新群
const wasConnected = webhookConnectedRef.current
if (unsubRef.current) {
unsubRef.current()
unsubRef.current = null
webhookConnectedRef.current = false
setWebhookConnected(false)
}
// 只有群聊(@chatroom 结尾)才拉取成员列表
if (room.isGroup !== false) {
getChatroomMembers(room.id)
.then((res) => setMembers(res.data || []))
.catch(() => setMembers([]))
} else {
setMembers([])
}
// 如果之前已连接,自动重连新群的 SSE
if (wasConnected) {
const unsub = subscribeWebhook(room.id, (msg) => {
setMessages((prev) => [...prev, msg])
setNewMsgIds((prev) => new Set([...prev, msg.id]))
onNewMessage?.(room.id, msg)
setTimeout(() => {
setNewMsgIds((prev) => {
const next = new Set(prev)
next.delete(msg.id)
return next
})
}, 3000)
})
unsubRef.current = unsub
webhookConnectedRef.current = true
setWebhookConnected(true)
}
// 同步触发查询,使用显式的 overrides 绕开 setState 时序
// (这样即便新群默认 startDate 与上一个群相同也能正确触发)
fetchMessages(true, {
startDate: newStart,
endDate: newEnd,
keyword: '',
members: [],
activePreset: '近7天',
})
}, [room]) // eslint-disable-line
// 拉取聊天记录
// overrides: 可选,绕开 state 闭包,直接用传入的筛选条件(切群自动加载场景使用)
const fetchMessages = useCallback(async (autoExpandIfEmpty = false, overrides = null) => {
if (!room) return
setLoading(true)
setErrorMsg('')
const startTs = Date.now()
const _startDate = overrides?.startDate ?? startDate
const _endDate = overrides?.endDate ?? endDate
const _selectedMembers = overrides?.members ?? selectedMembers
const _keyword = overrides?.keyword ?? keyword
const _activePreset = overrides?.activePreset ?? activePreset
try {
const LIMIT = 200
const st = dayjs(_startDate).unix()
const et = dayjs(_endDate).unix()
let msgs = []
let usedOffset = 0
for (let attempt = 0; attempt <= WARMUP_RETRY_LIMIT; attempt += 1) {
try {
const first = await getChatlog({ talker: room.id, startTime: st, endTime: et, senders: _selectedMembers, keyword: _keyword, limit: LIMIT, offset: 0 })
const total = first.data?.total || 0
msgs = first.data?.messages || []
usedOffset = 0
if (total > LIMIT) {
usedOffset = total - LIMIT
const last = await getChatlog({ talker: room.id, startTime: st, endTime: et, senders: _selectedMembers, keyword: _keyword, limit: LIMIT, offset: usedOffset })
msgs = last.data?.messages || []
}
// 若近7天为空且 autoExpandIfEmpty自动尝试近30天
if (msgs.length === 0 && autoExpandIfEmpty && _activePreset === '近7天') {
const s30 = dayjs().subtract(30, 'day').startOf('day').unix()
const e30 = dayjs().unix()
const fallback = await getChatlog({ talker: room.id, startTime: s30, endTime: e30, senders: [], keyword: '', limit: LIMIT, offset: 0 })
const fallbackMsgs = fallback.data?.messages || []
if (fallbackMsgs.length > 0) {
setStartDate(dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DDTHH:mm'))
setEndDate(dayjs().format('YYYY-MM-DDTHH:mm'))
setActivePreset('近30天')
msgs = fallbackMsgs
usedOffset = 0
}
}
// 保证 loading 至少显示 300ms防止空状态闪烁
const elapsed = Date.now() - startTs
if (elapsed < 300) await new Promise(r => setTimeout(r, 300 - elapsed))
break
} catch (e) {
if (attempt < WARMUP_RETRY_LIMIT && isWarmupError(e)) {
setErrorMsg(`自动解密仍在处理消息库,正在重试 ${attempt + 1}/${WARMUP_RETRY_LIMIT}...`)
await sleep(WARMUP_RETRY_DELAY_MS)
continue
}
throw e
}
}
setMessages(msgs)
setEarliestOffset(usedOffset)
setHasMore(usedOffset > 0)
setHasSearched(true)
} catch (e) {
setErrorMsg('查询失败:' + getApiErrorMessage(e))
} finally {
setLoading(false)
}
}, [room, startDate, endDate, selectedMembers, keyword, activePreset])
// 滚动到底部(加载更早消息时不滚动)
useEffect(() => {
if (noScrollRef.current) { noScrollRef.current = false; return }
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// 加载更早的消息
const loadEarlier = useCallback(async () => {
if (!room || loadingMore || earliestOffset <= 0) return
setLoadingMore(true)
noScrollRef.current = true
try {
const LIMIT = 200
const st = dayjs(startDate).unix()
const et = dayjs(endDate).unix()
const newOffset = Math.max(0, earliestOffset - LIMIT)
const res = await getChatlog({ talker: room.id, startTime: st, endTime: et, senders: selectedMembers, keyword, limit: LIMIT, offset: newOffset })
const older = res.data?.messages || []
setMessages(prev => [...older, ...prev])
setEarliestOffset(newOffset)
setHasMore(newOffset > 0)
} catch (e) {
setErrorMsg('加载失败:' + getApiErrorMessage(e))
} finally {
setLoadingMore(false)
}
}, [room, earliestOffset, loadingMore, startDate, endDate, selectedMembers, keyword])
// 连接 / 断开 SSE Webhook
// 滚动到顶部自动加载更早消息
useEffect(() => {
const el = scrollAreaRef.current
if (!el) return
const onScroll = () => { if (el.scrollTop === 0 && hasMore && !loadingMore) loadEarlier() }
el.addEventListener('scroll', onScroll)
return () => el.removeEventListener('scroll', onScroll)
}, [hasMore, loadingMore, loadEarlier])
const toggleWebhook = () => {
if (webhookConnected) {
unsubRef.current?.()
unsubRef.current = null
webhookConnectedRef.current = false
setWebhookConnected(false)
} else if (room) {
const unsub = subscribeWebhook(room.id, (msg) => {
setMessages((prev) => [...prev, msg])
setNewMsgIds((prev) => new Set([...prev, msg.id]))
onNewMessage?.(room.id, msg)
setTimeout(() => {
setNewMsgIds((prev) => {
const next = new Set(prev)
next.delete(msg.id)
return next
})
}, 3000)
})
unsubRef.current = unsub
webhookConnectedRef.current = true
setWebhookConnected(true)
}
}
// 应用日期预设
const applyPreset = (preset) => {
const [s, e] = preset.getDates()
setStartDate(s.format('YYYY-MM-DDTHH:mm'))
setEndDate(e.format('YYYY-MM-DDTHH:mm'))
setActivePreset(preset.label)
}
// 按天分组
const grouped = groupByDay(messages)
if (!room) {
return (
<div className="empty-state" style={{ flex: 1 }}>
<div className="empty-state-icon">💬</div>
<div className="empty-state-title">请从左侧选择一个会话</div>
<div className="empty-state-desc">选择会话后即可查看聊天记录</div>
</div>
)
}
return (
<>
{/* ── 筛选栏 ── */}
<div className="filter-bar">
{/* 日期快捷 */}
<div className="filter-group">
<div className="filter-label">快捷日期</div>
<div className="date-chips">
{DATE_PRESETS.map((p) => (
<div
key={p.label}
className={`chip ${activePreset === p.label ? 'active' : ''}`}
onClick={() => applyPreset(p)}
>
{p.label}
</div>
))}
</div>
</div>
{/* 自定义日期范围 */}
<div className="filter-group">
<div className="filter-label">日期范围</div>
<div className="date-range">
<input
type="datetime-local"
className="filter-input"
value={startDate}
onChange={(e) => { setStartDate(e.target.value); setActivePreset('') }}
style={{ minWidth: 155 }}
id="filter-start-date"
/>
<span className="date-range-sep"></span>
<input
type="datetime-local"
className="filter-input"
value={endDate}
onChange={(e) => { setEndDate(e.target.value); setActivePreset('') }}
style={{ minWidth: 155 }}
id="filter-end-date"
/>
</div>
</div>
{/* 人员选择(仅群聊显示) */}
{members.length > 0 && (
<div className="filter-group">
<div className="filter-label">发送人</div>
<MemberSelector
members={members}
selected={selectedMembers}
onChange={setSelectedMembers}
/>
</div>
)}
{/* 关键词 */}
<div className="filter-group">
<div className="filter-label">关键词</div>
<input
className="filter-input"
placeholder="搜索消息内容..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchMessages()}
id="filter-keyword"
/>
</div>
{/* 操作按钮 */}
<div className="filter-group" style={{ justifyContent: 'flex-end', flex: 1 }}>
<div className="filter-label">&nbsp;</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={fetchMessages} id="btn-search" disabled={loading}>
<Search size={14} />
{loading ? '查询中...' : '查询'}
</button>
<button className="btn btn-ghost" onClick={fetchMessages} title="刷新">
<RefreshCw size={14} />
</button>
{messages.length > 0 && (
<button
className="btn btn-ghost"
onClick={() => setShowAI(true)}
id="btn-ai-summary"
title="AI 生成知识总结"
style={{ borderColor: 'rgba(99,102,241,0.4)', color: 'var(--accent-light)' }}
>
<Sparkles size={14} />
AI 总结
</button>
)}
</div>
</div>
</div>
{/* ── 消息区域 ── */}
<div className="message-area" ref={scrollAreaRef}>
{loading && (
<div className="loading-overlay">
<div className="loading-spinner" />
<div>正在加载聊天记录...</div>
</div>
)}
{!loading && loadingMore && (
<div style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--text-muted)' }}>加载更早的消息...</div>
)}
{!loading && errorMsg && (
<div className="empty-state">
<div className="empty-state-icon"></div>
<div className="empty-state-title">查询出错</div>
<div className="empty-state-desc">{errorMsg}</div>
</div>
)}
{!loading && !errorMsg && hasSearched && messages.length === 0 && (
<div className="empty-state">
<div className="empty-state-icon">🔍</div>
<div className="empty-state-title">未找到聊天记录</div>
<div className="empty-state-desc">尝试调整筛选条件或扩大时间范围</div>
{activePreset !== '近30天' && (
<button
className="btn btn-ghost"
style={{ marginTop: 12, fontSize: 12 }}
onClick={() => {
const s = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DDTHH:mm')
const e = dayjs().format('YYYY-MM-DDTHH:mm')
setStartDate(s)
setEndDate(e)
setActivePreset('近30天')
}}
>
查看近 30 天记录
</button>
)}
</div>
)}
{!loading && grouped.map(({ day, msgs }) => (
<div key={day}>
<div className="msg-day-divider">
<div className="msg-day-divider-line" />
<div className="msg-day-divider-text">{day}</div>
<div className="msg-day-divider-line" />
</div>
{msgs.map((msg) => (
<MessageBubble
key={msg.id || `${msg.sender}_${msg.timestamp}`}
msg={msg}
keyword={keyword}
isNew={newMsgIds.has(msg.id)}
/>
))}
</div>
))}
<div ref={bottomRef} />
</div>
{/* ── AI 总结面板 ── */}
{showAI && (
<AISummaryPanel
messages={messages}
roomName={room?.name || '会话'}
onClose={() => setShowAI(false)}
/>
)}
{/* ── 状态栏 ── */}
<div className="status-bar">
<div className={`status-dot ${webhookConnected ? 'connected' : ''}`} />
<span className="status-text">
{webhookConnected ? 'Webhook 实时接收中' : '未连接 Webhook'}
</span>
{messages.length > 0 && (
<span className="status-text"> {messages.length} 条消息</span>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
<div
className="webhook-pill"
onClick={toggleWebhook}
id="webhook-toggle"
>
<Zap size={12} />
{webhookConnected ? '断开 Webhook' : '连接 Webhook'}
</div>
</div>
</div>
</>
)
}
// 按天分组消息
function groupByDay(messages) {
const groups = {}
for (const msg of messages) {
const day = dayjs.unix(msg.timestamp).format('YYYY年MM月DD日 dddd')
if (!groups[day]) groups[day] = []
groups[day].push(msg)
}
return Object.entries(groups).map(([day, msgs]) => ({ day, msgs }))
}

View File

@@ -0,0 +1,236 @@
import { useState, useEffect, useCallback } from 'react'
import { Search, RefreshCw, Edit3, Check, X, Download } from 'lucide-react'
import dayjs from 'dayjs'
import { getKnowledge, getKnowledgeDoc, patchKnowledge } from '../api'
import ReportDocumentView from '../components/ReportDocumentView'
import { exportWordDoc } from '../utils/wordExport'
export default function KnowledgePage({ onToast }) {
const [docs, setDocs] = useState([])
const [selectedDoc, setSelectedDoc] = useState(null)
const [docDetail, setDocDetail] = useState(null)
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState(false)
const [editContent, setEditContent] = useState('')
const [saving, setSaving] = useState(false)
const loadDocs = useCallback(async (kw) => {
setLoading(true)
try {
const data = await getKnowledge(kw)
setDocs(Array.isArray(data) ? data : [])
} catch {
setDocs([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadDocs('') }, [loadDocs])
const handleSearch = () => loadDocs(keyword)
const handleSelect = async (doc) => {
setSelectedDoc(doc)
setDocDetail(null)
setEditing(false)
try {
const detail = await getKnowledgeDoc(doc.id)
setDocDetail(detail)
setEditContent(detail.content || '')
} catch {
setDocDetail(null)
}
}
const handleSave = async () => {
if (!selectedDoc) return
setSaving(true)
try {
await patchKnowledge(selectedDoc.id, editContent)
onToast?.('保存成功')
setEditing(false)
setDocDetail((prev) => prev ? { ...prev, content: editContent } : prev)
} catch {
onToast?.('保存失败', 'error')
} finally {
setSaving(false)
}
}
const handleExport = async () => {
if (!selectedDoc || !docDetail?.content) {
onToast?.('暂无可导出的售后报告', 'error')
return
}
try {
await exportWordDoc(selectedDoc.title || `售后报告_${selectedDoc.id}`, docDetail.content)
onToast?.('Word 文档已导出')
} catch (e) {
onToast?.('Word 导出失败', 'error')
}
}
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', height: '100%' }}>
{/* 左栏:售后报告列表 */}
<div style={{ width: 280, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 8 }}>售后报告库</div>
<div style={{ display: 'flex', gap: 6 }}>
<input
className="filter-input"
style={{ flex: 1, fontSize: 12 }}
placeholder="搜索报告内容..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button className="btn btn-primary" style={{ fontSize: 12 }} onClick={handleSearch}>
<Search size={13} />
</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { setKeyword(''); loadDocs('') }}>
<RefreshCw size={13} />
</button>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{loading && (
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>加载中...</div>
)}
{!loading && docs.length === 0 && (
<div className="empty-state" style={{ flex: 1, padding: 24 }}>
<div className="empty-state-icon">📚</div>
<div className="empty-state-title">暂无售后报告</div>
<div className="empty-state-desc">AI 话题分析选择话题后点击AI 生成售后报告即可生成</div>
</div>
)}
{!loading && docs.length > 0 && (() => {
// 按群聊名称分组
const groupMap = {}
docs.forEach(doc => {
const key = doc.group_name || '未知群聊'
if (!groupMap[key]) groupMap[key] = []
groupMap[key].push(doc)
})
return Object.entries(groupMap).map(([groupName, items]) => (
<div key={groupName}>
<div style={{
padding: '6px 14px',
fontSize: 11,
fontWeight: 600,
color: 'var(--text-muted)',
background: 'var(--bg-overlay)',
borderBottom: '1px solid var(--border)',
letterSpacing: '0.05em',
textTransform: 'uppercase',
position: 'sticky',
top: 0,
zIndex: 1,
}}>
{groupName}
</div>
{items.map((doc) => (
<div
key={doc.id}
onClick={() => handleSelect(doc)}
style={{
padding: '10px 14px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: selectedDoc?.id === doc.id ? 'var(--bg-overlay)' : 'transparent',
}}
>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.title || `文档 #${doc.id}`}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
更新于 {dayjs(doc.updated_at).format('MM-DD HH:mm')}
</div>
</div>
))}
</div>
))
})()}
</div>
</div>
{/* 右栏:文档详情 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{!selectedDoc ? (
<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 }}>{selectedDoc.title || `文档 #${selectedDoc.id}`}</div>
<div style={{ display: 'flex', gap: 8 }}>
{editing ? (
<>
<button className="btn btn-primary" style={{ fontSize: 12 }} onClick={handleSave} disabled={saving}>
<Check size={13} /> {saving ? '保存中...' : '保存'}
</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { setEditing(false); setEditContent(docDetail?.content || '') }}>
<X size={13} /> 取消
</button>
</>
) : (
<>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={handleExport} disabled={!docDetail?.content}>
<Download size={13} /> 导出 Word
</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => setEditing(true)} disabled={!docDetail}>
<Edit3 size={13} /> 编辑
</button>
</>
)}
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden', padding: '16px 20px' }}>
{!docDetail ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>加载中...</div>
) : editing ? (
<textarea
style={{
width: '100%',
height: '100%',
background: 'var(--bg-overlay)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '14px 16px',
fontSize: 13,
lineHeight: 1.8,
color: 'var(--text)',
resize: 'none',
outline: 'none',
fontFamily: 'inherit',
boxSizing: 'border-box',
}}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
/>
) : (
<div style={{
height: '100%',
overflowY: 'auto',
background: 'var(--bg-overlay)',
borderRadius: 8,
padding: '20px 12px',
border: '1px solid var(--border)',
}}>
<ReportDocumentView content={docDetail.content || '(暂无内容,点击编辑或使用 AI 生成)'} />
</div>
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,271 @@
import { useState, useEffect } from 'react'
import { Copy, Check, Save } from 'lucide-react'
const CONFIG_ITEMS = [
{
group: 'chatlog 底层服务',
items: [
{ label: 'chatlog 地址', value: 'http://127.0.0.1:5030', desc: 'Go 后端,负责读取微信数据库' },
{ label: 'API 前缀', value: '/api/v1', desc: '所有 chatlog 接口均在此前缀下' },
],
},
{
group: 'chatlog_fastAPI 业务层',
items: [
{ label: 'FastAPI 地址', value: 'http://127.0.0.1:8000', desc: 'Python 后端,负责 AI 分析和知识库' },
{ label: '搜索接口', value: '/api/search', desc: '聊天记录搜索' },
{ label: '话题接口', value: '/api/topics', desc: 'AI 话题分析管理' },
{ label: '报告库接口', value: '/api/knowledge', desc: '售后报告管理' },
],
},
{
group: '桌面应用服务',
items: [
{ label: '本地应用入口', value: '/', desc: '桌面应用内置界面,由本地业务服务托管' },
],
},
]
const AI_FIELDS = [
{ key: 'ai_base_url', label: 'AI 接口地址', placeholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1', desc: '兼容 OpenAI 格式的 API 地址' },
{ key: 'ai_api_key', label: 'AI API Key', placeholder: 'sk-...', desc: '留空则 AI 功能不可用', type: 'password' },
{ key: 'ai_model', label: '话题分析模型', placeholder: 'qwen-plus', desc: '用于消息分类的模型' },
{ key: 'summary_model', label: '报告生成模型', placeholder: 'qwen-max', desc: '用于生成售后报告的模型' },
{ key: 'vision_model', label: '视觉模型', placeholder: 'qwen-vl-plus', desc: '用于图片/视频描述' },
{ key: 'voice_model', label: '语音模型', placeholder: 'paraformer-v2', desc: '用于语音转文字' },
]
const TOPIC_PROMPT_PLACEHOLDER = '例如:本群主要是某类设备售后群,请优先按设备部件、故障现象、处理进度来拆分话题;不要按客户名或日期拆分。'
function CopyButton({ text }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<button
onClick={handleCopy}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px' }}
title="复制"
>
{copied ? <Check size={13} color="var(--success, #10b981)" /> : <Copy size={13} />}
</button>
)
}
function AISettingsForm() {
const [form, setForm] = useState({})
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
fetch('/api/settings')
.then(r => r.json())
.then(data => setForm(data))
.catch(() => {})
}, [])
const handleChange = (key, value) => {
setForm(prev => ({ ...prev, [key]: value }))
}
const handleSave = async () => {
setSaving(true)
setMsg('')
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (res.ok) {
setMsg('已保存')
setTimeout(() => setMsg(''), 2000)
const updated = await fetch('/api/settings').then(r => r.json())
setForm(updated)
} else {
setMsg('保存失败')
}
} catch {
setMsg('保存失败')
}
setSaving(false)
}
return (
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
AI 模型配置
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 12 }}>
首次使用请填入你的 API Key 和接口地址保存后立即生效无需重启服务
</div>
{/* 未配置 API Key 时显示橙色警告横条 */}
{!form.ai_api_key && (
<div style={{
marginBottom: 12, padding: '8px 12px',
background: 'rgba(245,158,11,0.1)', border: '1px solid rgba(245,158,11,0.3)',
borderRadius: 8, fontSize: 12, color: '#d97706',
display: 'flex', alignItems: 'center', gap: 6,
}}>
未配置 AI API Key所有 AI 功能不可用请填入您自己的 API Key 并保存
</div>
)}
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{AI_FIELDS.map((field, i) => (
<div
key={field.key}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: i < AI_FIELDS.length - 1 ? '1px solid var(--border)' : 'none',
gap: 12,
}}
>
<div style={{ flex: '0 0 130px' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{field.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{field.desc}</div>
</div>
<input
type={field.type || 'text'}
value={form[field.key] || ''}
placeholder={field.placeholder}
onChange={(e) => handleChange(field.key, e.target.value)}
style={{
flex: 1,
fontSize: 13,
padding: '7px 12px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--surface-2)',
color: 'var(--text)',
outline: 'none',
}}
/>
{/* 配置状态指示点:绿色=已配置,红色=未配置 */}
<div
title={form[field.key] ? '已配置' : '未配置'}
style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: form[field.key] ? 'var(--success, #10b981)' : '#ef4444',
boxShadow: form[field.key] ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)',
}}
/>
</div>
))}
</div>
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>AI 话题分析提示词</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>
作为全局默认分析口径单个群聊可在 AI 话题分析里单独覆盖
</div>
<textarea
value={form.topic_analysis_prompt || ''}
placeholder={TOPIC_PROMPT_PLACEHOLDER}
onChange={(e) => handleChange('topic_analysis_prompt', e.target.value)}
style={{
width: '100%',
minHeight: 120,
boxSizing: 'border-box',
fontSize: 13,
lineHeight: 1.6,
padding: '10px 12px',
border: '1px solid var(--border)',
borderRadius: 8,
background: 'var(--surface-2)',
color: 'var(--text)',
outline: 'none',
resize: 'vertical',
fontFamily: 'inherit',
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 14 }}>
<button
onClick={handleSave}
disabled={saving}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 20px',
background: 'var(--accent, #6366f1)',
color: '#fff',
border: 'none',
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.6 : 1,
}}
>
<Save size={14} />
{saving ? '保存中...' : '保存配置'}
</button>
{msg && <span style={{ fontSize: 12, color: msg === '已保存' ? 'var(--success, #10b981)' : '#ef4444' }}>{msg}</span>}
</div>
</div>
)
}
export default function SettingsPage() {
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '28px 36px' }}>
<div style={{ maxWidth: 720 }}>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 6 }}>设置</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 28 }}>
系统各服务地址及 AI 配置管理
</div>
{/* AI 配置表单 */}
<AISettingsForm />
{CONFIG_ITEMS.map((group) => (
<div key={group.group} style={{ marginBottom: 28 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
{group.group}
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{group.items.map((item, i) => (
<div
key={item.label}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: i < group.items.length - 1 ? '1px solid var(--border)' : 'none',
gap: 12,
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{item.label}</div>
{item.desc && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{item.desc}</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<code style={{
fontSize: 12,
padding: '3px 10px',
background: 'var(--surface-2)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--accent-light, #a5b4fc)',
}}>
{item.value}
</code>
<CopyButton text={item.value} />
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,427 @@
import {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
AlignmentType,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
ShadingType,
ImageRun,
} from 'docx'
// 与 ReportDocumentView 的 WORD_PAGE_CSS 对齐的关键样式常量。
// docx 用半点half-point表示字号1pt=2 half-point用 twips 表示长度1in=1440 twips。
const FONT_FAMILY = 'Microsoft YaHei'
const FONT_BODY_HALF = 28 // 14pt 正文
const FONT_TABLE_HALF = 26 // 13pt 表格
const FONT_H1_HALF = 48 // 24pt
const FONT_H2_HALF = 34 // 17pt
const FONT_H3_HALF = 30 // 15pt
const COLOR_TITLE = '111827'
const COLOR_BODY = '1F2937'
const COLOR_TABLE_BORDER = '9CA3AF'
const COLOR_TH_BG = 'F3F4F6'
const COLOR_HR = 'D1D5DB'
const IMAGE_MAX_WIDTH = 480
const IMAGE_MAX_HEIGHT = 320
const PAGE_MARGIN_VERT = 1134 // ~2 cm
const PAGE_MARGIN_HORI = 1296 // ~2.25 cm
const SOLID_BORDER = (color = COLOR_TABLE_BORDER, size = 6) => ({
style: BorderStyle.SINGLE,
size,
color,
})
function sanitizeFileName(value = '售后报告') {
const cleaned = String(value)
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
return cleaned.slice(0, 80) || '售后报告'
}
function splitTableRow(line = '') {
return line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function isTableSeparator(line = '') {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)
}
// 行内 markdown**bold**、`code`
function parseInlineRuns(text, baseProps = {}) {
const runs = []
const re = /(\*\*([^*]+)\*\*|`([^`]+)`)/g
let last = 0
let m
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
runs.push(new TextRun({ text: text.slice(last, m.index), font: FONT_FAMILY, ...baseProps }))
}
if (m[2] != null) {
runs.push(new TextRun({ text: m[2], font: FONT_FAMILY, bold: true, ...baseProps }))
} else if (m[3] != null) {
runs.push(new TextRun({ text: m[3], font: 'Consolas', ...baseProps }))
}
last = m.index + m[0].length
}
if (last < text.length) {
runs.push(new TextRun({ text: text.slice(last), font: FONT_FAMILY, ...baseProps }))
}
if (runs.length === 0) {
runs.push(new TextRun({ text: '', font: FONT_FAMILY, ...baseProps }))
}
return runs
}
function makeHeadingParagraph(level, text) {
if (level === 1) {
return new Paragraph({
alignment: AlignmentType.CENTER,
heading: HeadingLevel.HEADING_1,
spacing: { before: 0, after: 240 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 12, color: COLOR_TITLE, space: 4 },
},
children: parseInlineRuns(text, {
bold: true,
size: FONT_H1_HALF,
color: COLOR_TITLE,
}),
})
}
if (level === 2) {
return new Paragraph({
heading: HeadingLevel.HEADING_2,
spacing: { before: 320, after: 120 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: COLOR_HR, space: 2 },
},
children: parseInlineRuns(text, {
bold: true,
size: FONT_H2_HALF,
color: COLOR_TITLE,
}),
})
}
return new Paragraph({
heading: HeadingLevel.HEADING_3,
spacing: { before: 200, after: 80 },
children: parseInlineRuns(text, {
bold: true,
size: FONT_H3_HALF,
color: COLOR_BODY,
}),
})
}
function makeBodyParagraph(text) {
return new Paragraph({
spacing: { before: 0, after: 80, line: 360 },
children: parseInlineRuns(text, { size: FONT_BODY_HALF, color: COLOR_BODY }),
})
}
function makeListParagraph(text, ordered) {
return new Paragraph({
spacing: { before: 0, after: 60, line: 360 },
indent: { left: 480, hanging: 240 },
bullet: ordered ? undefined : { level: 0 },
numbering: ordered ? { reference: 'ordered-default', level: 0 } : undefined,
children: parseInlineRuns(text, { size: FONT_BODY_HALF, color: COLOR_BODY }),
})
}
function makeHrParagraph() {
return new Paragraph({
spacing: { before: 200, after: 200 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: COLOR_HR, space: 2 },
},
children: [new TextRun({ text: '', font: FONT_FAMILY })],
})
}
function normalizeImageUrl(url = '') {
if (/^https?:\/\//i.test(url) || url.startsWith('/')) return url
return `/${url.replace(/^\.?\//, '')}`
}
function inferImageType(contentType = '', url = '') {
const lower = `${contentType} ${url}`.toLowerCase()
if (lower.includes('png')) return 'png'
if (lower.includes('gif')) return 'gif'
if (lower.includes('bmp')) return 'bmp'
if (lower.includes('webp')) return 'jpg'
return 'jpg'
}
function getImageSize(blob) {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(blob)
const img = new Image()
img.onload = () => {
const naturalWidth = img.naturalWidth || IMAGE_MAX_WIDTH
const naturalHeight = img.naturalHeight || IMAGE_MAX_HEIGHT
URL.revokeObjectURL(objectUrl)
const scale = Math.min(IMAGE_MAX_WIDTH / naturalWidth, IMAGE_MAX_HEIGHT / naturalHeight, 1)
resolve({
width: Math.max(1, Math.round(naturalWidth * scale)),
height: Math.max(1, Math.round(naturalHeight * scale)),
})
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
resolve({ width: IMAGE_MAX_WIDTH, height: Math.round(IMAGE_MAX_WIDTH * 0.62) })
}
img.src = objectUrl
})
}
async function makeImageParagraph(alt, url) {
const normalizedUrl = normalizeImageUrl(url)
try {
const resp = await fetch(normalizedUrl)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const blob = await resp.blob()
const data = await blob.arrayBuffer()
const size = await getImageSize(blob)
const type = inferImageType(resp.headers.get('content-type') || '', normalizedUrl)
return new Paragraph({
spacing: { before: 120, after: 80 },
children: [
new ImageRun({
data,
type,
transformation: size,
altText: {
title: alt || '售后图片',
description: alt || '售后图片',
name: alt || '售后图片',
},
}),
],
})
} catch {
return makeBodyParagraph(`[图片无法导出:${normalizedUrl}]`)
}
}
function makeCell(text, isHeader) {
return new TableCell({
margins: { top: 80, bottom: 80, left: 120, right: 120 },
shading: isHeader
? { type: ShadingType.CLEAR, color: 'auto', fill: COLOR_TH_BG }
: undefined,
borders: {
top: SOLID_BORDER(),
bottom: SOLID_BORDER(),
left: SOLID_BORDER(),
right: SOLID_BORDER(),
},
children: [
new Paragraph({
spacing: { before: 0, after: 0, line: 320 },
children: parseInlineRuns(text, {
size: FONT_TABLE_HALF,
color: COLOR_TITLE,
bold: !!isHeader,
}),
}),
],
})
}
function makeTable(headerCells, rows) {
const widthPct = headerCells.length > 0 ? Math.floor(10000 / headerCells.length) : 0
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
columnWidths: headerCells.map(() => widthPct),
rows: [
new TableRow({
tableHeader: true,
children: headerCells.map((cell) => makeCell(cell, true)),
}),
...rows.map(
(row) =>
new TableRow({
children: headerCells.map((_, idx) => makeCell(row[idx] ?? '', false)),
})
),
],
})
}
// 把 markdown 解析为 docx 子元素数组
async function buildChildrenFromMarkdown(markdown) {
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n')
const children = []
let paragraph = []
let listBuf = []
let listOrdered = false
const flushParagraph = () => {
if (!paragraph.length) return
children.push(makeBodyParagraph(paragraph.join(' ')))
paragraph = []
}
const flushList = () => {
if (!listBuf.length) return
for (const item of listBuf) {
children.push(makeListParagraph(item, listOrdered))
}
listBuf = []
}
for (let i = 0; i < lines.length; i += 1) {
const raw = lines[i]
const line = raw.trim()
if (!line) {
flushParagraph()
flushList()
continue
}
if (/^[-*_]{3,}$/.test(line)) {
flushParagraph()
flushList()
children.push(makeHrParagraph())
continue
}
const image = /^!\[([^\]]*)\]\(([^)]+)\)$/.exec(line)
if (image) {
flushParagraph()
flushList()
children.push(await makeImageParagraph(image[1], image[2]))
continue
}
if (line.includes('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
flushParagraph()
flushList()
const header = splitTableRow(lines[i])
let cursor = i + 2
const rows = []
while (
cursor < lines.length &&
lines[cursor].includes('|') &&
lines[cursor].trim() &&
!isTableSeparator(lines[cursor])
) {
rows.push(splitTableRow(lines[cursor]))
cursor += 1
}
children.push(makeTable(header, rows))
i = cursor - 1
continue
}
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
if (heading) {
flushParagraph()
flushList()
children.push(makeHeadingParagraph(heading[1].length, heading[2]))
continue
}
const unordered = /^[-*]\s+(.+)$/.exec(line)
if (unordered) {
flushParagraph()
if (listBuf.length && listOrdered) flushList()
listOrdered = false
listBuf.push(unordered[1])
continue
}
const ordered = /^\d+\.\s+(.+)$/.exec(line)
if (ordered) {
flushParagraph()
if (listBuf.length && !listOrdered) flushList()
listOrdered = true
listBuf.push(ordered[1])
continue
}
flushList()
paragraph.push(line)
}
flushParagraph()
flushList()
return children
}
export async function exportWordDoc(title = '售后报告', content = '') {
const safeTitle = sanitizeFileName(title)
const md = content || `# ${safeTitle}\n\n(暂无报告内容)`
const children = await buildChildrenFromMarkdown(md)
const doc = new Document({
creator: 'ChatLab',
title: safeTitle,
styles: {
default: {
document: {
run: { font: FONT_FAMILY, size: FONT_BODY_HALF, color: COLOR_BODY },
},
},
},
numbering: {
config: [
{
reference: 'ordered-default',
levels: [
{
level: 0,
format: 'decimal',
text: '%1.',
alignment: AlignmentType.START,
style: {
paragraph: { indent: { left: 480, hanging: 240 } },
},
},
],
},
],
},
sections: [
{
properties: {
page: {
margin: {
top: PAGE_MARGIN_VERT,
bottom: PAGE_MARGIN_VERT,
left: PAGE_MARGIN_HORI,
right: PAGE_MARGIN_HORI,
},
},
},
children,
},
],
})
const blob = await Packer.toBlob(doc)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `售后报告_${safeTitle}.docx`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}