Initial upload for secondary development
This commit is contained in:
87
chatlab-web/README.md
Normal file
87
chatlab-web/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# ChatLab Web MVP
|
||||
|
||||
> 设备售后微信知识库 — Web 端 MVP
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
chatlab-web/
|
||||
├── frontend/ # React (Vite) 前端
|
||||
└── backend/ # Python FastAPI 后端
|
||||
```
|
||||
|
||||
> ⚠️ 此目录与 get_wechat 主项目完全独立,不修改任何现有文件
|
||||
|
||||
---
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 1. 启动前端(含 Mock 数据,无需后端)
|
||||
|
||||
```bash
|
||||
cd chatlab-web/frontend
|
||||
npm run dev
|
||||
# 浏览器访问 http://localhost:5173
|
||||
```
|
||||
|
||||
### 2. 启动后端(接入 chatlog API)
|
||||
|
||||
```bash
|
||||
cd chatlab-web/backend
|
||||
pip install -r requirements.txt
|
||||
cp .env .env.local # 按需修改 CHATLOG_API 地址
|
||||
python main.py
|
||||
# 后端运行在 http://localhost:8000
|
||||
```
|
||||
|
||||
### 3. 前端切换到真实后端
|
||||
|
||||
编辑 `frontend/src/api/index.js`,将 `USE_MOCK = true` 改为 `false`。
|
||||
|
||||
---
|
||||
|
||||
## 接口说明
|
||||
|
||||
### 存量查询(前端主动拉取)
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/chatroom` | GET | 获取群聊列表 |
|
||||
| `/api/v1/chatlog` | GET | 拉取存量聊天记录(分页) |
|
||||
| `/api/session` | GET | 最近会话列表 |
|
||||
| `/api/contact` | GET | 联系人搜索 |
|
||||
|
||||
### 增量推送(Webhook → SSE)
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/webhook` | POST | chatlog 推送新消息(配置到 chatlog) |
|
||||
| `/api/sse/chatlog?talker=群ID` | GET (SSE) | 前端订阅实时推送 |
|
||||
| `/api/health` | GET | 健康检查 |
|
||||
|
||||
---
|
||||
|
||||
## chatlog API 对接
|
||||
|
||||
后端代理到 `http://127.0.0.1:5030`,参考接口:
|
||||
|
||||
```
|
||||
GET /api/v1/chatlog?talker={群ID}&time={start,end}&limit={n}&offset={n}&format=json
|
||||
POST {本后端 webhook URL} ← chatlog 配置 Webhook 推送地址
|
||||
```
|
||||
|
||||
具体入参出参待确认后更新 `backend/main.py` 中的代理逻辑。
|
||||
|
||||
---
|
||||
|
||||
## MVP 功能范围
|
||||
|
||||
- [x] 深色主题 UI + 完整设计系统
|
||||
- [x] 群聊列表 Sidebar
|
||||
- [x] 聊天记录检索(时间范围 / 发送人多选 / 关键词)
|
||||
- [x] 消息气泡展示(按天分组)
|
||||
- [x] 关键词高亮
|
||||
- [x] Webhook 增量接收 + SSE 实时推送
|
||||
- [x] Mock 数据层(可一键切换真实 API)
|
||||
- [ ] AI 话题分类(P0,待接入)
|
||||
- [ ] 知识库(P0,待接入)
|
||||
24
chatlab-web/frontend/.gitignore
vendored
Normal file
24
chatlab-web/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
chatlab-web/frontend/README.md
Normal file
16
chatlab-web/frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
21
chatlab-web/frontend/eslint.config.js
Normal file
21
chatlab-web/frontend/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
])
|
||||
14
chatlab-web/frontend/index.html
Normal file
14
chatlab-web/frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/jpeg" href="/company-logo.jpg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ChatLab — 售后知识库</title>
|
||||
<meta name="description" content="设备售后微信群聊天记录智能分析与知识库管理平台" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4470
chatlab-web/frontend/package-lock.json
generated
Normal file
4470
chatlab-web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
chatlab-web/frontend/package.json
Normal file
35
chatlab-web/frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.99.2",
|
||||
"axios": "^1.15.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"docx": "^9.6.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
BIN
chatlab-web/frontend/public/company-logo.jpg
Normal file
BIN
chatlab-web/frontend/public/company-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
1
chatlab-web/frontend/public/favicon.svg
Normal file
1
chatlab-web/frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
chatlab-web/frontend/public/icons.svg
Normal file
24
chatlab-web/frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
336
chatlab-web/frontend/src/App.jsx
Normal file
336
chatlab-web/frontend/src/App.jsx
Normal 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
|
||||
}
|
||||
279
chatlab-web/frontend/src/api/index.js
Normal file
279
chatlab-web/frontend/src/api/index.js
Normal 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_size(page 从 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 查库
|
||||
// 视频: path(Windows 反斜杠需转成正斜杠让 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}`)
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Topics(AI 话题)
|
||||
// ─────────────────────────────────────────────
|
||||
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
|
||||
}
|
||||
119
chatlab-web/frontend/src/api/mock.js
Normal file
119
chatlab-web/frontend/src/api/mock.js
Normal 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: '好的,我来处理' },
|
||||
],
|
||||
}
|
||||
BIN
chatlab-web/frontend/src/assets/hero.png
Normal file
BIN
chatlab-web/frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
chatlab-web/frontend/src/assets/vite.svg
Normal file
1
chatlab-web/frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
337
chatlab-web/frontend/src/components/AISummaryPanel.jsx
Normal file
337
chatlab-web/frontend/src/components/AISummaryPanel.jsx
Normal 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]
|
||||
// 语音用 voiceKey(ServerID),图片/视频用 mediaKey(md5)
|
||||
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')
|
||||
}
|
||||
131
chatlab-web/frontend/src/components/MemberSelector.jsx
Normal file
131
chatlab-web/frontend/src/components/MemberSelector.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
696
chatlab-web/frontend/src/components/MessageBubble.jsx
Normal file
696
chatlab-web/frontend/src/components/MessageBubble.jsx
Normal 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 }) {
|
||||
// 图片/视频用 md5(chatlog 按 md5 查库),语音用 voiceKey(ServerID)
|
||||
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})`
|
||||
}
|
||||
127
chatlab-web/frontend/src/components/ReportDocumentView.jsx
Normal file
127
chatlab-web/frontend/src/components/ReportDocumentView.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
817
chatlab-web/frontend/src/index.css
Normal file
817
chatlab-web/frontend/src/index.css
Normal 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;
|
||||
}
|
||||
10
chatlab-web/frontend/src/main.jsx
Normal file
10
chatlab-web/frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
491
chatlab-web/frontend/src/pages/ChatlogPage.jsx
Normal file
491
chatlab-web/frontend/src/pages/ChatlogPage.jsx
Normal 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"> </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 }))
|
||||
}
|
||||
236
chatlab-web/frontend/src/pages/KnowledgePage.jsx
Normal file
236
chatlab-web/frontend/src/pages/KnowledgePage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
271
chatlab-web/frontend/src/pages/SettingsPage.jsx
Normal file
271
chatlab-web/frontend/src/pages/SettingsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1154
chatlab-web/frontend/src/pages/TopicsPage.jsx
Normal file
1154
chatlab-web/frontend/src/pages/TopicsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
427
chatlab-web/frontend/src/utils/wordExport.js
Normal file
427
chatlab-web/frontend/src/utils/wordExport.js
Normal 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)
|
||||
}
|
||||
34
chatlab-web/frontend/vite.config.js
Normal file
34
chatlab-web/frontend/vite.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const FASTAPI_PORT = 8000
|
||||
const CHATLOG_PORT = 5030
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
host: '127.0.0.1', // 强制绑定到 IPv4,避免 localhost 解析到 IPv6 导致连接失败
|
||||
proxy: {
|
||||
// chatlog_fastAPI Python 后端:所有业务接口
|
||||
'/api/search': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
'/api/groups': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
'/api/topics': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
'/api/knowledge': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
'/api/tasks': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
'/api/ai': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
'/api/sse': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
'/api/settings': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
'/api/files': { target: `http://127.0.0.1:${FASTAPI_PORT}`, changeOrigin: true },
|
||||
// chatlog Go 后端:基础通信接口
|
||||
'/api/v1': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
|
||||
// chatlog Go 后端:媒体文件直接代理
|
||||
'/image': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
|
||||
'/voice': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
|
||||
'/video': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
|
||||
'/file': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
|
||||
'/data': { target: `http://127.0.0.1:${CHATLOG_PORT}`, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user