refactor(sidebar): rewrite summary display to support structured summary parsing

重构了侧边栏的每日总结组件,新增了结构化解析能力,可以识别核心诉求、处理结果、待办等区块和标签,优化了卡片样式和展示细节,提升了总结内容的可读性和美观度。
This commit is contained in:
jqb 2026-06-24 11:35:39 +08:00
parent 4ba2109f8d
commit 51310683c7

View File

@ -19,6 +19,17 @@ interface SummaryItem {
staffName?: string staffName?: string
} }
interface SummarySection {
title: string
items: string[]
}
interface ParsedSummary {
sections: SummarySection[]
tags: string[]
raw: string
}
function SummaryApp() { function SummaryApp() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [list, setList] = useState<SummaryItem[]>([]) const [list, setList] = useState<SummaryItem[]>([])
@ -281,11 +292,11 @@ function SummaryApp() {
<div style={{ <div style={{
background: '#fff', background: '#fff',
borderRadius: 12, borderRadius: 12,
padding: '12px 16px', padding: '14px 16px',
marginBottom: 12, marginBottom: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)' boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}> }}>
<div style={{ fontSize: 16, fontWeight: 500, color: '#333' }}> <div style={{ fontSize: 17, fontWeight: 600, color: '#333' }}>
📋 每日总结 📋 每日总结
</div> </div>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}> <div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
@ -294,30 +305,352 @@ function SummaryApp() {
</div> </div>
{list.map((item, index) => ( {list.map((item, index) => (
<div key={item.id || index} style={{ <SummaryCard key={item.id || index} item={item} />
background: '#fff',
borderRadius: 12,
padding: '14px 16px',
marginBottom: 12,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div style={{ fontSize: 13, color: '#1677ff', fontWeight: 500 }}>
{item.msgTime || formatDate(item.createdAt)}
</div>
<div style={{ fontSize: 12, color: '#999' }}>
{item.staffName || item.staffId || '未知老师'}
</div>
</div>
<div style={{ fontSize: 14, lineHeight: 1.7, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{item.summary || item.messageContext || '无内容'}
</div>
</div>
))} ))}
</div> </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) { function formatDate(timestamp?: number) {
if (!timestamp) return '-' if (!timestamp) return '-'
const date = new Date(timestamp) const date = new Date(timestamp)