661 lines
19 KiB
TypeScript
661 lines
19 KiB
TypeScript
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
|