agent_9art/frontend/sidebar/src/SummaryApp.tsx
jqb 51310683c7 refactor(sidebar): rewrite summary display to support structured summary parsing
重构了侧边栏的每日总结组件,新增了结构化解析能力,可以识别核心诉求、处理结果、待办等区块和标签,优化了卡片样式和展示细节,提升了总结内容的可读性和美观度。
2026-06-24 11:35:39 +08:00

661 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import { SpinLoading, Empty, Toast } from 'antd-mobile'
const CORPID = 'wwd483c2fba24ae30a'
const AGENTID = '1000021'
const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/summary.html')
interface SummaryItem {
id?: number
msgTime?: string
msgType?: number
fromUser?: string
toUser?: string
roomId?: string
messageContext?: string
summary?: string
createdAt?: number
staffId?: string
staffName?: string
}
interface SummarySection {
title: string
items: string[]
}
interface ParsedSummary {
sections: SummarySection[]
tags: string[]
raw: string
}
function SummaryApp() {
const [loading, setLoading] = useState(true)
const [list, setList] = useState<SummaryItem[]>([])
const [error, setError] = useState('')
const [needLogin, setNeedLogin] = useState(false)
useEffect(() => {
const init = async () => {
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
// 1. URL 中有 code → 先登录
if (code) {
const cleanUrl = window.location.pathname + window.location.hash
window.history.replaceState({}, document.title, cleanUrl)
const ok = await tryCodeLogin(code)
if (!ok) {
setLoading(false)
setNeedLogin(true)
setError('登录失败,请重试')
return
}
}
// 2. 检查员工登录态
const staffId = urlParams.get('staffId') || localStorage.getItem('staff_user_id') || ''
if (!staffId) {
setLoading(false)
setNeedLogin(true)
setError('未获取到员工ID,请先登录')
return
}
// 3. 加载每日总结
await loadSummary(staffId)
}
init()
}, [])
const noCacheFetch = async (url: string, options?: RequestInit) => {
const sep = url.includes('?') ? '&' : '?'
const nocacheUrl = `${url}${sep}_t=${Date.now()}`
return fetch(nocacheUrl, {
...options,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
...options?.headers
}
})
}
const tryCodeLogin = async (code: string): Promise<boolean> => {
try {
const res = await noCacheFetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' })
const data = await res.json()
if (data.code === 0 && data.data) {
const info = data.data
localStorage.setItem('staff_token', info.token)
localStorage.setItem('staff_user_id', info.userId)
localStorage.setItem('staff_user_name', info.userName || '')
localStorage.setItem('staff_avatar', info.avatar || '')
localStorage.setItem('staff_role', info.role || 'SEAT')
console.log('OAuth 登录成功:', info.userId, info.userName)
return true
}
console.error('OAuth login failed:', data.message)
} catch (e) {
console.error('Code login error:', e)
}
return false
}
const redirectToWeComOAuth = () => {
const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${CORPID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=snsapi_base&agentid=${AGENTID}&state=summary#wechat_redirect`
console.log('跳转企微 OAuth:', oauthUrl)
window.location.href = oauthUrl
}
const initWxConfigAndGetCustomerId = (): Promise<string> => {
return new Promise((resolve) => {
const wx = (window as any).wx
if (!wx || !wx.config) {
console.log('非企微环境,跳过 JS-SDK')
resolve('')
return
}
const currentUrl = window.location.href.split('#')[0]
noCacheFetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`)
.then(res => res.json())
.then(corpData => {
if (corpData.code !== 0 || !corpData.data) {
resolve('')
return
}
const cfg = corpData.data
wx.config({
beta: true,
debug: false,
appId: cfg.corpId,
timestamp: cfg.timestamp,
nonceStr: cfg.nonceStr,
signature: cfg.signature,
jsApiList: ['agentConfig', 'getContext', 'getCurExternalContact']
})
wx.ready(() => {
noCacheFetch(`/api/v1/auth/signature?type=agent&agentId=${AGENTID}&url=${encodeURIComponent(currentUrl)}`)
.then(res => res.json())
.then(agentData => {
if (agentData.code !== 0 || !agentData.data) {
resolve('')
return
}
const ac = agentData.data
wx.agentConfig({
corpid: ac.corpId,
agentid: ac.agentId,
timestamp: ac.timestamp,
nonceStr: ac.nonceStr,
signature: ac.signature,
jsApiList: ['getContext', 'getCurExternalContact'],
success: () => {
wx.invoke('getCurExternalContact', {}, (res: any) => {
if (res.err_msg === 'getCurExternalContact:ok' && res.userId) {
console.log('JS-SDK 获取客户ID:', res.userId)
resolve(res.userId)
} else {
console.warn('getCurExternalContact failed:', res)
resolve('')
}
})
},
fail: (err: any) => {
console.warn('agentConfig fail:', err)
resolve('')
}
})
})
.catch((e: any) => {
console.error('agentConfig fetch error:', e)
resolve('')
})
})
wx.error((err: any) => {
console.error('wx.config error:', err)
resolve('')
})
})
.catch((e: any) => {
console.error('initWxConfig error:', e)
resolve('')
})
})
}
const loadSummary = async (staffId: string) => {
const urlParams = new URLSearchParams(window.location.search)
let customerId = urlParams.get('customerId') || ''
// 优先通过企微 JS-SDK 获取当前客户ID
const sdkCustomerId = await initWxConfigAndGetCustomerId()
if (sdkCustomerId) {
customerId = sdkCustomerId
localStorage.setItem('current_customer_id', sdkCustomerId)
} else {
customerId = customerId || localStorage.getItem('current_customer_id') || ''
}
if (!customerId) {
setLoading(false)
setError('缺少客户ID参数')
Toast.show({ content: '缺少客户ID参数', icon: 'fail' })
return
}
try {
const res = await fetch(
`/api/v1/archive/daily-summary/all?customerId=${encodeURIComponent(customerId)}&staffId=${encodeURIComponent(staffId)}&_t=${Date.now()}`,
{
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
}
)
const data = await res.json()
if (data.code === 0 && Array.isArray(data.data)) {
setList(data.data)
} else {
const msg = data.message || '加载失败'
setError(msg)
Toast.show({ content: msg, icon: 'fail' })
}
} catch (e) {
console.error('加载每日总结失败:', e)
setError('网络错误')
Toast.show({ content: '网络错误', icon: 'fail' })
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div style={{ padding: 60, textAlign: 'center' }}>
<SpinLoading style={{ '--size': '32px' } as any} />
<div style={{ marginTop: 16, color: '#999', fontSize: 14 }}>加载每日总结中...</div>
</div>
)
}
if (needLogin) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 20 }}>
<div style={{ fontSize: 14, color: '#999', marginBottom: 24 }}>{error || '未获取到员工ID,请先登录'}</div>
<button
onClick={redirectToWeComOAuth}
style={{
padding: '10px 32px',
background: '#1677ff',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: 15,
cursor: 'pointer',
fontWeight: 500
}}
>
企微一键登录
</button>
</div>
)
}
if (error) {
return (
<div style={{ padding: 60, textAlign: 'center' }}>
<div style={{ color: '#999', fontSize: 14 }}>{error}</div>
</div>
)
}
if (list.length === 0) {
return (
<div style={{ padding: 60 }}>
<Empty description="暂无每日总结" />
</div>
)
}
return (
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
<div style={{
background: '#fff',
borderRadius: 12,
padding: '14px 16px',
marginBottom: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}>
<div style={{ fontSize: 17, fontWeight: 600, color: '#333' }}>
📋 每日总结
</div>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
共 {list.length} 条 · 按时间倒序
</div>
</div>
{list.map((item, index) => (
<SummaryCard key={item.id || index} item={item} />
))}
</div>
)
}
function SummaryCard({ item }: { item: SummaryItem }) {
const parsed = parseSummary(item.summary)
const hasStructure = parsed.sections.length > 0 || parsed.tags.length > 0
return (
<div style={{
background: '#fff',
borderRadius: 14,
padding: '18px 16px',
marginBottom: 14,
boxShadow: '0 2px 10px rgba(0,0,0,0.05)'
}}>
{/* 头部 */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 14,
marginBottom: 16,
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CalendarIcon />
<span style={{ fontSize: 15, fontWeight: 600, color: '#1677ff' }}>
{item.msgTime || formatDate(item.createdAt)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UserIcon />
<span style={{ fontSize: 14, color: '#666' }}>
{item.staffName || item.staffId || '未知老师'}
</span>
</div>
</div>
{hasStructure ? (
<>
{parsed.sections.map((section, idx) => (
<SectionBlock key={idx} section={section} />
))}
{parsed.tags.length > 0 && <TagList tags={parsed.tags} />}
</>
) : (
<div style={{ fontSize: 14, lineHeight: 1.8, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{item.summary || item.messageContext || '无内容'}
</div>
)}
</div>
)
}
function SectionBlock({ section }: { section: SummarySection }) {
const config = getSectionConfig(section.title)
const showTitle = displaySectionTitle(section.title)
return (
<div style={{ marginBottom: 18 }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 12,
fontSize: 15,
fontWeight: 600,
color: config.titleColor
}}>
<span style={{ fontSize: 16 }}>{config.icon}</span>
{showTitle}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{section.items.map((text, idx) => (
<div key={idx} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<div style={{ flexShrink: 0, marginTop: 2 }}>
{section.title.includes('核心诉求') ? (
<NumberBadge n={idx + 1} />
) : section.title.includes('处理结果') ? (
<GreenCheck />
) : section.title.includes('待办') ? (
<OrangeClock />
) : (
<Dot color={config.titleColor} />
)}
</div>
<div style={{
fontSize: 14,
lineHeight: 1.7,
color: '#333',
wordBreak: 'break-all',
flex: 1
}}>
{text}
</div>
</div>
))}
</div>
</div>
)
}
function TagList({ tags }: { tags: string[] }) {
return (
<div style={{ marginTop: 4 }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 10,
fontSize: 15,
fontWeight: 600,
color: '#1677ff'
}}>
<span style={{ fontSize: 16 }}>🏷️</span>
关键标签
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{tags.map((tag, idx) => (
<span key={idx} style={{
display: 'inline-block',
padding: '5px 12px',
background: '#e6f4ff',
color: '#1677ff',
borderRadius: 16,
fontSize: 13,
fontWeight: 500,
border: '1px solid #bae0ff'
}}>
#{tag}
</span>
))}
</div>
</div>
)
}
function getSectionConfig(title: string) {
if (title.includes('核心诉求')) {
return { icon: '📝', titleColor: '#1677ff' }
}
if (title.includes('处理结果')) {
return { icon: '✅', titleColor: '#52c41a' }
}
if (title.includes('待办') || title.includes('后续')) {
return { icon: '⏰', titleColor: '#fa8c16' }
}
return { icon: '•', titleColor: '#666' }
}
function displaySectionTitle(title: string) {
if (title.includes('核心诉求')) return '学员咨询'
if (title.includes('处理结果')) return '老师解答'
if (title.includes('待办')) return '跟进建议'
return title
}
function NumberBadge({ n }: { n: number }) {
return (
<div style={{
width: 20,
height: 20,
borderRadius: '50%',
background: '#1677ff',
color: '#fff',
fontSize: 12,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{n}
</div>
)
}
function GreenCheck() {
return (
<div style={{
width: 20,
height: 20,
borderRadius: '50%',
background: '#f6ffed',
border: '1px solid #b7eb8f',
color: '#52c41a',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
✓
</div>
)
}
function OrangeClock() {
return (
<div style={{
width: 20,
height: 20,
borderRadius: '50%',
background: '#fff7e6',
border: '1px solid #ffd591',
color: '#fa8c16',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
🕐
</div>
)
}
function Dot({ color }: { color: string }) {
return (
<div style={{
width: 8,
height: 8,
borderRadius: '50%',
background: color,
marginTop: 6
}} />
)
}
function CalendarIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1677ff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
)
}
function UserIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
)
}
function parseSummary(text?: string): ParsedSummary {
if (!text) return { sections: [], tags: [], raw: '' }
const result: ParsedSummary = { sections: [], tags: [], raw: text }
let content = text
// 提取关键标签:支持 "**关键标签**:#a#b" 或 "关键标签:#a#b" 或单独行
const tagPatterns = [
/(?:\*\*)?关键标签(?:\*\*)?[::]\s*(.+?)(?=\n\s*\*\*|\n\s*$|$)/s,
/(?:\*\*)?标签(?:\*\*)?[::]\s*(.+?)(?=\n\s*\*\*|\n\s*$|$)/s
]
for (const pattern of tagPatterns) {
const tagMatch = content.match(pattern)
if (tagMatch) {
result.tags = parseTags(tagMatch[1])
content = content.replace(tagMatch[0], '')
break
}
}
// 按 **标题** 分隔区块,标题可能有空格
const sectionRegex = /\*\*\s*(核心诉求|处理结果|待办事项|待处理|后续跟进|关键标签)\s*\*\*/g
const parts: { title: string; text: string }[] = []
let lastIndex = 0
let lastTitle = ''
let match
while ((match = sectionRegex.exec(content)) !== null) {
if (lastTitle) {
parts.push({ title: lastTitle, text: content.slice(lastIndex, match.index) })
}
lastTitle = match[1]
lastIndex = match.index + match[0].length
}
if (lastTitle) {
parts.push({ title: lastTitle, text: content.slice(lastIndex) })
}
for (const part of parts) {
if (part.title === '关键标签') {
const tags = parseTags(part.text)
if (tags.length) result.tags = tags
continue
}
const items = parseItems(part.text)
if (items.length > 0) {
result.sections.push({ title: part.title, items })
}
}
// 没有任何结构化区块时,尝试整段按行解析列表
if (result.sections.length === 0 && result.tags.length === 0) {
const items = parseItems(content)
if (items.length > 1) {
result.sections.push({ title: '总结', items })
}
}
return result
}
function parseItems(text: string): string[] {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const items: string[] = []
for (const line of lines) {
// 匹配 1. / 1. / - / * / • 开头的列表项
const match = line.match(/^(?:\d+[..]\s*|[\-*•]\s+)(.+)$/)
if (match) {
items.push(cleanText(match[1]))
} else if (items.length > 0 && !line.match(/^\*\*/)) {
// 续行(非标题)
items[items.length - 1] += ' ' + cleanText(line)
}
}
// 如果整段没有列表项,把段落本身作为一条内容(兼容纯文本区块)
if (items.length === 0 && text.trim().length > 0) {
items.push(cleanText(text.trim()))
}
return items
}
function parseTags(text: string): string[] {
const s = text.trim()
if (!s) return []
// 支持 #标签1#标签2 或 标签1#标签2 或 #标签1 #标签2
return s.split(/[#\s]+/)
.map(t => t.trim())
.filter(t => t.length > 0 && t !== '、' && t !== ',')
}
function cleanText(s: string): string {
return s.replace(/\*\*/g, '').trim()
}
function formatDate(timestamp?: number) {
if (!timestamp) return '-'
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
export default SummaryApp