mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 新增日报生产片段 UI 契约与视觉保护
This commit is contained in:
@@ -19,6 +19,7 @@ import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
|||||||
import { imageInsightService } from './services/image-insight-service'
|
import { imageInsightService } from './services/image-insight-service'
|
||||||
import { getReportTemplate } from '../shared/report-templates'
|
import { getReportTemplate } from '../shared/report-templates'
|
||||||
import type { ReportTemplateRef } from '../shared/report-template-package'
|
import type { ReportTemplateRef } from '../shared/report-template-package'
|
||||||
|
import { injectReportTemplateFragmentContract } from '../shared/report-template-fragment-contract'
|
||||||
import { reportTemplateService, validateReportTemplateHtml } from './report-template-service'
|
import { reportTemplateService, validateReportTemplateHtml } from './report-template-service'
|
||||||
|
|
||||||
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
||||||
@@ -92,11 +93,13 @@ const hashName = (name: string): number => {
|
|||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackAvatar = (name: string): string => {
|
type RenderedAvatar = { source: string; fallback: boolean }
|
||||||
|
|
||||||
|
const fallbackAvatar = (name: string): RenderedAvatar => {
|
||||||
const hue = hashName(name) % 360
|
const hue = hashName(name) % 360
|
||||||
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
return { source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, fallback: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageMimeType = (contentType: string | null, source: string): string => {
|
const imageMimeType = (contentType: string | null, source: string): string => {
|
||||||
@@ -108,9 +111,9 @@ const imageMimeType = (contentType: string | null, source: string): string => {
|
|||||||
return 'image/jpeg'
|
return 'image/jpeg'
|
||||||
}
|
}
|
||||||
|
|
||||||
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
|
const embedAvatar = async (source: string | undefined, name: string): Promise<RenderedAvatar> => {
|
||||||
if (!source) return fallbackAvatar(name)
|
if (!source) return fallbackAvatar(name)
|
||||||
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return { source, fallback: false }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (/^https?:\/\//i.test(source)) {
|
if (/^https?:\/\//i.test(source)) {
|
||||||
@@ -123,12 +126,12 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
|
|||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
const mime = imageMimeType(response.headers.get('content-type'), source)
|
const mime = imageMimeType(response.headers.get('content-type'), source)
|
||||||
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
|
return { source: `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`, fallback: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||||
const buffer = await fs.readFile(localPath)
|
const buffer = await fs.readFile(localPath)
|
||||||
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
|
return { source: `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`, fallback: false }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
||||||
return fallbackAvatar(name)
|
return fallbackAvatar(name)
|
||||||
@@ -195,17 +198,23 @@ const addReportCsp = (html: string, allowFileImages = true): string =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const inlineTemplateAssets = async (html: string, templateRoot: string): Promise<string> => {
|
const inlineTemplateAssets = async (html: string, templateRoot: string): Promise<string> => {
|
||||||
const matches = [...html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)]
|
const assetDataUrl = async (relative: string): Promise<string | null> => {
|
||||||
let result = html
|
|
||||||
for (const match of matches) {
|
|
||||||
const relative = match[2]
|
|
||||||
const assetPath = path.resolve(templateRoot, relative)
|
const assetPath = path.resolve(templateRoot, relative)
|
||||||
if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) continue
|
if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) return null
|
||||||
const extension = path.extname(assetPath).toLowerCase()
|
const extension = path.extname(assetPath).toLowerCase()
|
||||||
const mime =
|
const mime =
|
||||||
extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg'
|
extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg'
|
||||||
const data = (await fs.readFile(assetPath)).toString('base64')
|
const data = (await fs.readFile(assetPath)).toString('base64')
|
||||||
result = result.replace(match[0], `src="data:${mime};base64,${data}"`)
|
return `data:${mime};base64,${data}`
|
||||||
|
}
|
||||||
|
let result = html
|
||||||
|
for (const match of html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)) {
|
||||||
|
const dataUrl = await assetDataUrl(match[2])
|
||||||
|
if (dataUrl) result = result.replace(match[0], `src="${dataUrl}"`)
|
||||||
|
}
|
||||||
|
for (const match of html.matchAll(/\burl\(\s*(['"]?)(assets\/[A-Za-z0-9._/-]+)\1\s*\)/g)) {
|
||||||
|
const dataUrl = await assetDataUrl(match[2])
|
||||||
|
if (dataUrl) result = result.replace(match[0], `url("${dataUrl}")`)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -267,24 +276,34 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||||
|
|
||||||
const avatars = new Map<string, string>()
|
const avatars = new Map<string, RenderedAvatar>()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(avatarNames).map(async (name) => {
|
Array.from(avatarNames).map(async (name) => {
|
||||||
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
const avatar = (name: string): RenderedAvatar => avatars.get(name) || fallbackAvatar(name)
|
||||||
|
const renderAvatar = (
|
||||||
|
name: string,
|
||||||
|
role: 'hero' | 'message' | 'participant' | 'ranking',
|
||||||
|
legacyClass = '',
|
||||||
|
alt = ''
|
||||||
|
): string => {
|
||||||
|
const rendered = avatar(name)
|
||||||
|
const fallbackClass = rendered.fallback ? ' tm-avatar--fallback' : ''
|
||||||
|
return `<img class="${legacyClass ? `${legacyClass} ` : ''}tm-avatar tm-avatar--${role}${fallbackClass}" src="${escapeHtml(rendered.source)}" alt="${escapeHtml(alt)}">`
|
||||||
|
}
|
||||||
|
|
||||||
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||||
const heroAvatars = heroNames
|
const heroAvatars = heroNames
|
||||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
.map((name) => renderAvatar(name, 'hero', '', name))
|
||||||
.join('')
|
.join('')
|
||||||
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
||||||
|
|
||||||
const topicCards = report.topics
|
const topicCards = report.topics
|
||||||
.map(
|
.map(
|
||||||
(topic) => `<div class="card topic-card">
|
(topic) => `<div class="card topic-card tm-fragment tm-topic-card">
|
||||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
<div class="topic-title-row tm-topic-card__title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||||
<p>${escapeHtml(topic.summary)}</p>
|
<p>${escapeHtml(topic.summary)}</p>
|
||||||
${
|
${
|
||||||
@@ -327,11 +346,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
})()
|
})()
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
<div class="participants">${topic.participants
|
<div class="participants tm-topic-card__participants">${topic.participants
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(
|
.map(
|
||||||
(name) =>
|
(name) =>
|
||||||
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
|
`<span class="person-chip tm-participant">${renderAvatar(name, 'participant')}<b class="tm-participant__name">${escapeHtml(name)}</b></span>`
|
||||||
)
|
)
|
||||||
.join('')}</div>
|
.join('')}</div>
|
||||||
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
||||||
@@ -348,10 +367,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const importantMessages = report.importantMessages
|
const importantMessages = report.importantMessages
|
||||||
.map(
|
.map(
|
||||||
(message) => `<div class="important-card">
|
(message) => `<div class="important-card tm-fragment tm-message tm-message--important">
|
||||||
<img class="avatar" src="${avatar(message.sender)}" alt="">
|
${renderAvatar(message.sender, 'message', 'avatar')}
|
||||||
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
|
<div class="important-body tm-message__body"><div class="important-meta tm-message__meta"><b class="tm-message__author">${escapeHtml(message.sender)}</b><span class="tm-message__time">${escapeHtml(message.time)}</span></div>
|
||||||
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
|
<div class="important-text tm-message__text">${escapeHtml(message.content)}</div><div class="important-note tm-message__note">${escapeHtml(message.note)}</div></div>
|
||||||
</div>`
|
</div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
@@ -359,12 +378,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
const quoteBlocks = report.quotes
|
const quoteBlocks = report.quotes
|
||||||
.map(
|
.map(
|
||||||
(quote) =>
|
(quote) =>
|
||||||
`<div class="chat-block">${quote.messages
|
`<div class="chat-block tm-fragment tm-quote">${quote.messages
|
||||||
.map(
|
.map(
|
||||||
(
|
(
|
||||||
message
|
message
|
||||||
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
|
) => `<div class="chat-msg tm-message tm-message--quote">${renderAvatar(message.sender, 'message', 'chat-avatar')}<div class="tm-message__body">
|
||||||
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
|
<div class="chat-name tm-message__author">${escapeHtml(message.sender)}</div><div class="chat-bubble tm-message__text">${escapeHtml(message.content)}</div>
|
||||||
</div></div>`
|
</div></div>`
|
||||||
)
|
)
|
||||||
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
||||||
@@ -373,7 +392,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const todoCards = (report.todos || [])
|
const todoCards = (report.todos || [])
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="action-card todo-card">
|
(item) => `<div class="action-card todo-card tm-fragment tm-todo-card">
|
||||||
<b>${escapeHtml(item.task)}</b>
|
<b>${escapeHtml(item.task)}</b>
|
||||||
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||||
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
||||||
@@ -383,7 +402,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const unresolvedCards = (report.unresolved || [])
|
const unresolvedCards = (report.unresolved || [])
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="action-card unresolved-card">
|
(item) => `<div class="action-card unresolved-card tm-fragment tm-unresolved-card">
|
||||||
<b>${escapeHtml(item.question)}</b>
|
<b>${escapeHtml(item.question)}</b>
|
||||||
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||||
<div class="action-note">${escapeHtml(item.note)}</div>
|
<div class="action-note">${escapeHtml(item.note)}</div>
|
||||||
@@ -410,7 +429,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const reversalCards = (report.reversals || [])
|
const reversalCards = (report.reversals || [])
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="qa-card">
|
(item) => `<div class="qa-card tm-fragment tm-reversal-card">
|
||||||
<b>${escapeHtml(item.topic)}</b>
|
<b>${escapeHtml(item.topic)}</b>
|
||||||
<div>最初:${escapeHtml(item.initialView)}</div>
|
<div>最初:${escapeHtml(item.initialView)}</div>
|
||||||
<div>后来:${escapeHtml(item.finalView)}</div>
|
<div>后来:${escapeHtml(item.finalView)}</div>
|
||||||
@@ -449,7 +468,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const voiceCards = (report.media?.voiceHighlights || [])
|
const voiceCards = (report.media?.voiceHighlights || [])
|
||||||
.map(
|
.map(
|
||||||
(item) => `<div class="qa-card">
|
(item) => `<div class="qa-card tm-fragment tm-voice-card">
|
||||||
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
||||||
<div>${escapeHtml(item.note)}</div>
|
<div>${escapeHtml(item.note)}</div>
|
||||||
</div>`
|
</div>`
|
||||||
@@ -458,8 +477,8 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
|
|
||||||
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
||||||
.map(
|
.map(
|
||||||
(item, index) => `<div class="rank">
|
(item, index) => `<div class="rank tm-fragment tm-ranking-item">
|
||||||
<img src="${avatar(item.sender)}" alt="">
|
${renderAvatar(item.sender, 'ranking')}
|
||||||
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
||||||
<span>${item.count} 条 · ${item.durationSec} 秒</span>
|
<span>${item.count} 条 · ${item.durationSec} 秒</span>
|
||||||
</div>`
|
</div>`
|
||||||
@@ -480,7 +499,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(
|
.map(
|
||||||
(speaker, index) =>
|
(speaker, index) =>
|
||||||
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
`<div class="rank tm-fragment tm-ranking-item">${renderAvatar(speaker.name, 'ranking')}<b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
@@ -510,7 +529,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
const qaCards = report.qa
|
const qaCards = report.qa
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(item) =>
|
||||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
`<div class="qa-card tm-fragment tm-qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
@@ -637,7 +656,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||||
return addReportCsp(html, resolvedTemplate.source === 'builtin')
|
return addReportCsp(injectReportTemplateFragmentContract(html), resolvedTemplate.source === 'builtin')
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderReportSnapshotHtml = async (
|
const renderReportSnapshotHtml = async (
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
export const REPORT_TEMPLATE_FRAGMENT_CONTRACT_VERSION = '1'
|
||||||
|
|
||||||
|
// 外部模板可以通过变量调整主题,但不允许生产 fragment 失去基本几何约束。
|
||||||
|
export const REPORT_TEMPLATE_FRAGMENT_CONTRACT_CSS = `
|
||||||
|
:root {
|
||||||
|
--tm-avatar-message-size: 34px;
|
||||||
|
--tm-avatar-participant-size: 22px;
|
||||||
|
--tm-avatar-ranking-size: 26px;
|
||||||
|
--tm-avatar-hero-size: 40px;
|
||||||
|
--tm-avatar-radius: 50%;
|
||||||
|
--tm-message-gap: 8px;
|
||||||
|
--tm-participant-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-fragment { min-width: 0 !important; max-width: 100%; }
|
||||||
|
|
||||||
|
.tm-fragment,
|
||||||
|
.tm-fragment * {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-message {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
gap: var(--tm-message-gap, 8px);
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-message__body {
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-message__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 6px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-participant {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: var(--tm-participant-gap, 4px);
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-avatar {
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
aspect-ratio: 1 / 1 !important;
|
||||||
|
object-fit: cover !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
border-radius: var(--tm-avatar-radius, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
img.tm-avatar.tm-avatar--message {
|
||||||
|
width: clamp(28px, var(--tm-avatar-message-size, 34px), 44px) !important;
|
||||||
|
height: clamp(28px, var(--tm-avatar-message-size, 34px), 44px) !important;
|
||||||
|
min-width: clamp(28px, var(--tm-avatar-message-size, 34px), 44px) !important;
|
||||||
|
max-width: clamp(28px, var(--tm-avatar-message-size, 34px), 44px) !important;
|
||||||
|
min-height: clamp(28px, var(--tm-avatar-message-size, 34px), 44px) !important;
|
||||||
|
max-height: clamp(28px, var(--tm-avatar-message-size, 34px), 44px) !important;
|
||||||
|
flex-basis: clamp(28px, var(--tm-avatar-message-size, 34px), 44px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.tm-avatar.tm-avatar--participant {
|
||||||
|
width: clamp(18px, var(--tm-avatar-participant-size, 22px), 28px) !important;
|
||||||
|
height: clamp(18px, var(--tm-avatar-participant-size, 22px), 28px) !important;
|
||||||
|
min-width: clamp(18px, var(--tm-avatar-participant-size, 22px), 28px) !important;
|
||||||
|
max-width: clamp(18px, var(--tm-avatar-participant-size, 22px), 28px) !important;
|
||||||
|
min-height: clamp(18px, var(--tm-avatar-participant-size, 22px), 28px) !important;
|
||||||
|
max-height: clamp(18px, var(--tm-avatar-participant-size, 22px), 28px) !important;
|
||||||
|
flex-basis: clamp(18px, var(--tm-avatar-participant-size, 22px), 28px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.tm-avatar.tm-avatar--ranking {
|
||||||
|
width: clamp(20px, var(--tm-avatar-ranking-size, 26px), 34px) !important;
|
||||||
|
height: clamp(20px, var(--tm-avatar-ranking-size, 26px), 34px) !important;
|
||||||
|
min-width: clamp(20px, var(--tm-avatar-ranking-size, 26px), 34px) !important;
|
||||||
|
max-width: clamp(20px, var(--tm-avatar-ranking-size, 26px), 34px) !important;
|
||||||
|
min-height: clamp(20px, var(--tm-avatar-ranking-size, 26px), 34px) !important;
|
||||||
|
max-height: clamp(20px, var(--tm-avatar-ranking-size, 26px), 34px) !important;
|
||||||
|
flex-basis: clamp(20px, var(--tm-avatar-ranking-size, 26px), 34px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.tm-avatar.tm-avatar--hero {
|
||||||
|
width: clamp(28px, var(--tm-avatar-hero-size, 40px), 56px) !important;
|
||||||
|
height: clamp(28px, var(--tm-avatar-hero-size, 40px), 56px) !important;
|
||||||
|
min-width: clamp(28px, var(--tm-avatar-hero-size, 40px), 56px) !important;
|
||||||
|
max-width: clamp(28px, var(--tm-avatar-hero-size, 40px), 56px) !important;
|
||||||
|
min-height: clamp(28px, var(--tm-avatar-hero-size, 40px), 56px) !important;
|
||||||
|
max-height: clamp(28px, var(--tm-avatar-hero-size, 40px), 56px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-message__author,
|
||||||
|
.tm-message__time,
|
||||||
|
.tm-message__text,
|
||||||
|
.tm-participant__name {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
/* 只折叠承载 production fragment 的主内容网格,避免桌面模板把正文压成多列窄缝。 */
|
||||||
|
main > :has(.tm-fragment) {
|
||||||
|
grid-template-columns: minmax(0, 1fr) !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const injectReportTemplateFragmentContract = (html: string): string => {
|
||||||
|
const style = `<style id="tm-production-fragment-contract" data-version="${REPORT_TEMPLATE_FRAGMENT_CONTRACT_VERSION}">${REPORT_TEMPLATE_FRAGMENT_CONTRACT_CSS}</style>`
|
||||||
|
if (/<\/head\s*>/i.test(html)) return html.replace(/<\/head\s*>/i, `${style}</head>`)
|
||||||
|
return `${style}${html}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { _electron as electron, chromium, expect, test } from '@playwright/test'
|
||||||
|
import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { ZipArchive } from 'archiver'
|
||||||
|
|
||||||
|
const fixture = JSON.parse(
|
||||||
|
readFileSync(resolve('tests/fixtures/report-template-production-fragments.json'), 'utf8')
|
||||||
|
)
|
||||||
|
|
||||||
|
const makeZip = async (directory: string): Promise<string> => {
|
||||||
|
const archivePath = join(directory, 'hostile-avatar-template.zip')
|
||||||
|
const output = createWriteStream(archivePath)
|
||||||
|
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||||||
|
archive.pipe(output)
|
||||||
|
archive.append(
|
||||||
|
JSON.stringify({
|
||||||
|
protocolVersion: '1.0',
|
||||||
|
kind: 'daily-report',
|
||||||
|
id: 'community.github.example.fragment-contract',
|
||||||
|
name: 'Production Fragment Contract 压力模板',
|
||||||
|
author: { name: 'fixture' },
|
||||||
|
templateVersion: '1.0.0',
|
||||||
|
interfaceVersion: '1',
|
||||||
|
entry: 'template.html',
|
||||||
|
capture: { width: 1000, maxWidth: 1000, maxHeight: 20000 },
|
||||||
|
license: { spdx: 'MIT' }
|
||||||
|
}),
|
||||||
|
{ name: 'manifest.json' }
|
||||||
|
)
|
||||||
|
archive.append(
|
||||||
|
`<!doctype html><html><head><style>
|
||||||
|
*{box-sizing:border-box}html,body{margin:0;background:#f2f5f7;color:#102030;font-family:sans-serif}body{overflow-x:hidden}.report{max-width:1000px;margin:0 auto;padding:20px}.panel{margin:12px 0;padding:14px;border:1px solid #c9d4dd;border-radius:8px}.topics,.participants{display:flex;flex-wrap:wrap;gap:8px}.topic-card{min-width:0;flex:1 1 260px}.important-card,.chat-msg{padding:10px;border-top:1px solid #d8e0e6}.chat-bubble{padding:8px;background:#fff;max-width:100%}img{width:100%;height:auto}.empty-section{display:none!important}@media(max-width:680px){.report{padding:12px}.panel{padding:10px}}
|
||||||
|
</style></head><body><main class="report"><header class="panel">{{HERO_AVATARS}}</header><section class="panel topics">{{TOPIC_CARDS}}</section><section class="panel">{{IMPORTANT_MESSAGES}}</section><section class="panel">{{QUOTE_BLOCKS}}</section><section class="panel">{{TODO_CARDS}}{{UNRESOLVED_CARDS}}</section><section class="panel">{{QA_CARDS}}</section><section class="panel">{{RANK_ITEMS}}</section></main></body></html>`,
|
||||||
|
{ name: 'template.html' }
|
||||||
|
)
|
||||||
|
await new Promise<void>((resolvePromise, reject) => {
|
||||||
|
output.on('close', resolvePromise)
|
||||||
|
output.on('error', reject)
|
||||||
|
archive.on('error', reject)
|
||||||
|
void archive.finalize().catch(reject)
|
||||||
|
})
|
||||||
|
return archivePath
|
||||||
|
}
|
||||||
|
|
||||||
|
test('REPORT-TEMPLATE-E2E-FRAGMENT-01 protects production avatars and text from hostile template CSS', async ({}, testInfo) => {
|
||||||
|
const userData = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-user-'))
|
||||||
|
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-output-'))
|
||||||
|
const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-fixture-'))
|
||||||
|
const launch = () =>
|
||||||
|
electron.launch({
|
||||||
|
args: [resolve('out/main/reportTemplateTest.js')],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||||
|
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let app = await launch()
|
||||||
|
try {
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
const installed = await page.evaluate((packagePath) => window.api.installReportTemplate(packagePath), await makeZip(fixtureDir))
|
||||||
|
expect(installed.success, installed.error).toBe(true)
|
||||||
|
const exported = await page.evaluate(
|
||||||
|
({ metadata, report }) =>
|
||||||
|
window.api.exportGroupReport({
|
||||||
|
templateRef: { id: 'community.github.example.fragment-contract', version: '1.0.0' },
|
||||||
|
metadata,
|
||||||
|
report
|
||||||
|
}),
|
||||||
|
fixture
|
||||||
|
)
|
||||||
|
expect(exported.success, exported.error).toBe(true)
|
||||||
|
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('tm-production-fragment-contract')
|
||||||
|
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('tm-avatar--fallback')
|
||||||
|
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('<>')
|
||||||
|
expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000)
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
try {
|
||||||
|
for (const viewportWidth of [1000, 430]) {
|
||||||
|
const inspectPage = await browser.newPage({ viewport: { width: viewportWidth, height: 1200 }, deviceScaleFactor: 1 })
|
||||||
|
const externalRequests: string[] = []
|
||||||
|
const badResponses: string[] = []
|
||||||
|
inspectPage.on('request', (request) => {
|
||||||
|
if (/^https?:/i.test(request.url())) externalRequests.push(request.url())
|
||||||
|
})
|
||||||
|
inspectPage.on('response', (response) => {
|
||||||
|
if (response.status() >= 400) badResponses.push(`${response.status()} ${response.url()}`)
|
||||||
|
})
|
||||||
|
await inspectPage.goto(pathToFileURL(exported.htmlPath!).href)
|
||||||
|
await inspectPage.waitForFunction(() => Array.from(document.images).every((image) => image.complete))
|
||||||
|
const metrics = await inspectPage.evaluate(() => {
|
||||||
|
const rect = (element: Element) => {
|
||||||
|
const box = element.getBoundingClientRect()
|
||||||
|
return { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }
|
||||||
|
}
|
||||||
|
const overlaps = (first: ReturnType<typeof rect>, second: ReturnType<typeof rect>) =>
|
||||||
|
first.left < second.right && first.right > second.left && first.top < second.bottom && first.bottom > second.top
|
||||||
|
const avatars = Array.from(document.querySelectorAll('img.tm-avatar')).map((element) => {
|
||||||
|
const box = rect(element)
|
||||||
|
const parent = element.closest('.tm-message,.tm-participant,.tm-ranking-item,.tm-fragment,.report')
|
||||||
|
return {
|
||||||
|
classes: element.className,
|
||||||
|
box,
|
||||||
|
parent: parent ? rect(parent) : null,
|
||||||
|
display: getComputedStyle(element).display,
|
||||||
|
objectFit: getComputedStyle(element).objectFit
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const messages = Array.from(document.querySelectorAll('.tm-message')).map((element) => {
|
||||||
|
const avatar = element.querySelector('img.tm-avatar')
|
||||||
|
const body = element.querySelector('.tm-message__body')
|
||||||
|
const author = element.querySelector('.tm-message__author')
|
||||||
|
const timestamp = element.querySelector('.tm-message__time')
|
||||||
|
return {
|
||||||
|
message: rect(element),
|
||||||
|
avatar: avatar ? rect(avatar) : null,
|
||||||
|
body: body ? rect(body) : null,
|
||||||
|
author: author ? rect(author) : null,
|
||||||
|
timestamp: timestamp ? rect(timestamp) : null,
|
||||||
|
authorTimestampOverlap: author && timestamp ? overlaps(rect(author), rect(timestamp)) : false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
imageFailures: Array.from(document.images).filter((image) => image.naturalWidth === 0).length,
|
||||||
|
avatars,
|
||||||
|
messages,
|
||||||
|
fallbackCount: document.querySelectorAll('.tm-avatar--fallback').length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(metrics.scrollWidth).toBeLessThanOrEqual(viewportWidth + 1)
|
||||||
|
expect(metrics.imageFailures).toBe(0)
|
||||||
|
expect(metrics.fallbackCount).toBeGreaterThanOrEqual(4)
|
||||||
|
for (const avatar of metrics.avatars) {
|
||||||
|
expect(avatar.display).not.toBe('none')
|
||||||
|
expect(avatar.objectFit).toBe('cover')
|
||||||
|
expect(avatar.box.width).toBeGreaterThan(0)
|
||||||
|
expect(avatar.box.height).toBeGreaterThan(0)
|
||||||
|
expect(Math.abs(avatar.box.width - avatar.box.height)).toBeLessThanOrEqual(1)
|
||||||
|
if (avatar.classes.includes('tm-avatar--message')) {
|
||||||
|
expect(avatar.box.width).toBeGreaterThanOrEqual(28)
|
||||||
|
expect(avatar.box.width).toBeLessThanOrEqual(44)
|
||||||
|
}
|
||||||
|
if (avatar.classes.includes('tm-avatar--participant')) {
|
||||||
|
expect(avatar.box.width).toBeGreaterThanOrEqual(18)
|
||||||
|
expect(avatar.box.width).toBeLessThanOrEqual(28)
|
||||||
|
}
|
||||||
|
expect(avatar.parent).not.toBeNull()
|
||||||
|
expect(avatar.box.width).toBeLessThanOrEqual(avatar.parent!.width + 1)
|
||||||
|
expect(avatar.box.height).toBeLessThanOrEqual(avatar.parent!.height + 1)
|
||||||
|
}
|
||||||
|
for (const message of metrics.messages) {
|
||||||
|
expect(message.body?.width || 0).toBeGreaterThan(0)
|
||||||
|
expect(message.body!.right).toBeLessThanOrEqual(viewportWidth + 1)
|
||||||
|
expect(message.authorTimestampOverlap).toBe(false)
|
||||||
|
expect(message.avatar!.right).toBeLessThanOrEqual(message.body!.left + 1)
|
||||||
|
}
|
||||||
|
await inspectPage.screenshot({ path: testInfo.outputPath(`fragment-contract-${viewportWidth}.png`), fullPage: true })
|
||||||
|
await inspectPage.close()
|
||||||
|
expect(externalRequests).toEqual([])
|
||||||
|
expect(badResponses).toEqual([])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
rmSync(fixtureDir, { recursive: true, force: true })
|
||||||
|
rmSync(outputDir, { recursive: true, force: true })
|
||||||
|
rmSync(userData, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"groupName": "Production Fragment 验收组",
|
||||||
|
"reportDate": "2026-09-09",
|
||||||
|
"dateRange": "09:00-18:30",
|
||||||
|
"messageCount": 128,
|
||||||
|
"activeUsers": 9,
|
||||||
|
"timeSpan": "9 h 30 min",
|
||||||
|
"generatedAt": "2026-09-09 18:35",
|
||||||
|
"recordNote": "虚构数据:仅用于生产 fragment UI contract 验收。",
|
||||||
|
"footerNote": "不对应真实聊天记录。",
|
||||||
|
"heroParticipants": ["真实图片用户", "无头像", "王小明"],
|
||||||
|
"avatars": {
|
||||||
|
"真实图片用户": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII="
|
||||||
|
},
|
||||||
|
"reportMode": "full"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"overview": "真实 renderer 生成的 fragment 压力 fixture,覆盖头像、fallback、长昵称、长正文与空模块。",
|
||||||
|
"hero": {
|
||||||
|
"headline": "生产 Fragment 压力验收",
|
||||||
|
"summary": "头像必须保持受保护的几何尺寸,文本必须在桌面和移动宽度下可读。",
|
||||||
|
"keyTakeaway": "模板主题可以变化,但不能把生产 fragment 挤坏。",
|
||||||
|
"pendingNote": "",
|
||||||
|
"statusLine": "使用真实与 fallback 头像混合场景。"
|
||||||
|
},
|
||||||
|
"topics": [
|
||||||
|
{
|
||||||
|
"title": "超长话题标题:接口回归、缓存排查与多端发布窗口协调需要持续跟踪",
|
||||||
|
"timeRange": "09:10-11:40",
|
||||||
|
"heat": "高",
|
||||||
|
"participants": ["真实图片用户", "无头像", "王小明", "赵小红", "陈晨", "李雷", "韩梅梅", "超长昵称示例用户(含 &<> 特殊字符)以及更多文字"],
|
||||||
|
"summary": "多个参与者同时讨论时,参与者头像与昵称应换行而不突破 topic 容器。",
|
||||||
|
"conclusions": [{ "text": "保留最小可读正文宽度,并保证 fallback 与真实图片头像的几何一致。" }],
|
||||||
|
"keywords": ["头像", "长昵称", "移动端", "contract"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "第二个话题",
|
||||||
|
"timeRange": "14:00-15:30",
|
||||||
|
"heat": "中",
|
||||||
|
"participants": ["无头像"],
|
||||||
|
"summary": "单参与者与空 optional 模块不应留下异常空白。",
|
||||||
|
"conclusions": [],
|
||||||
|
"keywords": ["空模块"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [],
|
||||||
|
"importantMessages": [
|
||||||
|
{
|
||||||
|
"sender": "真实图片用户",
|
||||||
|
"time": "10:01",
|
||||||
|
"content": "普通图片头像与普通正文。",
|
||||||
|
"note": "图片头像"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sender": "超长昵称示例用户(含 &<> 特殊字符)以及更多文字",
|
||||||
|
"time": "10:02",
|
||||||
|
"content": "这是一段包含中文、English、emoji 🙂、URL-like https://example.invalid/path?long=query 与超长连续字符串abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 的多行正文。\n第二行仍应自然换行,且不能被头像、昵称或时间挤压。",
|
||||||
|
"note": "长昵称与长正文"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sender": "无头像",
|
||||||
|
"time": "10:03",
|
||||||
|
"content": "fallback avatar 使用单字名称。",
|
||||||
|
"note": "单字 fallback"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sender": "王小明",
|
||||||
|
"time": "10:04",
|
||||||
|
"content": "fallback avatar 使用多字名称。",
|
||||||
|
"note": "多字 fallback"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{ "sender": "真实图片用户", "content": "普通金句与真实图片头像。" },
|
||||||
|
{ "sender": "无头像", "content": "fallback 头像也必须保持与消息头像相同的安全几何。" }
|
||||||
|
],
|
||||||
|
"note": "带作者的普通金句。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{ "sender": "超长昵称示例用户(含 &<> 特殊字符)以及更多文字", "content": "没有额外作者字段的长金句:当内容足够长时,消息容器的正文宽度仍必须大于零,并能在 430px 视口中换行。" }
|
||||||
|
],
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"qa": [
|
||||||
|
{ "question": "长正文会不会压住头像?", "answer": "不会,正文容器具有最小可用宽度与换行保护。", "answerer": "无头像" }
|
||||||
|
],
|
||||||
|
"todos": [
|
||||||
|
{ "task": "完成 Production Fragment UI Contract 验收记录", "owner": "真实图片用户", "deadline": "今晚", "topic": "模板", "note": "正常 TODO" }
|
||||||
|
],
|
||||||
|
"unresolved": [
|
||||||
|
{ "question": "超长连续字符串在窄屏是否溢出?", "owner": "王小明", "status": "待验证", "note": "使用 guardrail 自动检查。", "lastDiscussedAt": "18:20" }
|
||||||
|
],
|
||||||
|
"storylines": [],
|
||||||
|
"reversals": [],
|
||||||
|
"participantChains": [],
|
||||||
|
"analytics": {
|
||||||
|
"topicHeat": [],
|
||||||
|
"activeTimeline": "09:00-18:30",
|
||||||
|
"topSpeakers": [
|
||||||
|
{ "name": "真实图片用户", "count": 34 },
|
||||||
|
{ "name": "无头像", "count": 21 },
|
||||||
|
{ "name": "王小明", "count": 15 }
|
||||||
|
],
|
||||||
|
"voiceLeaderboard": []
|
||||||
|
},
|
||||||
|
"keywords": ["production", "avatar", "fallback", "长昵称", "长正文"],
|
||||||
|
"media": { "gallery": [], "voiceHighlights": [], "funBadges": [] },
|
||||||
|
"sectionMeta": {
|
||||||
|
"topics": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 },
|
||||||
|
"importantMessages": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 4, "displayedCount": 4 },
|
||||||
|
"moments": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 },
|
||||||
|
"actions": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 },
|
||||||
|
"qa": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 1, "displayedCount": 1 },
|
||||||
|
"resources": { "enabled": false, "importance": 0, "confidence": 1, "totalCount": 0, "displayedCount": 0 }
|
||||||
|
},
|
||||||
|
"summaryStats": {
|
||||||
|
"messageCount": 128,
|
||||||
|
"activeUsers": 9,
|
||||||
|
"topicCount": 2,
|
||||||
|
"mediaCount": 0,
|
||||||
|
"imageCount": 0,
|
||||||
|
"voiceCount": 0,
|
||||||
|
"stickerCount": 0,
|
||||||
|
"conclusionCount": 1,
|
||||||
|
"todoCount": 1,
|
||||||
|
"unresolvedCount": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user