feat(summary-card): 优化每日总结卡片渲染展示样式和结构

- 新增对对话气泡、跟进建议卡片和灰色标签的支持,丰富展示形式
- 重构内容区块分类逻辑,区分学员咨询、老师解答、跟进建议及其他
- 对气泡内容支持多条消息合并及编号显示,提升阅读清晰度
- 重新设计元信息行样式,优化顾问名称及日期显示布局
- 替换原有图标为自定义头像和状态图形,增强视觉识别
- 新增待办事项卡片组件,支持待办项列表及无待办状态展示
- 调整整体样式细节,包括边距、圆角及阴影效果,更加简洁美观
- 标签展示由列表改为行内多标签展示,改善视觉效果
- 增加文本内容安全判定,过滤无效或占位性文字
- 优化日期格式化显示,支持中文月份和日期格式
This commit is contained in:
jqb 2026-08-11 16:22:27 +08:00
parent 9c3268acdb
commit 2342451983

View File

@ -1,5 +1,6 @@
/** /**
* 每日总结卡片渲染组件(summary / summary-search 页面共用) * 每日总结卡片渲染组件(summary / summary-search 页面共用)
* 样式:对话气泡(学员咨询左 / 老师解答右)+ 跟进建议卡片 + 灰色标签
*/ */
export interface SummaryItem { export interface SummaryItem {
@ -31,46 +32,89 @@ export function SummaryCard({ item }: { item: SummaryItem }) {
const parsed = parseSummary(item.summary) const parsed = parseSummary(item.summary)
const hasStructure = parsed.sections.length > 0 || parsed.tags.length > 0 const hasStructure = parsed.sections.length > 0 || parsed.tags.length > 0
// 按区块类型归类:学员咨询(左气泡) / 老师解答(右气泡) / 跟进建议(卡片) / 其它(通用卡片)
const customerItems: string[] = []
const teacherItems: string[] = []
const todoItems: string[] = []
let hasTodoSection = false
const otherSections: SummarySection[] = []
for (const section of parsed.sections) {
if (section.title.includes('核心诉求')) {
customerItems.push(...section.items)
} else if (section.title.includes('处理结果')) {
teacherItems.push(...section.items)
} else if (section.title.includes('待办') || section.title.includes('后续')) {
hasTodoSection = true
todoItems.push(...section.items.filter(t => !isNoneText(t)))
} else {
otherSections.push(section)
}
}
return ( return (
<div style={{ <div style={{
background: '#fff', background: '#fff',
borderRadius: 14, borderRadius: 16,
padding: '18px 16px', padding: '14px',
marginBottom: 14, marginBottom: 24,
boxShadow: '0 2px 10px rgba(0,0,0,0.05)' boxShadow: '0 2px 8px rgba(0,0,0,0.04)'
}}> }}>
{/* 头部 */} {/* 元信息行:日期 · 顾问 | 咨询跟进 */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingBottom: 14, padding: '0 2px',
marginBottom: 16, paddingBottom: 12,
marginBottom: 14,
borderBottom: '1px solid #f0f0f0' borderBottom: '1px solid #f0f0f0'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, minWidth: 0 }}>
<CalendarIcon /> <span style={{ color: '#1677ff', fontWeight: 700, flexShrink: 0 }}>{formatCnDate(item)}</span>
<span style={{ fontSize: 15, fontWeight: 600, color: '#1677ff' }}> <span style={{ color: '#bbb' }}>·</span>
{item.msgTime || formatDate(item.createdAt)} <span style={{
</span> color: '#666',
</div> overflow: 'hidden',
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> textOverflow: 'ellipsis',
<UserIcon /> whiteSpace: 'nowrap'
<span style={{ fontSize: 14, color: '#666' }}> }}>
{item.staffName || item.staffId || '未知老师'} 顾问 {item.staffName || item.staffId || '未知老师'}
</span> </span>
</div> </div>
<span style={{ fontSize: 12, color: '#999', flexShrink: 0 }}>咨询跟进</span>
</div> </div>
{hasStructure ? ( {hasStructure ? (
<> <>
{parsed.sections.map((section, idx) => ( {/* 对话式总结:学员咨询左气泡,老师解答右气泡(同侧多条合并为一个气泡并编号) */}
<SectionBlock key={idx} section={section} /> {(customerItems.length > 0 || teacherItems.length > 0) && (
<div style={{ marginBottom: 14 }}>
{customerItems.length > 0 && <BubbleRow side="left" items={customerItems} />}
{teacherItems.length > 0 && <BubbleRow side="right" items={teacherItems} />}
</div>
)}
{/* 跟进建议卡片 */}
{hasTodoSection && <TodoCard items={todoItems} />}
{/* 其它区块通用展示 */}
{otherSections.map((section, idx) => (
<GenericSection key={idx} section={section} />
))} ))}
{parsed.tags.length > 0 && <TagList tags={parsed.tags} />}
{/* 关键标签 */}
{parsed.tags.length > 0 && <TagRow tags={parsed.tags} />}
</> </>
) : ( ) : (
<div style={{ fontSize: 14, lineHeight: 1.8, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}> <div style={{
padding: '0 2px 4px',
fontSize: 14,
lineHeight: 1.8,
color: '#333',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
}}>
{item.summary || item.messageContext || '无内容'} {item.summary || item.messageContext || '无内容'}
</div> </div>
)} )}
@ -78,196 +122,193 @@ export function SummaryCard({ item }: { item: SummaryItem }) {
) )
} }
function SectionBlock({ section }: { section: SummarySection }) { function BubbleRow({ side, items }: { side: 'left' | 'right'; items: string[] }) {
const config = getSectionConfig(section.title) const isLeft = side === 'left'
const showTitle = displaySectionTitle(section.title) // 多条消息合并为一个气泡,前面加 1. 2. 3. 编号
const text = items.length > 1
? items.map((t, i) => `${i + 1}. ${t}`).join('\n')
: items[0]
return ( return (
<div style={{ marginBottom: 18 }}>
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'flex-start',
gap: 8, gap: 10,
marginBottom: 12, marginBottom: 12,
fontSize: 15, flexDirection: isLeft ? 'row' : 'row-reverse'
fontWeight: 600,
color: config.titleColor
}}> }}>
<span style={{ fontSize: 16 }}>{config.icon}</span> {isLeft ? <StudentAvatar /> : <TeacherAvatar />}
{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={{ <div style={{
maxWidth: '80%',
padding: '10px 14px',
borderRadius: isLeft ? '4px 12px 12px 12px' : '12px 4px 12px 12px',
background: isLeft ? '#f2f3f5' : '#2b7bff',
color: isLeft ? '#333' : '#fff',
fontSize: 14, fontSize: 14,
lineHeight: 1.7, lineHeight: 1.7,
color: '#333', whiteSpace: 'pre-wrap',
wordBreak: 'break-all', wordBreak: 'break-all'
flex: 1
}}> }}>
{text} {text}
</div> </div>
</div> </div>
)
}
function StudentAvatar() {
return (
<div style={{
width: 36,
height: 36,
borderRadius: '50%',
background: '#ff9f43',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#fff">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-6 8-6s8 2 8 6v1H4z" />
</svg>
</div>
)
}
function TeacherAvatar() {
return (
<div style={{
width: 36,
height: 36,
borderRadius: '50%',
background: '#4d9aff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14a4.5 4.5 0 0 0 7 0" />
<line x1="9.3" y1="9.8" x2="9.3" y2="9.9" />
<line x1="14.7" y1="9.8" x2="14.7" y2="9.9" />
</svg>
</div>
)
}
function TodoCard({ items }: { items: string[] }) {
return (
<div style={{
background: '#f7f8fa',
borderRadius: 12,
border: '1px solid #eef0f2',
padding: '14px 16px',
marginBottom: 14
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
<div style={{
width: 20,
height: 20,
borderRadius: 6,
background: '#1677ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
flexShrink: 0
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<span style={{ fontSize: 15, fontWeight: 600, color: '#333', flex: 1 }}>跟进建议</span>
<span style={{ fontSize: 12, color: '#999' }}>
{items.length > 0 ? `${items.length} 项待办` : '暂无待办'}
</span>
</div>
{items.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((text, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{
width: 18,
height: 18,
borderRadius: '50%',
border: '1px solid #d9d9d9',
color: '#999',
fontSize: 11,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
marginTop: 1
}}>
{idx + 1}
</div>
<div style={{ fontSize: 13, lineHeight: 1.6, color: '#666', wordBreak: 'break-all', flex: 1 }}>
{text}
</div>
</div>
))}
</div>
) : (
<div style={{ fontSize: 13, color: '#bbb' }}>暂无待办事项</div>
)}
</div>
)
}
function GenericSection({ section }: { section: SummarySection }) {
return (
<div style={{
background: '#f7f8fa',
borderRadius: 12,
border: '1px solid #eef0f2',
padding: '14px 16px',
marginBottom: 14
}}>
<div style={{ fontSize: 15, fontWeight: 600, color: '#333', marginBottom: 10 }}>{section.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{section.items.map((text, idx) => (
<div key={idx} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<div style={{
width: 6,
height: 6,
borderRadius: '50%',
background: '#1677ff',
marginTop: 7,
flexShrink: 0
}} />
<div style={{ fontSize: 13, lineHeight: 1.7, color: '#666', wordBreak: 'break-all', flex: 1 }}>
{text}
</div>
</div>
))} ))}
</div> </div>
</div> </div>
) )
} }
function TagList({ tags }: { tags: string[] }) { function TagRow({ tags }: { tags: string[] }) {
return ( return (
<div style={{ marginTop: 4 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, padding: '0 2px' }}>
<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) => ( {tags.map((tag, idx) => (
<span key={idx} style={{ <span key={idx} style={{
display: 'inline-block',
padding: '5px 12px', padding: '5px 12px',
background: '#e6f4ff', background: '#e8e8ee',
color: '#1677ff', color: '#86868f',
borderRadius: 16, borderRadius: 6,
fontSize: 13, fontSize: 12
fontWeight: 500,
border: '1px solid #bae0ff'
}}> }}>
#{tag} {tag}
</span> </span>
))} ))}
</div> </div>
</div>
) )
} }
function getSectionConfig(title: string) { function isNoneText(t: string) {
if (title.includes('核心诉求')) { return /^(无|暂无|没有|无。|暂无。|没有。|无建议|暂不需要)$/.test(t.trim())
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 { function parseSummary(text?: string): ParsedSummary {
@ -367,8 +408,15 @@ function cleanText(s: string): string {
return s.replace(/\*\*/g, '').trim() return s.replace(/\*\*/g, '').trim()
} }
function formatDate(timestamp?: number) { function formatCnDate(item: SummaryItem): string {
if (!timestamp) return '-' if (item.msgTime) {
const date = new Date(timestamp) const m = item.msgTime.match(/(\d{4})[-\/年](\d{1,2})[-\/月](\d{1,2})/)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` if (m) return `${parseInt(m[2], 10)}月${parseInt(m[3], 10)}日`
return item.msgTime
}
if (item.createdAt) {
const d = new Date(item.createdAt)
return `${d.getMonth() + 1}月${d.getDate()}日`
}
return '-'
} }