/* ===== Screens Part 3: Brief, Content, Monitor, Reports, Review ===== */
// ========== 05 · BRIEF COMPOSER ==========
const UNIVERSAL_CONTENT_BOUNDARIES = {
hardBlocks: [
'违法犯罪、武器制作、恐怖主义、组织仇恨、人口贩运、诈骗和禁限售交易',
'儿童安全、性剥削、未成年人裸露、诱导未成年人互动',
'仇恨言论、定向骚扰、威胁、网暴动员和人肉搜索',
'自伤自杀、危险挑战、会诱导模仿的不安全行为',
'隐私泄露、身份证件/住址/联系方式/黑客数据',
'钓鱼、恶意软件、虚假抽奖、金字塔骗局、误导性外链',
'未授权音乐/视频/图片/商标/达人素材复用',
],
cautionClaims: [
'医疗、金融、法律、安全、就业、公共事件、灾害、政策和危机信息必须有来源',
'产品能力、续航、价格、交付、销量、排名、用户收益不得绝对化',
'AIGC 或显著编辑的真实人物/真实场景需按平台要求标识',
'商业合作、赠品、佣金、联盟链接、达人激励必须披露',
'禁止刷量、买粉、互关互赞、评论农场、重复铺量和诱导互动',
],
};
const PLATFORM_CONTENT_BOUNDARIES = [
{
id: 'xhs',
platform: '小红书',
family: 'visual-search-commerce',
source: 'GitHub 家族映射 + 国内平台官方源待接入',
formatRules: ['真实体验优先,商业笔记需显性标注', '封面/标题不能制造虚假结果、虚假测评或虚假用户经历', '图文证据需能支撑前后对比、清单推荐和排名判断'],
linkRules: ['私域引流、二维码、抽奖、外链购买和导流话术需单独审核', '不使用“评论区见”“私信发链接”规避平台规则'],
creatorRules: ['达人必须披露合作关系、赠品或试用权益', '不能伪装素人自发体验或虚构购买记录'],
reviewTriggers: ['全网最低、闭眼入、官方认证、必买、治疗/收益/安全承诺', '竞品拉踩、截图证据、用户评论截图、价格和库存承诺'],
},
{
id: 'douyin',
platform: '抖音 / TikTok-style',
family: 'short-video-recommendation',
source: 'OpenTermsArchive TikTok Community Guidelines + 抖音官方源待接入',
formatRules: ['前 3 秒钩子不能误导能力、价格、结果、风险或公共事件', '危险动作、驾驶/设备演示和挑战类内容必须避免诱导模仿', '真实人物或真实场景 AIGC 需按平台要求标识'],
linkRules: ['直播、私信、橱窗、外链和私域导流需检查交易和合规风险', '禁止以承诺福利、破解、返利诱导跳转'],
creatorRules: ['品牌合作、付费口播、赠品试用、佣金和挂链必须披露', '不得使用刷量、互赞、评论脚本或虚假评价'],
reviewTriggers: ['挑战、实测、秒杀、限时抢、很快到账、无人驾驶、真实事故/灾害画面', 'AI 换脸、仿声、公众人物表态或产品背书'],
},
{
id: 'wechat-official',
platform: '微信公众号',
family: 'longform-editorial',
source: 'OpenTermsArchive WeChat Acceptable Use Policy + 微信公众平台官方源待接入',
formatRules: ['标题、封面和摘要不得标题党、恐慌化或假冒官方通知', '长文需区分事实、推断、观点和品牌立场', '技术、政策和行业判断需保留来源与边界'],
linkRules: ['外链、小程序、二维码、表单收集、抽奖和诱导分享需审核', '不得导向诈骗、违规交易、侵权下载或隐私收集'],
creatorRules: ['转载、引用、配图、数据图和第三方素材需保留授权或来源', '评论精选和回复不得泄露用户隐私或升级争议'],
reviewTriggers: ['政策解读、行业排名、技术参数、价格交付、融资/收益、抽奖福利', '医疗/金融/法律/公共安全类表述'],
},
{
id: 'wechat-channels',
platform: '视频号',
family: 'wechat-short-video-live',
source: 'OpenTermsArchive WeChat Acceptable Use Policy + 视频号官方源待接入',
formatRules: ['短视频钩子不能夸大承诺或制造未证实事实', '直播口播的价格、库存、交付、能力和福利需预审', '评论区承接需避免争吵、隐私泄露和未授权承诺'],
linkRules: ['私域添加、企业微信、群聊、二维码、直播间转化链路需单独审查', '不以福利承诺诱导用户离开平台完成高风险交易'],
creatorRules: ['官方账号、达人账号和员工账号身份要清楚', '品牌合作和达人权益不得隐藏'],
reviewTriggers: ['直播优惠、限量、交付时间、配置确定、私信领取、群内报价', '用户投诉、事故、负面评论和售后承诺'],
},
{
id: 'youtube',
platform: 'YouTube',
family: 'long-video-search',
source: 'OpenTermsArchive YouTube Community Guidelines',
formatRules: ['标题、缩略图、描述和置顶评论必须匹配实际内容', '长测/技术视频需展示测试条件、样本和限制', '避免危险行为、误导元数据、重复搬运和未授权第三方内容'],
linkRules: ['描述、口播、画面 URL 和置顶评论都算外链风险', '不得导向成人、诈骗、恶意软件、侵权下载、仇恨/骚扰或禁限售交易'],
creatorRules: ['赞助、联盟链接、赠品、借测和付费推广需披露', '不得买量、互订、诱导订阅交换或虚假互动'],
reviewTriggers: ['缩略图夸张承诺、未授权音乐/素材、外链下载、枪械/危险操作、医疗误导、AI 公共事件'],
},
{
id: 'instagram',
platform: 'Instagram',
family: 'visual-social',
source: 'OpenTermsArchive Instagram / Facebook Community Guidelines',
formatRules: ['账号身份、品牌资产和视觉素材不得造成冒充或虚假来源', 'Reels / Stories 不得使用震惊、血腥、性化或骚扰性创意', '付费内容标准比自然帖更严'],
linkRules: ['bio link、贴纸、私信和购物标签需检查诈骗、侵权和敏感品类风险', '不得利用私人困难或敏感属性做定向诱导'],
creatorRules: ['创作者关系、素材授权、音乐和 logo 使用需清楚', '不得伪装官方、伪装达人或规避封禁'],
reviewTriggers: ['before/after、用户肖像、音乐授权、抽奖、敏感属性暗示、竞品 logo', '公共人物 AI 图像或仿声'],
},
{
id: 'weibo-x',
platform: '微博 / X-style',
family: 'public-conversation',
source: 'OpenTermsArchive X Community Guidelines + 微博官方源待接入',
formatRules: ['热点参与必须与品牌事实相关,不借灾害、公共事件或争议蹭话题', '短文本声明需可被截图独立理解', '避免引战、网暴动员和与用户争夺解释权'],
linkRules: ['话题、转评、抽奖、外链和私信承接需检查诱导互动和诈骗风险', '不发布隐私信息、截图曝光或未授权聊天记录'],
creatorRules: ['达人转发和话题共创需披露合作关系', '不得组织水军、刷话题、控评或伪造声量'],
reviewTriggers: ['热搜借势、声明回应、竞品事故、用户投诉、抽奖、价格战', '政治/公共安全/灾害相关词'],
},
{
id: 'bilibili',
platform: 'B站 / YouTube long-form style',
family: 'community-long-video',
source: 'OpenTermsArchive YouTube Community Guidelines + B站官方源待接入',
formatRules: ['标题/封面不能承诺视频里没有交付的测评结论', '技术拆解和实测需标注条件、方法、限制和样本', '避免危险驾驶、设备误用和可模仿的高风险操作'],
linkRules: ['简介、评论置顶、动态和充电/店铺跳转需检查侵权、诈骗和导流风险', '第三方下载、未授权素材包和敏感交易链接禁用'],
creatorRules: ['恰饭、借测、送测、赞助、联合出品和素材来源需披露', '弹幕/评论引导不能煽动攻击竞品或用户'],
reviewTriggers: ['首发爆料、实测排行、拆机、事故画面、竞品对比、未授权 BGM/影视素材', '无人驾驶/安全能力绝对化表达'],
},
];
const AIGC_PLATFORM_VARIANT_RULES = [
{ id: 'xhs-seeding', platform: '小红书', label: '图文 / 短视频种草', len: '短视频 + 笔记', tone: '真实体验 · 搜索友好', hooks: ['标题承接统一主题,但更短更可搜', '前三行给场景或问题,不伪装素人体验', '封面和正文证据必须能支撑卖点'] },
{ id: 'wechat-channels-short-video', platform: '视频号', label: '短视频 / 直播承接', len: '15-90s', tone: '官方口播 · 场景解释', hooks: ['首行直接进入主题', '口播保留概念车边界', '评论区承接不做价格/交付承诺'] },
{ id: 'wechat-mp-longform', platform: '微信公众号', label: '长图文 / 深度解释', len: '800-1800 字', tone: '官方叙事 · 解释型', hooks: ['标题稳定承接统一主题', '正文区分事实、概念、愿景和活动信息', '适合放完整 CTA 与风险边界'] },
{ id: 'youtube-video-search', platform: 'YouTube', label: '视频搜索 / 海外解释', len: '30s-3min', tone: 'English · clear claim boundary', hooks: ['Title must match the actual footage', 'Description explains concept limits', 'Avoid unsupported performance or delivery claims'] },
{ id: 'instagram-visual-social', platform: 'Instagram', label: 'Reels / 视觉社交', len: 'Reel / Post', tone: 'Visual launch signal', hooks: ['Theme line stays concise', 'Visual identity and source must be clear', 'No implied endorsement or fake partnership'] },
];
const isBriefExternalSocialPost = (post = {}) => {
const type = `${post.source_type || ''} ${post.asset_type || ''} ${post.source_post_type || ''} ${post.relationship_status || ''}`.toLowerCase();
return Boolean(
post.is_official_post === false ||
type.includes('earned') ||
type.includes('ugc') ||
type.includes('kol') ||
type.includes('external')
);
};
const getBriefContentCount = (brief = {}, postLookup = null) => {
const item = brief || {};
if (postLookup) {
const ownedAssetCount = (item.assetPostIds || []).filter(id => postLookup.has(id)).length;
return ownedAssetCount || (item.platformVariants || []).length || 0;
}
return (item.assetPostIds || []).length || (item.platformVariants || []).length || 0;
};
const getBriefPublishState = (brief = {}) => {
const raw = String(brief?.status || '').toLowerCase();
if (['published', 'active', 'live', 'ready'].includes(raw)) return 'published';
if (['draft', 'pending', 'unpublished'].includes(raw)) return 'draft';
return getBriefContentCount(brief) ? 'published' : 'draft';
};
const getBriefPublishStatusMeta = (brief = {}) => {
const state = getBriefPublishState(brief);
return state === 'published'
? { state, label: '已发布', tagClass: 'accent' }
: { state, label: '草稿', tagClass: '' };
};
const getBriefPostDate = (post = {}) => post.published_at || post.publish_time || post.created_at || '';
const makePostLookup = (posts = []) => {
const map = new Map();
posts.forEach(post => {
[post.post_id, post.content_id, post.id].filter(Boolean).forEach(id => map.set(id, post));
});
return map;
};
const getBriefAssetPosts = (brief = {}, posts = []) => {
const ids = new Set((brief?.assetPostIds || []).filter(Boolean));
if (!ids.size) return [];
return posts.filter(post => ids.has(post.post_id) || ids.has(post.content_id) || ids.has(post.id));
};
const getBriefSortTime = (brief = {}, postLookup = null) => {
const item = brief || {};
const assetDates = (item.assetPostIds || [])
.map(id => postLookup?.get(id))
.filter(Boolean)
.map(getBriefPostDate);
const contentDates = [
...assetDates,
...(item.platformVariants || []).map(variant => variant.publishedAt),
].filter(Boolean).map(value => {
const t = new Date(String(value).replace(/年|月/g, '/').replace(/日/g, '').replace(/\./g, '/')).getTime();
return Number.isFinite(t) ? t : 0;
}).filter(Boolean);
if (contentDates.length) return Math.max(...contentDates);
const fallbackDates = [
item.endDate,
item.startDate,
item.updatedAt,
item.createdAt,
].filter(Boolean).map(value => {
const t = new Date(String(value).replace(/年|月/g, '/').replace(/日/g, '').replace(/\./g, '/')).getTime();
return Number.isFinite(t) ? t : 0;
});
return Math.max(0, ...fallbackDates);
};
const formatBriefDate = (brief = {}, postLookup = null) => {
const t = getBriefSortTime(brief, postLookup);
if (!t) return '未排期';
return new Date(t).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
};
const briefTypeLabel = (brief = {}) => {
const item = brief || {};
return item.parentCampaignId ? '子 Brief' : item.type === 'campaign' ? 'Campaign' : item.type === 'series' ? '栏目' : 'Brief';
};
const getAssignedBriefId = (post = {}) => post.primaryMasterBriefId || post.briefMapping?.primaryMasterBriefId || '';
const getBriefOptionLabel = (brief = {}, postLookup = null) => {
const count = getBriefContentCount(brief, postLookup);
return `${brief.name || brief.id}${count ? ` · ${count}` : ''}`;
};
const cleanBriefText = (value = '') => {
if (typeof value !== 'string') return '';
return value
.replace(/<[^>]+>/g, ' ')
.replace(/ | /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\s+/g, ' ')
.trim();
};
const getBriefPostTitle = (post = {}) => cleanBriefText(post.title || post.caption?.title || post.raw?.title || '未命名内容');
const getBriefPostBody = (post = {}) => [
post.body,
post.caption?.text,
post.caption?.content,
post.caption,
post.description,
post.text,
post.raw?.desc,
post.raw?.Digest,
post.raw?.digest,
post.raw?.note_card?.desc,
post.raw?.object_desc?.description,
post.raw?.description,
post.raw?.caption,
post.raw?.edge_media_to_caption?.edges?.[0]?.node?.text,
].map(cleanBriefText).find(value => value) || '';
const getBriefPostLabel = (post = {}) => {
const date = getBriefPostDate(post) || '未排期';
const platform = post.platform || '平台';
const title = getBriefPostTitle(post);
return `${date} · ${platform} · ${title}`.slice(0, 120);
};
const Brief = () => {
const [campaigns, setCampaigns] = React.useState([]);
const [briefId, setBriefId] = React.useState('');
const [marketHandoffs, setMarketHandoffs] = React.useState([]);
const [socialPosts, setSocialPosts] = React.useState([]);
const [strategyThesis, setStrategyThesis] = React.useState(null);
const [briefView, setBriefView] = React.useState('briefs');
const [mappingDrafts, setMappingDrafts] = React.useState({});
const [mappingStatus, setMappingStatus] = React.useState({});
const [briefCreateOpen, setBriefCreateOpen] = React.useState(false);
const [newBriefTitle, setNewBriefTitle] = React.useState('');
const [editingTitleId, setEditingTitleId] = React.useState('');
const [titleDraft, setTitleDraft] = React.useState('');
const [briefMutationStatus, setBriefMutationStatus] = React.useState('');
React.useEffect(() => {
fetch(socialApiUrl('/api/initiatives'))
.then(response => response.json())
.then(data => {
const rows = data.initiatives || data.campaigns || [];
setCampaigns(rows);
setBriefId(current => current || [...rows].sort((a, b) => getBriefSortTime(b) - getBriefSortTime(a))[0]?.id || '');
})
.catch(() => {});
fetch(socialApiUrl('/api/market/handoffs?target_module=strategy&limit=5'))
.then(response => response.json())
.then(data => setMarketHandoffs(data.handoffs || []))
.catch(() => setMarketHandoffs([]));
fetch(socialApiUrl('/api/social/sync/status'))
.then(response => response.json())
.then(data => setSocialPosts(data.posts || []))
.catch(() => setSocialPosts([]));
fetch(socialApiUrl('/api/strategy/thesis'))
.then(response => response.json())
.then(data => setStrategyThesis(data.strategyThesis || null))
.catch(() => setStrategyThesis(null));
}, []);
const ownedSocialPosts = React.useMemo(() => socialPosts.filter(post => !isBriefExternalSocialPost(post)), [socialPosts]);
const postLookup = React.useMemo(() => makePostLookup(ownedSocialPosts), [ownedSocialPosts]);
const sortedBriefs = React.useMemo(
() => [...campaigns].sort((a, b) => getBriefSortTime(b, postLookup) - getBriefSortTime(a, postLookup) || getBriefContentCount(b, postLookup) - getBriefContentCount(a, postLookup) || String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN')),
[campaigns, postLookup]
);
const activeCampaign = sortedBriefs.find(item => item.id === briefId) || sortedBriefs[0] || null;
const configurablePosts = React.useMemo(
() => [...ownedSocialPosts].filter(post => post.post_id || post.content_id || post.id).sort((a, b) => getPostTimeValue(b) - getPostTimeValue(a)),
[ownedSocialPosts]
);
const upsertBrief = React.useCallback((nextBrief) => {
if (!nextBrief?.id) return;
setCampaigns(prev => {
const exists = prev.some(item => item.id === nextBrief.id);
return exists ? prev.map(item => item.id === nextBrief.id ? nextBrief : item) : [nextBrief, ...prev];
});
}, []);
const createBrief = async () => {
const title = newBriefTitle.trim();
if (!title) {
setBriefMutationStatus('请先填写 Brief 标题');
return;
}
setBriefMutationStatus('creating');
try {
const response = await fetch(socialApiUrl('/api/initiatives'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'standalone',
name: title,
status: 'draft',
stage: 'Brief 编排',
goals: [],
platformFocus: [],
assetPostIds: [],
assetCampaignLabels: [title],
masterBrief: { title: `${title} Master Brief` },
}),
});
const data = await response.json();
if (!response.ok || data.ok === false) throw new Error(data.error || data.message || 'Brief 创建失败');
upsertBrief(data.initiative);
setBriefId(data.initiative.id);
setBriefCreateOpen(false);
setNewBriefTitle('');
setBriefMutationStatus('created');
window.setTimeout(() => setBriefMutationStatus(''), 1200);
} catch (error) {
setBriefMutationStatus(error.message || 'Brief 创建失败');
}
};
const beginTitleEdit = () => {
if (!activeCampaign?.id) return;
setEditingTitleId(activeCampaign.id);
setTitleDraft(activeCampaign.name || '');
setBriefMutationStatus('');
};
const saveBriefTitle = async () => {
if (!activeCampaign?.id) return;
const nextTitle = titleDraft.trim();
if (!nextTitle) {
setBriefMutationStatus('标题不能为空');
return;
}
setBriefMutationStatus('saving-title');
try {
const response = await fetch(socialApiUrl(`/api/initiatives/${encodeURIComponent(activeCampaign.id)}`), {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: nextTitle,
assetCampaignLabels: activeCampaign.assetCampaignLabels?.length ? activeCampaign.assetCampaignLabels : [nextTitle],
}),
});
const data = await response.json();
if (!response.ok || data.ok === false) throw new Error(data.error || data.message || '标题保存失败');
upsertBrief(data.initiative);
setEditingTitleId('');
setBriefMutationStatus('saved-title');
window.setTimeout(() => setBriefMutationStatus(''), 1200);
} catch (error) {
setBriefMutationStatus(error.message || '标题保存失败');
}
};
const updateBriefPublishState = async (nextState) => {
if (!activeCampaign?.id) return;
setBriefMutationStatus('saving-status');
try {
const response = await fetch(socialApiUrl(`/api/initiatives/${encodeURIComponent(activeCampaign.id)}`), {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ status: nextState === 'published' ? 'published' : 'draft' }),
});
const data = await response.json();
if (!response.ok || data.ok === false) throw new Error(data.error || data.message || '状态保存失败');
upsertBrief(data.initiative);
setBriefMutationStatus('saved-status');
window.setTimeout(() => setBriefMutationStatus(''), 1200);
} catch (error) {
setBriefMutationStatus(error.message || '状态保存失败');
}
};
const assignPostBriefFromBrief = async (post, targetBriefId) => {
const postId = post?.post_id || post?.content_id || post?.id || '';
if (!postId) return;
setMappingStatus(prev => ({ ...prev, [postId]: 'saving' }));
try {
const response = await fetch(socialApiUrl('/api/social/posts/brief'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ postId, briefId: targetBriefId }),
});
const data = await response.json();
if (!response.ok || data.ok === false) throw new Error(data.error || data.message || 'Brief 配置失败');
setSocialPosts(data.posts || []);
setCampaigns(data.initiatives || campaigns);
setMappingDrafts(prev => ({ ...prev, [postId]: targetBriefId || '' }));
setMappingStatus(prev => ({ ...prev, [postId]: 'saved' }));
window.setTimeout(() => setMappingStatus(prev => ({ ...prev, [postId]: '' })), 1200);
} catch (error) {
setMappingStatus(prev => ({ ...prev, [postId]: error.message || 'error' }));
}
};
return (
Brief 编排器 · 从策略对象到执行的翻译层
Brief 按内容发布日期倒序排列;平台变体规则进入 AIGC 内容生成目录。
{briefView === 'briefs' && (
)}
{briefView === 'content-map' ? (
setMappingDrafts(prev => ({ ...prev, [postId]: briefId }))}
onSave={assignPostBriefFromBrief}
/>
) : (
Brief 列表
{sortedBriefs.length}
{sortedBriefs.map(item => {
const statusMeta = getBriefPublishStatusMeta(item);
return (
setBriefId(item.id)}
style={{
padding:'11px 14px',
borderLeft: activeCampaign?.id === item.id ? '2px solid var(--accent)' : '2px solid transparent',
borderBottom:'1px solid var(--hairline)',
background: activeCampaign?.id === item.id ? 'var(--accent-tint)' : 'transparent',
cursor:'pointer',
}}
>
{item.name}
{getBriefContentCount(item, postLookup)}
{formatBriefDate(item, postLookup)} · {briefTypeLabel(item)}
{statusMeta.label}
);
})}
{briefCreateOpen ? (
setNewBriefTitle(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') createBrief();
if (event.key === 'Escape') { setBriefCreateOpen(false); setNewBriefTitle(''); }
}}
placeholder="输入 Brief 标题"
autoFocus
/>
{briefMutationStatus && briefMutationStatus !== 'creating' &&
{briefMutationStatus === 'created' ? '已创建' : briefMutationStatus}}
) : (
)}
{editingTitleId === activeCampaign?.id ? (
setTitleDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') saveBriefTitle();
if (event.key === 'Escape') setEditingTitleId('');
}}
style={{height:32, minWidth:0}}
autoFocus
/>
) : (
{activeCampaign?.name || 'Brief'}
)}
{getBriefContentCount(activeCampaign, postLookup)} 内容
{getBriefPublishStatusMeta(activeCampaign).label}
边界自检通过
{editingTitleId !== activeCampaign?.id && (
)}
QA Checklist
8 / 8
{['品牌定位一致','命题清晰','人群边界','CTA 明确','平台语法适配','禁用词扫描','法务风险','审核流程'].map((c, i) => (
{c}
))}
)}
);
};
const BriefMarketContextCard = ({ handoff }) => {
const pack = handoff?.contextPack || {};
const snapshot = pack.market_snapshot || {};
const risks = pack.risk_notes || [];
const whiteSpaces = pack.competitive_benchmark?.white_spaces || [];
return (
Market Context
{handoff ? 'ready' : 'none'}
{handoff ? (
<>
{snapshot.summary}
{whiteSpaces.slice(0, 2).map(item => {item})}
{risks.slice(0, 2).flatMap(item => item.review_reason || []).map(item => {item})}
>
) : (
暂无市场感知 handoff。
)}
);
};
const BriefContentMappingView = ({ posts = [], briefs = [], postLookup = null, mappingDrafts = {}, mappingStatus = {}, onDraftChange, onSave }) => {
const briefById = React.useMemo(() => new Map(briefs.map(brief => [brief.id, brief])), [briefs]);
return (
内容挂载 · 根据内容主体选择 Brief
{posts.length}
这里只处理“已发内容 {'->'} Brief”的映射。先读发布日期、平台、标题和正文主体,再选择对应 Brief 保存。
发布日期
平台
标题 / 正文
目标 Brief
操作
{posts.map(post => {
const postId = post.post_id || post.content_id || post.id;
const currentBriefId = getAssignedBriefId(post);
const draftBriefId = Object.prototype.hasOwnProperty.call(mappingDrafts, postId) ? mappingDrafts[postId] : currentBriefId;
const currentBrief = briefById.get(currentBriefId);
const status = mappingStatus[postId] || '';
const body = getBriefPostBody(post);
return (
{getBriefPostDate(post) || '-'}
{post.platform || '-'}
{getBriefPostTitle(post)}
{body || '正文为空;请根据标题、平台和原始内容链接判断。'}
当前 Brief:{currentBrief?.name || '未配置'}
{status && status !== 'saving' && {status === 'saved' ? '已保存' : status}}
);
})}
{!posts.length &&
暂无可挂载的已发内容。
}
);
};
const MasterBriefBody = ({ brief, campaign, strategyThesis }) => (
<>
{strategyThesis ? (
{strategyThesis.coreThesis}
{strategyThesis.marketBelief}
{(strategyThesis.contentParadigm || []).slice(0, 9).map(item => {item})}
) : null}
{brief?.background || `${campaign?.name || '当前策略对象'} 的背景待补充。`}
{brief?.objective || '主目标待补充。'}
{brief?.audienceSummary || '目标人群待补充。'}
{brief?.coreMessage || campaign?.coreMessage || '核心命题待确认。'}
{(brief?.sellingPoints?.length ? brief.sellingPoints : campaign?.sellingPoints || []).map(item => - {item}
)}
{brief?.cta || campaign?.cta || 'CTA 待补充。'}
>
);
const BriefMetaStrip = ({ brief, postLookup }) => (
postLookup.has(id)).length : (brief?.assetPostIds || []).length)}/>
);
const BriefContainedContent = ({ brief = {}, socialPosts = [] }) => {
const item = brief || {};
const variants = item.platformVariants || [];
const assetPosts = getBriefAssetPosts(item, socialPosts).sort((a, b) => {
const at = new Date(String(getBriefPostDate(a)).replace(/年|月/g, '/').replace(/日/g, '').replace(/\./g, '/')).getTime() || 0;
const bt = new Date(String(getBriefPostDate(b)).replace(/年|月/g, '/').replace(/日/g, '').replace(/\./g, '/')).getTime() || 0;
return bt - at;
});
const rows = assetPosts.length
? assetPosts.map(post => ({ platform: post.platform || '-', title: post.title || post.caption || post.description || '未命名内容', publishedAt: getBriefPostDate(post), id: post.post_id || post.content_id || post.id }))
: variants.map((variant, index) => ({ ...variant, id: `${variant.platform}-${variant.title}-${index}` }));
if (!rows.length) {
return (
);
}
return (
{rows.map(row => (
{row.platform}
{row.title || row.unifiedTopic}
{row.publishedAt || '-'}
))}
);
};
const ContentBoundaryBriefBody = ({ brief, campaign }) => {
const campaignBoundaries = brief?.contentBoundaries?.length ? brief.contentBoundaries : campaign?.contentBoundaries || ['延续品牌长期定位和本轮核心命题', '不要把短期热点凌驾于品牌记忆之上', '所有内容必须保留清晰 CTA'];
const riskBoundaries = brief?.riskBoundaries?.length ? brief.riskBoundaries : campaign?.riskBoundaries || ['禁用夸张促销表达', '避免无证据竞品拉踩', '能力表达需带条件和场景'];
return (
<>
Brief 只输出内容边界和风险约束;平台变体规则、达人开场、场景脚本、多达人改写版由 AIGC 内容生成模块继续叠加。
>
);
};
const BoundaryNoteList = ({ items, tone = 'accent', columns = 1 }) => {
const colors = {
accent: ['var(--accent)', 'var(--accent-tint)'],
warn: ['var(--warn)', 'var(--warn-tint)'],
danger: ['var(--danger)', 'var(--danger-tint)'],
info: ['var(--info)', 'var(--info-tint)'],
};
const [color, bg] = colors[tone] || colors.accent;
return (
{items.map(item => (
{item}
))}
);
};
const PlatformBoundaryCard = ({ item }) => (
{item.platform}
{item.family}
source
{item.source}
);
const MiniBoundaryGroup = ({ label, items, warn }) => (
{label}
{items.slice(0, 2).map(item => - {item}
)}
);
const CreatorContentVariantBody = ({ creator }) => (
<>
{creator.avatar}
{creator.handle}
适配风格:{creator.style}
{creator.must.map((m, i) => - {m}
)}
{creator.avoid.map((a, i) => - {a}
)}
>
);
const AigcPlatformRuleBody = ({ rule }) => {
const boundary = PLATFORM_CONTENT_BOUNDARIES.find(item => rule?.platform && (item.platform === rule.platform || item.platform.includes(rule.platform) || rule.platform.includes(item.platform)));
return (
{rule.tone}
{rule.hooks.map(item => - {item}
)}
{boundary ? (
) : null}
);
};
const Section = ({ label, children }) => (
);
const KV = ({ label, value }) => (
);
const BoundaryRow = ({ type, label, items }) => {
const colors = { block: ['var(--danger)', 'var(--danger-tint)'], avoid: ['var(--warn)', 'var(--warn-tint)'], prefer: ['var(--accent)', 'var(--accent-tint)'] };
const [c, bg] = colors[type];
return (
{label}
{items.map(i => {i})}
);
};
// ========== 06 · CONTENT GENERATION + QA ==========
const PR_REVIEW_SKILL_SUMMARY = {
name: 'Chief Public Relations Officer',
principles: [
'危机中先处理情绪,再处理事实',
'品牌不要和用户争夺解释权',
'不要说“你误会了 / 本意不是 / 过度解读”',
'能改文案就不要硬解释',
],
};
const runPrContentAudit = (draft = {}) => {
const text = `${draft.title || ''}\n${draft.hook || ''}\n${draft.body || ''}`;
const rules = [
{ level: 'S3', token: '玻璃心', note: '默认用户敏感,容易升级舆情' },
{ level: 'S2', token: '你误会了', note: '品牌与用户争夺解释权' },
{ level: 'S2', token: '本意不是', note: '先解释自己,未承接用户情绪' },
{ level: 'S2', token: '过度解读', note: '指责用户解读,容易被截图传播' },
{ level: 'S2', token: '牛马', note: '品牌替用户自嘲,存在冒犯风险' },
{ level: 'S2', token: '买不起', note: '可能被理解为品牌轻视用户' },
{ level: 'S2', token: '秒杀', note: '过强促销/对比表达,需改写' },
{ level: 'S1', token: '很快', note: '模糊能力表达,建议量化' },
{ level: 'S1', token: '绝了', note: '口语化夸张,需判断是否符合品牌语气' },
];
const hits = rules.filter(rule => text.includes(rule.token));
const rank = { S0: 0, S1: 1, S2: 2, S3: 3, S4: 4 };
const level = hits.reduce((max, hit) => rank[hit.level] > rank[max] ? hit.level : max, 'S0');
const action = {
S0: '无明显风险,仅需监测',
S1: '轻微争议,建议优化表达或温和回应',
S2: '中度舆情,建议修改内容并准备公开回应',
S3: '严重舆情,建议统一口径并正式说明',
S4: '重大危机,需启动危机公关机制',
}[level];
return { level, hits, action };
};
const CONTENT_DRAFTS = [
{ title: '当城市开始静音,奥瑞利亚把"家"开到了夜里', hook: '前3行:雨后停车场一滴水声', body: '以静奢为锚。女儿在后排睡着。一公里路,像时间被装进抽屉。', platform: '小红书', score: 92, deviation: 0.04 },
{ title: '26dB · 比你家卧室还安静的车', hook: '前3行:数字对比 · 引发好奇', body: '26dB 意味着什么?我们请 4 位父亲在车里读睡前故事。', platform: '小红书', score: 88, deviation: 0.08 },
{ title: '第二台车,为什么越来越多人选静音?', hook: '前3s:静音开关声对比', body: '15 秒切片 · 3 个场景 · 0 参数。', platform: '抖音', score: 85, deviation: 0.12 },
{ title: '夜归的人,需要怎样的车位', hook: '图集 · 9 张夜间停车美学', body: '回家那一刻才是真正的驾驶。', platform: '微博', score: 79, deviation: 0.18 },
];
const CREATOR_CONTENT_VARIANTS = [
{
id: 'muxi',
handle: '@木兮的车库',
avatar: '木',
platform: '小红书',
role: '策略型',
style: '家庭视角 · 细腻克制',
must: ['必须出现 3 岁女儿场景', '必须出现夜间小区停车', '必须出现后排拉开'],
avoid: ['避免过度对比', '避免讲 0-100 加速'],
opening: '"上周末接小朋友下课,雨刚停,车位一片水光。我第一次觉得,静是一种可以被看见的奢侈。"'
},
{
id: 'wlsc',
handle: '@未来说车',
avatar: '未',
platform: '抖音',
role: '策略型',
style: '理性工程 · 拆解型',
must: ['静音实测数据', '夜充全流程录制', '对比传统燃油而非竞品'],
avoid: ['不要主观情绪', '不要使用 "秒杀"'],
opening: '"今天不聊豪华词汇,只把夜间停车、家充和后排静音拆成三段实测。"'
},
{
id: 'lucia',
handle: '@Lucia的设计日记',
avatar: 'L',
platform: '小红书',
role: '策略型',
style: '设计师视角 · 图像感',
must: ['出现工作室夜归场景', '出现材质特写', '配色参考她的 vision board'],
avoid: ['避免技术参数堆砌', '避免俯拍视角'],
opening: '"收工时城市的颜色会变低,车内材质和灯光反而决定了我愿不愿意慢下来。"'
},
];
const Content = () => {
const [selected, setSelected] = React.useState(0);
const [selectedCreator, setSelectedCreator] = React.useState(0);
const [selectedPlatformRule, setSelectedPlatformRule] = React.useState(0);
const d = CONTENT_DRAFTS[selected];
const creator = CREATOR_CONTENT_VARIANTS[selectedCreator];
const platformRule = AIGC_PLATFORM_VARIANT_RULES[selectedPlatformRule] || AIGC_PLATFORM_VARIANT_RULES[0];
const prAudit = runPrContentAudit(d);
return (
内容生成 · 品牌一致性质检
AIGC 不是自由创作。每一条内容生成时受品牌、策略、brief、平台、达人、风险词六层约束。
草稿队列
{CONTENT_DRAFTS.length}
{CONTENT_DRAFTS.map((c, i) => (
setSelected(i)}
style={{
padding:'12px 14px',
borderLeft: selected === i ? '2px solid var(--accent)' : '2px solid transparent',
borderBottom:'1px solid var(--hairline)',
background: selected === i ? 'var(--accent-tint)' : 'transparent',
cursor:'pointer',
}}
>
{c.platform}
85 ? 'var(--accent)' : c.score > 75 ? 'var(--ink-2)' : 'var(--warn)'}}>{c.score}
{c.title}
偏航 {c.deviation.toFixed(2)}
))}
达人内容创作
{CREATOR_CONTENT_VARIANTS.length}
{CREATOR_CONTENT_VARIANTS.map((item, i) => (
setSelectedCreator(i)}
style={{
padding:'12px 14px',
borderLeft: selectedCreator === i ? '2px solid var(--accent)' : '2px solid transparent',
borderBottom:'1px solid var(--hairline)',
background: selectedCreator === i ? 'var(--accent-tint)' : 'transparent',
cursor:'pointer',
}}
>
{item.handle}
{item.platform}
{item.role} · {item.style}
))}
平台变体规则
{AIGC_PLATFORM_VARIANT_RULES.length}
{AIGC_PLATFORM_VARIANT_RULES.map((item, i) => (
setSelectedPlatformRule(i)}
style={{
padding:'12px 14px',
borderLeft: selectedPlatformRule === i ? '2px solid var(--accent)' : '2px solid transparent',
borderBottom:'1px solid var(--hairline)',
background: selectedPlatformRule === i ? 'var(--accent-tint)' : 'transparent',
cursor:'pointer',
}}
>
{item.platform}
{item.len}
{item.label} · {item.tone}
))}
{creator.handle} · 达人改写预览
GPT-Aurelia v2
{CONTENT_DRAFTS.slice(0, 4).map((c, i) => (
0{i+1}
{c.title}
预估 CTR {(3 + i * 0.4).toFixed(1)}%
))}
PR 舆情检测
{prAudit.level}
{PR_REVIEW_SKILL_SUMMARY.name}
{prAudit.action}
{(prAudit.hits.length ? prAudit.hits : [{ token: '无红旗表达', note: '继续监测评论区反馈' }]).map(hit => (
{hit.token} · {hit.note}
))}
修订建议
2
表述模糊
"充电很快" → 建议量化为"夜间家充 7h 满"
);
};
const PlatformMock = ({ platform, title, body, hook, creator }) => (
{creator?.avatar || 'M'}
{creator?.handle || '@木兮的车库'}
{platform} · 达人内容草稿
DRAFT
{/* placeholder image */}
[ 封面图 · 夜景停车场 · 待生成 ]
{title}
{hook}
{body}
{['#静奢风', '#AX7', '#夜归人', '#第二台车'].map(t => {t})}
);
const QaScoreCard = ({ score }) => (
);
const QaDim = ({ label, v, ok }) => (
);
// ========== 07 · MONITOR ==========
const getSocialAccount = (accountId) => (SOCIAL_ACCOUNTS || []).find(account => account.account_id === accountId) || {};
const getPostComments = (postId) => (SOCIAL_COMMENTS || []).filter(comment => comment.post_id === postId);
const commentPrRank = { S0: 0, S1: 1, S2: 2, S3: 3, S4: 4 };
const commentLegalRank = { L0: 0, L1: 1, L2: 2, L3: 3, L4: 4 };
const trustBarrierPattern = /不敢买|不敢入|不敢下单|信不过|没信心|跑路|割韭菜|ppt车|概念版权/i;
const smallFactoryPattern = /小厂|小品牌|小公司|新势力/i;
const brandBackgroundClarifiedPattern = /不是小厂|不算小厂|不是什么小厂|背后.{0,12}(广汽|领程|大厂|主机厂|车企)|有.{0,12}(广汽|领程|大厂|主机厂|车企).{0,8}背景|原来.{0,12}(广汽|领程|大厂|主机厂|车企)/i;
const isNegativeBrandTrustText = (text = '') => trustBarrierPattern.test(text) || (smallFactoryPattern.test(text) && !brandBackgroundClarifiedPattern.test(text));
const getCommentRiskRank = (comment = {}) => {
const sentiment = Number(comment.sentiment || 0);
const prRank = commentPrRank[String(comment.pr_level || comment.public_opinion_level || '').toUpperCase()] || 0;
const legalRank = commentLegalRank[String(comment.legal_level || '').toUpperCase()] || 0;
const text = `${comment.text || ''} ${comment.keyword || ''} ${comment.risk_tag || ''}`;
if (prRank >= 3 || legalRank >= 3 || sentiment < -0.55) return 3;
if (comment.risk_tag || prRank >= 2 || legalRank >= 2 || sentiment < -0.15 || isNegativeBrandTrustText(text)) return 2;
return 1;
};
const isRiskComment = (comment = {}) => getCommentRiskRank(comment) >= 2;
const positiveCommentPattern = /喜欢|想要|期待|终于|太帅|帅|好看|酷|真香|种草|不错|可以|给力|厉害|赞|支持|nice|love|cool|awesome|amazing|great|😍|❤️|👍|🔥/i;
const isPositiveComment = (comment = {}) => {
if (isRiskComment(comment)) return false;
if (socialNumber(comment.sentiment) > 0.15) return true;
const prRank = commentPrRank[String(comment.pr_level || comment.public_opinion_level || '').toUpperCase()] || 0;
const legalRank = commentLegalRank[String(comment.legal_level || '').toUpperCase()] || 0;
const riskLevel = String(comment.pr_risk_level || '').toLowerCase();
const text = `${comment.text || ''} ${comment.keyword || ''}`;
if (isNegativeBrandTrustText(text)) return false;
return prRank === 0 && legalRank <= 1 && riskLevel !== 'medium' && positiveCommentPattern.test(text);
};
const getCommentSentimentMeta = (comment = {}) => {
const score = socialNumber(comment.sentiment);
if (isRiskComment(comment)) return { tone: 'risk', label: '负面', score };
if (isPositiveComment(comment)) return { tone: 'good', label: '正向', score };
return { tone: 'neutral', label: '中性', score };
};
const commentConcernMeta = [
{ id: 'price_purchase', label: '价格/购买门槛', short: '价格购买', keywords: ['价格', '多少钱', '售价', '报价', '贵', '便宜', '买不起', '买得起', 'price', 'cost', 'expensive', 'cheap'] },
{ id: 'availability_delivery', label: '上市/量产/交付', short: '上市交付', keywords: ['上市', '量产', '开卖', '发售', '交付', '预定', '预订', '什么时候', 'available', 'release', 'launch', 'deliver', 'when can', 'want one'] },
{ id: 'product_specs', label: '配置/性能/能力', short: '配置能力', keywords: ['续航', '电池', '空间', '座椅', '内饰', '配置', '动力', '越野', '尺寸', '参数', '功能', '智能', 'range', 'battery', 'interior', 'feature', 'spec'] },
{ id: 'design_aesthetic', label: '外观/设计/科技感', short: '外观设计', keywords: ['外观', '设计', '造型', '方盒', '好看', '帅', '酷', '丑', '垃圾桶', '棺材', 'cybertruck', 'design', 'cool', 'beautiful', 'ugly', 'tescoba'] },
{ id: 'usage_scenario', label: '使用场景/生活方式', short: '使用场景', keywords: ['露营', '房车', '自驾', '旅行', '办公', '数字游民', '钓鱼', '越野穿越', 'camping', 'travel', 'fishing', 'home', 'office'] },
{ id: 'safety_reliability', label: '安全/可靠性', short: '安全可靠', keywords: ['安全', '事故', '失灵', '质量', '耐用', '可靠', '坏', '故障', '保命', '战争', 'toyota', 'reliable', 'safety', 'war'] },
{ id: 'brand_naming', label: '品牌/命名/表达', short: '品牌命名', keywords: ['牛马', '名字', '命名', '冒犯', '玻璃心', '本意不是', '品牌', 'name', 'brand'] },
{ id: 'proof_trust', label: '真实性/证据信任', short: '证据信任', keywords: ['ppt', '概念', '概念版权', '小厂', '不敢买', '不敢入', '信不过', '没信心', '实验室', '真车', '实车', '量产版', '真的假的', '证据', 'concept', 'real', 'prototype', 'fake'] },
{ id: 'competitor_compare', label: '竞品/国家对比', short: '竞品对比', keywords: ['特斯拉', 'tesla', 'cybertruck', 'toyota', 'lada', '中国', 'поднебесная', 'китай', 'китайцы'] },
{ id: 'complaint_after_sales', label: '投诉/售后', short: '投诉售后', keywords: ['投诉', '退款', '维权', '售后', '赔偿', '客服', 'complaint', 'refund', 'service'] },
{ id: 'positive_interest', label: '正向喜爱/种草', short: '正向喜爱', keywords: ['喜欢', '想要', '期待', '太帅', '好看', '酷', '真香', '种草', '不错', '厉害', '赞', 'nice', 'love', 'awesome', 'amazing', 'super', 'бомба', 'респект', 'супер'] },
{ id: 'other', label: '其他/待观察', short: '其他', keywords: [] },
];
const commentConcernById = Object.fromEntries(commentConcernMeta.map(item => [item.id, item]));
const getCommentConcern = (comment = {}) => {
if (comment.user_concern_category) {
const meta = commentConcernById[comment.user_concern_category] || {};
return { id: comment.user_concern_category, label: comment.user_concern_label || meta.label || '其他/待观察', short: meta.short || comment.user_concern_label || '其他' };
}
const text = `${comment.text || ''} ${comment.keyword || ''} ${comment.risk_tag || ''}`.toLowerCase();
const hit = commentConcernMeta.find(item => item.id !== 'other' && item.keywords.some(keyword => text.includes(String(keyword).toLowerCase())));
if (hit) return hit;
if (socialNumber(comment.sentiment) > 0.15) return commentConcernById.positive_interest;
return commentConcernById.other;
};
const socialNumber = (value) => Number(value || 0);
const socialInteraction = (post = {}) => socialNumber(post.likes) + socialNumber(post.comments) + socialNumber(post.shares) + socialNumber(post.saves);
const isExternalSocialPost = (post = {}) => {
const type = `${post.source_type || ''} ${post.asset_type || ''} ${post.source_post_type || ''} ${post.relationship_status || ''}`.toLowerCase();
return Boolean(
post.is_official_post === false ||
type.includes('earned') ||
type.includes('ugc') ||
type.includes('kol') ||
type.includes('external')
);
};
const needsChineseTranslation = (text = '') => {
const clean = String(text || '').trim();
return Boolean(clean && !/[\u3400-\u9FFF]/.test(clean) && /[A-Za-z\u00C0-\u024F\u0400-\u04FF]/.test(clean));
};
const socialTranslationKey = (type, id) => `${type}:${id}`;
const ContentTitleLink = ({ title, url, className = '', linkClassName = 'content-title-link' }) => {
const content = {title || '未命名内容'};
return url ? {content} : content;
};
const getSocialPostUrl = (post = {}, account = {}) => {
const raw = post.raw || {};
const candidates = [
post.url,
post.link,
post.permalink,
post.share_url,
post.manual_metrics?.url,
raw.url,
raw.link,
raw.permalink,
raw.share_url,
raw.share_link,
raw.note_url,
raw.web_url,
raw.share_info?.link,
];
const direct = candidates.map(value => String(value || '').trim()).find(value => /^https?:\/\//i.test(value));
if (direct) return direct;
if (post.platform === '小红书') {
const noteId = String(raw.id || raw.note_id || raw.noteId || '').trim();
if (noteId) return `https://www.xiaohongshu.com/explore/${noteId}`;
}
return account.homepage_url || '';
};
const assetScopeMeta = {
owned: { label: '自有账号', desc: '官方账号发布内容' },
external: { label: '外部声量', desc: 'KOL / UGC / 第三方自然内容' },
};
const getMonitorDataset = (brandId = 'montx') => {
const posts = (SOCIAL_POSTS || []).filter(post => post.brand_id === brandId);
const fallbackPosts = posts.length ? posts : (SOCIAL_POSTS || []);
const accountIds = new Set(fallbackPosts.map(post => post.account_id));
const accounts = (SOCIAL_ACCOUNTS || []).filter(account => account.brand_id === brandId || accountIds.has(account.account_id));
const postIds = new Set(fallbackPosts.map(post => post.post_id));
const comments = (SOCIAL_COMMENTS || []).filter(comment => postIds.has(comment.post_id));
return { accounts, posts: fallbackPosts, comments };
};
const mergeSocialRows = (baseRows = [], extraRows = [], idKey) => {
const map = new Map();
[...baseRows, ...extraRows].forEach(row => {
if (!row?.[idKey]) return;
map.set(row[idKey], { ...(map.get(row[idKey]) || {}), ...row });
});
return Array.from(map.values());
};
const monitorRiskRank = { high: 3, medium: 2, low: 1 };
const monitorRiskLabel = { high: 'HIGH', medium: 'MED', low: 'LOW' };
const monitorRiskLevelByRank = { 3: 'high', 2: 'medium', 1: 'low' };
const contentSourceMeta = {
api: 'API',
manual: '人工台账',
external: '外部UGC',
mixed: 'API+人工',
unknown: '未标记',
};
const getContentSourceGroup = (post = {}) => {
const sourceText = `${post.data_channel || ''} ${post.provider || ''} ${post.source_type || ''} ${post.asset_type || ''} ${Array.isArray(post.data_sources) ? post.data_sources.join(' ') : ''}`.toLowerCase();
if (isExternalSocialPost(post)) return 'external';
if (/api_plus_manual|manual.*api|api.*manual/.test(sourceText)) return 'mixed';
if (/manual|sheet|workbook|台账/.test(sourceText)) return 'manual';
if (/api|tikhub|justone|sync/.test(sourceText)) return 'api';
return 'unknown';
};
const getMonitorPostBriefMeta = (post = {}) => {
const brief = post.brief || {};
const id = brief.primaryBriefId || post.primaryMasterBriefId || post.briefMapping?.primaryMasterBriefId || '';
const name = brief.primaryBriefName || post.briefMapping?.masterBriefGroup || post.briefMapping?.unifiedTopic || '';
const parent = brief.parentCampaignId || post.parentCampaignId || post.briefMapping?.parentCampaignId || '';
return {
id,
name,
parent,
assigned: Boolean(brief.assigned || id || name || parent),
};
};
const getMonitorPostBriefFilterValue = (post = {}) => {
const meta = getMonitorPostBriefMeta(post);
return meta.id || meta.name || meta.parent || 'unassigned';
};
const getMonitorPostBriefFilterLabel = (post = {}) => {
const meta = getMonitorPostBriefMeta(post);
return meta.name || meta.id || meta.parent || '未挂 Brief';
};
const getPostTimeValue = (post = {}) => {
const raw = String(post.published_at || post.publish_time || post.created_at || '').trim();
if (!raw) return 0;
const normalized = raw.replace(/年|月/g, '/').replace(/日/g, '').replace(/\./g, '/');
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime();
};
const getPostCompositeRisk = (post = {}, postComments = []) => {
const baseRank = monitorRiskRank[post.risk_level] || 1;
const riskRows = postComments.filter(isRiskComment);
const maxCommentRank = postComments.reduce((max, comment) => Math.max(max, getCommentRiskRank(comment)), 1);
const rank = Math.max(baseRank, maxCommentRank);
const highComments = postComments.filter(comment => getCommentRiskRank(comment) >= 3).length;
return {
level: monitorRiskLevelByRank[rank] || 'low',
rank,
totalComments: postComments.length,
riskComments: riskRows.length,
highComments,
};
};
const monitorActionLabel = {
observed: '已观察',
'needs-response': '待回应',
'needs-answer': '待解答',
'needs-review': '待复核',
reviewing: '复核中',
resolved: '已关闭',
};
const socialApiUrl = (pathname) => (window.location.protocol === 'file:' ? `http://localhost:4173${pathname}` : pathname);
const Monitor = ({ onNav, workspaceContext }) => {
const [ticks, setTicks] = React.useState(0);
const brandId = workspaceContext?.brandId || BRAND?.id || 'montx';
const activeBrand = (WORKSPACE_BRANDS || []).find(brand => brand.id === brandId) || BRAND;
const baseDataset = getMonitorDataset(brandId);
const [syncedAssets, setSyncedAssets] = React.useState({ accounts: [], posts: [], comments: [], postDailyMetricSnapshots: [], postDailyMetricBackfill: null, lastSync: '', nextRun: '', status: 'idle', errors: [], runs: [], syncLog: null });
const [syncingPlatform, setSyncingPlatform] = React.useState('');
const accounts = mergeSocialRows(baseDataset.accounts, syncedAssets.accounts, 'account_id');
const posts = mergeSocialRows(baseDataset.posts, syncedAssets.posts, 'post_id');
const comments = mergeSocialRows(baseDataset.comments, syncedAssets.comments || [], 'comment_id');
const postDailyMetricSnapshots = syncedAssets.postDailyMetricSnapshots || [];
const [assetScope, setAssetScope] = React.useState('owned');
const [workTab, setWorkTab] = React.useState('content');
const [filters, setFilters] = React.useState({ platform: 'all', product: 'all', risk: 'all', status: 'all' });
const [contentFilters, setContentFilters] = React.useState({ query: '', campaign: 'all', brief: 'all', contentType: 'all', dateRange: 'all', commentScope: 'all', source: 'all', sort: 'risk' });
const [syncLogOpen, setSyncLogOpen] = React.useState(false);
const [commentConcernFilter, setCommentConcernFilter] = React.useState('all');
const [activeTrendPostId, setActiveTrendPostId] = React.useState('');
const [activeCommentId, setActiveCommentId] = React.useState('');
const [commentActions, setCommentActions] = React.useState({});
const [replyDraft, setReplyDraft] = React.useState('');
const [replyGenerating, setReplyGenerating] = React.useState(false);
const [translations, setTranslations] = React.useState({});
const [translationStatus, setTranslationStatus] = React.useState('idle');
const [manualUploadOpen, setManualUploadOpen] = React.useState(false);
const [manualUploadPreview, setManualUploadPreview] = React.useState(null);
const [manualUploadStatus, setManualUploadStatus] = React.useState('idle');
const [manualUploadError, setManualUploadError] = React.useState('');
const [manualUploadResolutions, setManualUploadResolutions] = React.useState({});
React.useEffect(() => {
const int = setInterval(() => setTicks(t => t + 1), 2000);
return () => clearInterval(int);
}, []);
const applySyncPayload = React.useCallback((data = {}) => {
setSyncedAssets({
accounts: data.accounts || [],
posts: data.posts || [],
comments: data.comments || [],
postDailyMetricSnapshots: data.postDailyMetricSnapshots || [],
postDailyMetricBackfill: data.postDailyMetricBackfill || null,
lastSync: data.lastFinishedAt || '',
nextRun: data.nextRunAt || '',
status: data.status || 'idle',
errors: data.errors || [],
runs: data.runs || [],
syncLog: data.syncLog || null,
});
}, []);
const loadSyncStatus = React.useCallback(async () => {
try {
const response = await fetch(socialApiUrl('/api/social/sync/status'));
const data = await response.json();
if (response.ok) applySyncPayload(data);
} catch (error) {
setSyncedAssets(prev => ({ ...prev, errors: [{ platform: 'local', message: error.message || '读取同步状态失败' }] }));
}
}, [applySyncPayload]);
React.useEffect(() => {
loadSyncStatus();
const int = setInterval(loadSyncStatus, 30000);
return () => clearInterval(int);
}, [loadSyncStatus]);
const ownedPosts = posts.filter(post => !isExternalSocialPost(post));
const externalPosts = posts.filter(isExternalSocialPost);
const scopedPosts = assetScope === 'external' ? externalPosts : ownedPosts;
const commentsByPost = React.useMemo(() => {
const map = new Map();
comments.forEach(comment => {
const list = map.get(comment.post_id) || [];
list.push(comment);
map.set(comment.post_id, list);
});
return map;
}, [comments]);
const getRowRisk = React.useCallback((post) => getPostCompositeRisk(post, commentsByPost.get(post.post_id) || []), [commentsByPost]);
const getDisplayedPostComments = React.useCallback((post) => Math.max(pickNativeMetric(post, 'comments'), (commentsByPost.get(post.post_id) || []).length), [commentsByPost]);
const getDisplayedPostInteraction = React.useCallback((post) => pickNativeMetric(post, 'likes') + getDisplayedPostComments(post) + pickNativeMetric(post, 'shares') + pickNativeMetric(post, 'reposts') + pickNativeMetric(post, 'saves'), [getDisplayedPostComments]);
const scopedPostIds = new Set(scopedPosts.map(post => post.post_id));
const scopedComments = comments.filter(comment => scopedPostIds.has(comment.post_id));
const scopedAccountIds = new Set(scopedPosts.map(post => post.account_id).filter(Boolean));
const scopedAccounts = accounts.filter(account => scopedAccountIds.has(account.account_id));
const followerDelta = scopedAccounts.reduce((sum, account) => sum + socialNumber(account.follower_delta_period), 0);
const followerGrowthStarts = scopedAccounts.map(account => account.follower_delta_start_date).filter(Boolean).sort();
const followerGrowthEnds = scopedAccounts.map(account => account.follower_delta_end_date).filter(Boolean).sort();
const followerGrowthStart = followerGrowthStarts[0] || '';
const followerGrowthEnd = followerGrowthEnds[followerGrowthEnds.length - 1] || '';
const ownedViews = ownedPosts.reduce((sum, post) => sum + getPostContentViews(post), 0);
const externalViews = externalPosts.reduce((sum, post) => sum + getPostContentViews(post), 0);
const scopedPendingMetrics = scopedPosts.filter(isPostMetricPending).length;
const externalPendingMetrics = externalPosts.filter(isPostMetricPending).length;
const campaignOptions = Array.from(new Set(scopedPosts.map(post => post.campaign).filter(Boolean))).sort((a, b) => String(a).localeCompare(String(b), 'zh-CN'));
const briefOptionRows = Array.from(scopedPosts.reduce((map, post) => {
const id = getMonitorPostBriefFilterValue(post);
if (!map.has(id)) map.set(id, getMonitorPostBriefFilterLabel(post));
return map;
}, new Map()).entries())
.map(([id, label]) => ({ id, label }))
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'zh-CN'));
const briefOptions = briefOptionRows.map(item => item.id);
const briefLabelById = briefOptionRows.reduce((map, item) => ({ ...map, [item.id]: item.label }), {});
const contentTypeOptions = Array.from(new Set(scopedPosts.map(post => post.content_type || post.type).filter(Boolean))).sort((a, b) => String(a).localeCompare(String(b), 'zh-CN'));
const sourceOptions = Array.from(new Set(scopedPosts.map(getContentSourceGroup))).filter(Boolean);
const contentQuery = contentFilters.query.trim().toLowerCase();
const now = Date.now();
const filteredPosts = scopedPosts.filter(post => {
const rowRisk = getRowRisk(post);
const rowComments = commentsByPost.get(post.post_id) || [];
if (filters.platform !== 'all' && post.platform !== filters.platform) return false;
if (filters.product !== 'all' && post.product !== filters.product) return false;
if (filters.risk !== 'all' && rowRisk.level !== filters.risk) return false;
if (filters.status !== 'all' && post.status !== filters.status) return false;
if (contentFilters.campaign !== 'all' && post.campaign !== contentFilters.campaign) return false;
if (contentFilters.brief !== 'all' && getMonitorPostBriefFilterValue(post) !== contentFilters.brief) return false;
if (contentFilters.contentType !== 'all' && (post.content_type || post.type) !== contentFilters.contentType) return false;
if (contentFilters.source !== 'all' && getContentSourceGroup(post) !== contentFilters.source) return false;
if (contentFilters.commentScope === 'has-comments' && rowComments.length === 0 && socialNumber(post.comments) === 0) return false;
if (contentFilters.commentScope === 'no-comments' && (rowComments.length > 0 || socialNumber(post.comments) > 0)) return false;
if (contentFilters.commentScope === 'risk-comments' && rowRisk.riskComments === 0) return false;
if (contentFilters.dateRange !== 'all') {
const days = Number(contentFilters.dateRange.replace('d', '')) || 0;
const time = getPostTimeValue(post);
if (!time || now - time > days * 24 * 60 * 60 * 1000) return false;
}
if (contentQuery) {
const account = accounts.find(item => item.account_id === post.account_id) || {};
const briefMeta = getMonitorPostBriefMeta(post);
const haystack = [
post.title,
post.caption,
post.product,
post.campaign,
briefMeta.id,
briefMeta.name,
briefMeta.parent,
post.platform,
post.content_type,
post.creator_handle,
account.handle,
account.account_name,
].join(' ').toLowerCase();
if (!haystack.includes(contentQuery)) return false;
}
return true;
}).sort((a, b) => {
if (contentFilters.sort === 'newest') return getPostTimeValue(b) - getPostTimeValue(a);
if (contentFilters.sort === 'views') return socialNumber(b.views || b.plays || b.reads) - socialNumber(a.views || a.plays || a.reads);
if (contentFilters.sort === 'comments') return getDisplayedPostComments(b) - getDisplayedPostComments(a);
if (contentFilters.sort === 'engagement') return getDisplayedPostInteraction(b) - getDisplayedPostInteraction(a);
return getRowRisk(b).rank - getRowRisk(a).rank || getPostTimeValue(b) - getPostTimeValue(a);
});
const visiblePosts = filteredPosts;
const trendSnapshotsByPost = React.useMemo(() => {
const map = new Map();
postDailyMetricSnapshots.forEach(snapshot => {
if (!snapshot?.post_id) return;
const rows = map.get(snapshot.post_id) || [];
rows.push(snapshot);
map.set(snapshot.post_id, rows);
});
map.forEach(rows => rows.sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))));
return map;
}, [postDailyMetricSnapshots]);
const activeTrendPost = activeTrendPostId ? posts.find(post => post.post_id === activeTrendPostId) : null;
const activeTrendAccount = activeTrendPost ? accounts.find(account => account.account_id === activeTrendPost.account_id) || getSocialAccount(activeTrendPost.account_id) : {};
const activeTrendSnapshots = activeTrendPostId ? trendSnapshotsByPost.get(activeTrendPostId) || [] : [];
const commentConcernCounts = scopedComments.reduce((map, comment) => {
const concern = getCommentConcern(comment);
map[concern.id] = (map[concern.id] || 0) + 1;
return map;
}, {});
const commentConcernOptions = commentConcernMeta.filter(item => item.id !== 'other' && commentConcernCounts[item.id]).concat(commentConcernCounts.other ? [commentConcernById.other] : []);
const commentStream = scopedComments.filter(comment => commentConcernFilter === 'all' || getCommentConcern(comment).id === commentConcernFilter);
const riskComments = scopedComments.filter(isRiskComment);
const openRiskComments = riskComments.filter(comment => (commentActions[comment.comment_id] || comment.action_status) !== 'resolved');
const totalViews = scopedPosts.reduce((sum, post) => sum + getPostContentViews(post), 0);
const totalInteraction = scopedPosts.reduce((sum, post) => sum + getDisplayedPostInteraction(post), 0);
const totalComments = scopedPosts.reduce((sum, post) => sum + getDisplayedPostComments(post), 0);
const avgSentiment = scopedComments.length ? scopedComments.reduce((sum, comment) => sum + socialNumber(comment.sentiment), 0) / scopedComments.length : 0;
const topRisk = [...riskComments].sort((a, b) => socialNumber(a.sentiment) - socialNumber(b.sentiment))[0];
const activeComment = commentStream.find(comment => comment.comment_id === activeCommentId) || topRisk || commentStream[0] || scopedComments[0] || null;
const activeCommentPost = activeComment ? posts.find(post => post.post_id === activeComment.post_id) : null;
const activeCommentAccount = activeCommentPost ? accounts.find(account => account.account_id === activeCommentPost.account_id) || getSocialAccount(activeCommentPost.account_id) : {};
const activeCommentConcern = activeComment ? getCommentConcern(activeComment) : null;
const activeCommentSentiment = activeComment ? getCommentSentimentMeta(activeComment) : null;
const activeCommentSourceUrl = activeCommentPost ? getSocialPostUrl(activeCommentPost, activeCommentAccount) : '';
const platformOptions = Array.from(new Set((scopedPosts.length ? scopedPosts : posts).map(post => post.platform).filter(Boolean)));
const productOptions = Array.from(new Set(scopedPosts.map(post => post.product).filter(Boolean)));
const statusOptions = Array.from(new Set(scopedPosts.map(post => post.status).filter(Boolean)));
const externalTranslationCandidates = React.useMemo(() => {
if (assetScope !== 'external') return [];
const items = [];
visiblePosts.forEach(post => {
const text = post.title || post.caption || '';
const key = socialTranslationKey('post', post.post_id);
if (needsChineseTranslation(text) && !translations[key]) items.push({ id: key, type: 'post_title', text });
});
scopedComments.forEach(comment => {
const key = socialTranslationKey('comment', comment.comment_id);
if (needsChineseTranslation(comment.text) && !translations[key]) items.push({ id: key, type: 'comment', text: comment.text });
});
return items.slice(0, 24);
}, [assetScope, visiblePosts, scopedComments, translations]);
const externalTranslationSignature = externalTranslationCandidates.map(item => item.id).join('|');
const externalTranslatedCount = Object.values(translations).filter(item => item?.translation).length;
const opportunities = scopedComments
.filter(comment => !isRiskComment(comment) && comment.keyword)
.slice(0, 3)
.map(comment => ({ t: comment.keyword, p: comment.platform, pct: comment.action_status === 'observed' ? '可沉淀' : '待响应' }));
const updateFilter = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
const updateContentFilter = (key, value) => setContentFilters(prev => ({ ...prev, [key]: value }));
const resetFilters = () => setFilters({ platform: 'all', product: 'all', risk: 'all', status: 'all' });
const resetContentFilters = () => setContentFilters({ query: '', campaign: 'all', brief: 'all', contentType: 'all', dateRange: 'all', commentScope: 'all', source: 'all', sort: 'risk' });
const changeAssetScope = (scope) => {
setAssetScope(scope);
resetFilters();
resetContentFilters();
setCommentConcernFilter('all');
setActiveCommentId('');
setActiveTrendPostId('');
setWorkTab('content');
};
const setCommentStatus = (commentId, status) => setCommentActions(prev => ({ ...prev, [commentId]: status }));
React.useEffect(() => {
if (!externalTranslationSignature || translationStatus === 'loading') return;
let cancelled = false;
const items = externalTranslationCandidates;
setTranslationStatus('loading');
fetch(socialApiUrl('/api/social/translate'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ items }),
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ data }) => {
if (cancelled) return;
setTranslationStatus(data.ok === false ? 'error' : 'ready');
setTranslations(prev => {
const next = { ...prev };
(data.translations || []).forEach(item => {
next[item.id] = {
translation: item.translation || '',
language: item.language || '',
error: item.error || '',
source: data.source || 'unknown',
};
});
return next;
});
})
.catch(error => {
if (cancelled) return;
setTranslationStatus('error');
setTranslations(prev => {
const next = { ...prev };
items.forEach(item => {
next[item.id] = { translation: '', language: '', error: error.message || '翻译失败', source: 'client-error' };
});
return next;
});
});
return () => {
cancelled = true;
};
}, [externalTranslationSignature]);
const syncStatusText = syncedAssets.status === 'running' ? '同步中' : syncedAssets.status === 'error' || syncedAssets.status === 'partial' ? '部分失败' : 'Live';
const lastSyncText = formatBeijingTime(syncedAssets.lastSync, '等待首次同步');
const nextRunText = formatBeijingTime(syncedAssets.nextRun, '每日自动同步');
const formatShortSyncTime = (value, fallback) => formatBeijingTime(value, fallback, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
const lastSyncShortText = formatShortSyncTime(syncedAssets.lastSync, '待同步');
const nextRunShortText = formatShortSyncTime(syncedAssets.nextRun, '每日');
const syncErrorText = (syncedAssets.errors || [])
.map((item, index) => `${index + 1}. ${item.platform || item.source || '同步'}:${item.message || item.error || JSON.stringify(item)}`)
.join('\n') || '暂无异常详情';
const syncAllAccounts = async () => {
setSyncingPlatform('all');
setSyncedAssets(prev => ({ ...prev, status: 'running', errors: [] }));
try {
const response = await fetch(socialApiUrl('/api/social/sync?trigger=manual'), { method: 'POST' });
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.message || '同步失败');
applySyncPayload(data);
} catch (error) {
setSyncedAssets(prev => ({ ...prev, status: 'error', errors: [{ platform: 'local', message: error.message || '同步失败' }] }));
} finally {
setSyncingPlatform('');
}
};
const generateReply = async (comment = activeComment) => {
if (!comment) return;
setActiveCommentId(comment.comment_id);
const post = posts.find(item => item.post_id === comment.post_id) || {};
const account = accounts.find(item => item.account_id === post.account_id) || {};
setReplyGenerating(true);
setReplyDraft('DeepSeek 正在生成回应建议...');
try {
const response = await fetch(socialApiUrl('/api/social/comment/reply-suggestion'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ comment, post, account }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.message || '生成失败');
setReplyDraft([
data.reply || `感谢你的反馈。关于「${comment.keyword || comment.risk_tag || '这个问题'}」,我们会补充说明。`,
'',
`舆情等级:${data.publicOpinionLevel || data.prLevel || 'S1'}`,
`法务等级:${data.legalLevel || 'L1'}`,
`语气:${data.tone || '克制、专业'}`,
`风险:${data.riskLevel || 'medium'}`,
`动作:${data.nextAction || '人工复核后使用'}`,
data.legalNextAction ? `法务动作:${data.legalNextAction}` : '',
data.rationale ? `判断:${data.rationale}` : '',
data.riskPoints?.length ? `风险点:${data.riskPoints.join(';')}` : '',
data.legalRiskTypes?.length ? `法务类型:${data.legalRiskTypes.join(';')}` : '',
data.legalRiskPoints?.length ? `法务风险:${data.legalRiskPoints.join(';')}` : '',
data.replacementSuggestion ? `文案替换:${data.replacementSuggestion}` : '',
data.reviewSuggestion ? `复盘建议:${data.reviewSuggestion}` : '',
data.legalEscalation ? `升级建议:${data.legalEscalation}` : '',
`来源:${data.source || 'local'}${data.model ? ` · ${data.model}` : ''}${data.modelError ? ` · ${data.modelError}` : ''}`,
].filter(Boolean).join('\n'));
} catch (error) {
setReplyDraft(`建议回应:感谢你指出「${comment.keyword || comment.risk_tag || '这个问题'}」。这条内容会补充说明适用场景、产品边界和后续节奏,避免造成误解。\n\n来源:local-fallback · ${error.message || '生成失败'}`);
} finally {
setReplyGenerating(false);
}
};
const readManualUploadFile = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const bytes = new Uint8Array(reader.result || []);
let chunk = '';
let encoded = '';
for (let i = 0; i < bytes.length; i += 1) {
chunk += String.fromCharCode(bytes[i]);
if (chunk.length >= 0x8000) {
encoded += btoa(chunk);
chunk = '';
}
}
if (chunk) encoded += btoa(chunk);
resolve({ name: file.name, size: file.size, contentBase64: encoded });
};
reader.onerror = () => reject(reader.error || new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
const previewManualUpload = async (event) => {
const selectedFiles = Array.from(event.target.files || []);
event.target.value = '';
if (!selectedFiles.length) return;
setManualUploadStatus('previewing');
setManualUploadError('');
setManualUploadPreview(null);
setManualUploadResolutions({});
try {
const files = await Promise.all(selectedFiles.map(readManualUploadFile));
const response = await fetch(socialApiUrl('/api/social/manual-upload/preview'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ files }),
});
const data = await response.json();
if (!response.ok || data.ok === false) throw new Error(data.error || data.message || '上传预检失败');
const conflicts = data.preview?.conflicts || [];
setManualUploadPreview(data);
setManualUploadResolutions(Object.fromEntries(conflicts.map(conflict => [
conflict.id,
conflict.recommended_action === 'use_upload' ? 'use_upload' : 'keep_system',
])));
setManualUploadStatus('ready');
} catch (error) {
setManualUploadStatus('error');
setManualUploadError(error.message || '上传预检失败');
}
};
const applyManualUpload = async () => {
if (!manualUploadPreview?.sessionId) return;
setManualUploadStatus('applying');
setManualUploadError('');
try {
const response = await fetch(socialApiUrl('/api/social/manual-upload/apply'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: manualUploadPreview.sessionId, resolutions: manualUploadResolutions }),
});
const data = await response.json();
if (!response.ok || data.ok === false) throw new Error(data.error || data.message || '写入失败');
applySyncPayload(data);
setManualUploadStatus('applied');
setTimeout(() => setManualUploadOpen(false), 700);
} catch (error) {
setManualUploadStatus('error');
setManualUploadError(error.message || '写入失败');
}
};
return (
社媒资产 · Live Monitor
{activeBrand.name} 的账号、文章、评论和回应建议集中监控。系统每日自动同步,支持手动即时刷新。
北京时间 {lastSyncShortText} → {nextRunShortText}
= 0 ? '+' : ''}${formatCompactNumber(followerDelta)}`}
meta={assetScope === 'external' ? `评论 ${formatCompactNumber(totalComments)} · 账号 ${scopedAccounts.length}` : (followerGrowthStart ? `${followerGrowthStart} - ${followerGrowthEnd}` : `当前视图 ${scopedPosts.length} · 全部 ${posts.length}`)}
tone={assetScope === 'external' || followerDelta > 0 ? 'good' : 'neutral'}
/>
= 0 ? '+' : ''}${avgSentiment.toFixed(2)}`} tone={openRiskComments.length ? 'risk' : 'good'}/>
updateFilter('platform', value)}/>
updateFilter('product', value)}/>
updateFilter('risk', value)} render={value => value === 'all' ? '全部' : monitorRiskLabel[value]}/>
updateFilter('status', value)} render={value => value === 'all' ? '全部' : value}/>
{assetScope === 'external' && translationStatus === 'loading' ? `已译 ${externalTranslatedCount} · DeepSeek 翻译中` : assetScope === 'external' ? `已译 ${externalTranslatedCount}` : `匹配 ${filteredPosts.length}/${scopedPosts.length} 篇`}
{workTab === 'content' && (
内容资产库
{assetScopeMeta[assetScope].label} · {visiblePosts.length} 篇命中 · 内容池筛选
{visiblePosts.length}
updateContentFilter('campaign', value)}/>
updateContentFilter('brief', value)} render={value => briefLabelById[value] || value}/>
updateContentFilter('contentType', value)}/>
updateContentFilter('dateRange', value)} render={value => ({ '7d': '近 7 天', '14d': '近 14 天', '30d': '近 30 天' }[value] || value)}/>
updateContentFilter('commentScope', value)} render={value => ({ 'has-comments': '有评论', 'risk-comments': '有风险评论', 'no-comments': '无评论' }[value] || value)}/>
updateContentFilter('source', value)} render={value => contentSourceMeta[value] || value}/>
updateContentFilter('sort', value === 'all' ? 'risk' : value)} render={value => ({ risk: '风险优先', newest: '最新发布', views: '观看/阅读', comments: '评论数', engagement: '互动数' }[value] || value)}/>
| 文章 |
渠道 |
观看/阅读 |
点赞 |
评论 |
收藏 |
转发 |
趋势 |
风险 |
{visiblePosts.map(p => {
const account = accounts.find(item => item.account_id === p.account_id) || getSocialAccount(p.account_id);
const rowRisk = getRowRisk(p);
const titleTranslation = translations[socialTranslationKey('post', p.post_id)];
const postUrl = getSocialPostUrl(p, account);
const postViews = getPostContentViews(p);
const metricPending = isPostMetricPending(p);
const displayedComments = getDisplayedPostComments(p);
const trendRows = trendSnapshotsByPost.get(p.post_id) || [];
const briefMeta = getMonitorPostBriefMeta(p);
return (
|
{isExternalSocialPost(p) && titleTranslation?.translation && 译:{titleTranslation.translation}}
{p.publish_time || '-'} · {p.product || '-'} · {p.campaign || '-'}
{briefMeta.assigned && Brief · {briefMeta.name || briefMeta.id || briefMeta.parent}}
{isExternalSocialPost(p) && 外部声量 · {p.relationship_status || 'organic'} · {p.creator_handle || 'KOL/UGC'}}
|
{p.platform}
{account.handle || account.account_name || '-'}
|
{metricPending ? 待补数 : {formatCompactNumber(postViews)}} |
{formatCompactNumber(pickNativeMetric(p, 'likes'))} |
{formatCompactNumber(displayedComments)} |
{formatCompactNumber(pickNativeMetric(p, 'saves'))} |
{formatCompactNumber(pickNativeMetric(p, 'shares') + pickNativeMetric(p, 'reposts'))} |
|
{rowRisk.level === 'high' && HIGH · {rowRisk.riskComments}}
{rowRisk.level === 'medium' && MED · {rowRisk.riskComments}}
{rowRisk.level === 'low' && LOW}
|
);
})}
{!filteredPosts.length && scopedPosts.length > 0 && (
当前筛选没有命中文章。可以清空内容池筛选或调整平台、产品、风险条件。
)}
{!scopedPosts.length && (
暂无文章数据。可以点击同步获取平台资产。
)}
)}
{workTab === 'comments' && (
评论池
{assetScopeMeta[assetScope].label} · {commentStream.length}/{scopedComments.length} 条 · 按用户关心问题分类
{commentStream.length}
{commentConcernOptions.map(item => (
))}
setActiveCommentId(comment.comment_id)}
expanded
/>
)}
{workTab === 'risk' && (
风险队列
{assetScopeMeta[assetScope].label} · {riskComments.length} 条风险评论 · {openRiskComments.length} 条待处理
{openRiskComments.length} open
{riskComments.map(comment => {
const post = posts.find(item => item.post_id === comment.post_id);
const status = commentActions[comment.comment_id] || comment.action_status || 'observed';
const commentTranslation = translations[socialTranslationKey('comment', comment.comment_id)];
return (
);
})}
{!riskComments.length &&
暂无风险评论
}
)}
{activeTrendPost && (
setActiveTrendPostId('')}
/>
)}
{syncLogOpen && (
setSyncLogOpen(false)}
/>
)}
{manualUploadOpen && (
setManualUploadResolutions(prev => ({ ...prev, [id]: value }))}
onFileChange={previewManualUpload}
onApply={applyManualUpload}
onClose={() => setManualUploadOpen(false)}
/>
)}
);
};
const trendMetricOptions = [
{ key: 'views', label: '观看/阅读' },
{ key: 'likes', label: '点赞' },
{ key: 'comments', label: '评论' },
{ key: 'saves', label: '收藏' },
{ key: 'shares', label: '转发' },
];
const getTrendMetric = (snapshot = {}, key = 'views') => socialNumber(snapshot.metrics?.[key]);
const getTrendDelta = (snapshot = {}, key = 'views') => socialNumber(snapshot.daily_delta?.[key]);
const formatTrendDelta = (value = 0) => `${value > 0 ? '+' : ''}${formatCompactNumber(value)}`;
const getTrendDateLabel = (date = '') => String(date || '').slice(5).replace('-', '/');
const isEstimatedTrendSnapshot = (snapshot = {}) => snapshot.source === 'estimated_trend_gap_fill' || Boolean(snapshot.estimate_method);
const getTrendSourceLabel = (snapshot = {}) => {
if (!isEstimatedTrendSnapshot(snapshot)) return '真实快照';
return snapshot.estimate_method === 'linear_interpolation_between_snapshots' ? '趋势插值' : '首发期估算';
};
const ContentTrendDrawer = ({ post = {}, account = {}, snapshots = [], onClose }) => {
const [metricKey, setMetricKey] = React.useState('views');
const rows = [...snapshots].sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
const metric = trendMetricOptions.find(item => item.key === metricKey) || trendMetricOptions[0];
const current = rows.length ? getTrendMetric(rows[rows.length - 1], metricKey) : 0;
const first = rows.length ? getTrendMetric(rows[0], metricKey) : 0;
const deltaRows = rows.map(row => ({ ...row, value: getTrendMetric(row, metricKey), delta: getTrendDelta(row, metricKey) }));
const peakDelta = deltaRows.length > 1
? Math.max(...deltaRows.slice(1).map(row => row.delta), 0)
: Math.max(...deltaRows.map(row => row.delta), 0);
const peakRow = deltaRows.find(row => row.delta === peakDelta) || deltaRows[0] || {};
const latestDelta = deltaRows.length ? deltaRows[deltaRows.length - 1].delta : 0;
const decay = peakDelta > 0 ? Math.round(((peakDelta - latestDelta) / peakDelta) * 100) : null;
const dateRange = rows.length ? `${rows[0].date} - ${rows[rows.length - 1].date}` : '暂无快照';
const sourceFiles = Array.from(new Set(rows.map(row => row.source_file).filter(Boolean)));
const estimatedCount = rows.filter(isEstimatedTrendSnapshot).length;
const realCount = rows.length - estimatedCount;
const width = 520;
const lineHeight = 154;
const barHeight = 112;
const padX = 24;
const maxValue = Math.max(...deltaRows.map(row => row.value), 1);
const maxAbsDelta = Math.max(...deltaRows.map(row => Math.abs(row.delta)), 1);
const xFor = (index) => rows.length <= 1 ? width / 2 : padX + (index / (rows.length - 1)) * (width - padX * 2);
const yFor = (value) => 18 + (1 - (value / maxValue)) * (lineHeight - 36);
const points = deltaRows.map((row, index) => `${xFor(index)},${yFor(row.value)}`).join(' ');
const baseline = 58;
return (
);
};
const manualUploadActionLabel = {
keep_system: '保留系统值',
use_upload: '使用上传值',
merge_candidate: '合并到候选内容',
skip: '跳过',
};
const ManualSocialUploadDialog = ({ preview, status, error, resolutions, onResolutionChange, onFileChange, onApply, onClose }) => {
const summary = preview?.preview?.summary || {};
const conflicts = preview?.preview?.conflicts || [];
const updates = preview?.preview?.updates || [];
const files = preview?.files || [];
const missingFiles = preview?.preview?.missing_files || [];
const changedUpdates = updates.filter(item => Number(item.diff || 0) !== 0);
return (
event.stopPropagation()}>
上传运营表格
上传表为导出时点的全量累计数据;系统用相邻快照计算每日增长。公众号收藏只使用“收藏(人)”。
{error &&
{error}
}
{files.length > 0 && (
{files.map(file => {file.name})}
)}
{preview && (
<>
{formatCompactNumber(summary.account_updates || 0)}账号更新
{formatCompactNumber(summary.post_metric_updates || 0)}字段更新
{formatCompactNumber(summary.conflicts || 0)}需确认
{formatCompactNumber(summary.missing_files || 0)}缺少表格
{missingFiles.length > 0 && (
{missingFiles.map(item => {item})}
)}
{conflicts.length > 0 && (
需要运营确认
{conflicts.map(conflict => (
{conflict.platform} · {conflict.title || conflict.upload_title || conflict.candidate_title || '数据冲突'}
{conflict.message}
{conflict.field ? `${conflict.field}:系统 ${formatCompactNumber(conflict.system_value)} / 上传 ${formatCompactNumber(conflict.upload_value)}` : conflict.candidate_post_id ? `候选:${conflict.candidate_title}` : ''}
))}
)}
预检更新
{changedUpdates.slice(0, 18).map(update => (
{update.platform} · {update.title || update.account_id}
{update.field}
{formatCompactNumber(update.old)} → {formatCompactNumber(update.new)}
))}
{!changedUpdates.length &&
本次没有发现需要更新的字段。
}
>
)}
{status === 'applied' ? '已写入' : status === 'applying' ? '正在写入...' : preview ? '预检完成,将按全量累计快照写入系统' : '先选择表格进行预检'}
);
};
const MonitorStat = ({ label, value, meta, tone = 'neutral' }) => (
{label}
{value}
{meta}
);
const MonitorSelect = ({ label, value, options = [], onChange, render }) => (
);
const syncSectionLabel = {
ok: '成功',
empty: '无新增',
error: '失败',
running: '同步中',
idle: '待同步',
};
const SyncSectionPill = ({ section = {} }) => (
{syncSectionLabel[section.status] || section.status || '待同步'} · {formatCompactNumber(socialNumber(section.count))}
);
const SyncMetricLine = ({ metrics = {} }) => {
const rows = [
['观看/阅读', metrics.views],
['点赞', metrics.likes],
['评论', metrics.comments],
['收藏', metrics.saves],
['转发', metrics.shares],
];
return (
{rows.map(([label, value]) => {label} {formatCompactNumber(socialNumber(value))})}
);
};
const SyncLogDetailSection = ({ title, rows = [], omitted = 0, type }) => (
{title}
{rows.length}{omitted ? ` +${omitted}` : ''}
{rows.map((row, index) => (
{type === 'account' && (
<>
{row.account_name || row.handle || row.account_id || '-'}
{row.handle || row.account_id || '-'} · 粉丝 {formatCompactNumber(socialNumber(row.followers))}
>
)}
{type === 'post' && (
<>
{row.url ? {row.title || '未命名内容'} : (row.title || '未命名内容')}
{formatBeijingTime(row.published_at, '时间未知')} · {row.content_type || row.post_id || '-'}
>
)}
{type === 'comment' && (
<>
{row.user_name || '匿名用户'}
{formatBeijingTime(row.published_at, '时间未知')} · {row.risk_tag || row.keyword || row.post_id || '-'}
{row.text || '-'}
>
)}
))}
{!rows.length &&
本次未返回明细
}
);
const SyncLogDetails = ({ item = {} }) => {
const detail = item.items || {};
const hasDetail = (detail.accounts?.length || detail.posts?.length || detail.comments?.length || detail.omitted?.accounts || detail.omitted?.posts || detail.omitted?.comments);
if (!hasDetail) return null;
return (
查看本次返回:账号 {formatCompactNumber(socialNumber(item.account?.count))} · 内容 {formatCompactNumber(socialNumber(item.content?.count))} · 评论 {formatCompactNumber(socialNumber(item.comments?.count))}
);
};
const SyncLogModal = ({ log = {}, status, lastSync, nextRun, onClose }) => {
const platforms = log?.platforms || [];
return (
event.stopPropagation()}>
平台同步日志
{status} · 上次 {lastSync} · 下次 {nextRun}
触发:{log?.trigger || '-'}
开始:{formatBeijingTime(log?.startedAt, '-')}
完成:{formatBeijingTime(log?.finishedAt, '-')}
平台 / 账号
账号信息
内容池
评论池
说明
{platforms.map((item, index) => (
{item.platform}
{item.account_name || item.handle || item.account_id || '-'}
{item.message || item.account?.message || item.content?.message || item.comments?.message || '-'}
{item.elapsedMs ? ` · ${(item.elapsedMs / 1000).toFixed(1)}s` : ''}
))}
{!platforms.length &&
暂无平台同步日志。
}
{log?.errors?.length > 0 && (
{log.errors.map((error, index) => {error.platform || error.account_name || '同步'}:{error.message || '未知异常'})}
)}
);
};
const formatCommentPublishTime = (comment = {}) => {
const raw = String(comment.publish_time || comment.published_at || comment.created_at || comment.raw?.time || '').trim();
if (!raw) return '时间未知';
if (/^\d{4}[/-]\d{1,2}[/-]\d{1,2}/.test(raw)) return raw;
return formatBeijingTime(raw, raw, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
const CommentStream = ({ comments = [], posts = [], accounts = [], tick, activeCommentId, commentActions = {}, translations = {}, onSelect, expanded = false }) => {
const postById = new Map(posts.map(post => [post.post_id, post]));
const accountById = new Map(accounts.map(account => [account.account_id, account]));
const show = [...comments]
.sort((a, b) => Number(isRiskComment(b)) - Number(isRiskComment(a)) || String(b.publish_time || '').localeCompare(String(a.publish_time || '')))
.slice(0, expanded ? comments.length : Math.min(comments.length, 5 + (tick % 3)));
return (
{show.map((c, i) => {
const concern = getCommentConcern(c);
const sentimentMeta = getCommentSentimentMeta(c);
const sourcePost = postById.get(c.post_id) || {};
const sourceAccount = accountById.get(sourcePost.account_id) || getSocialAccount(sourcePost.account_id);
const sourceUrl = getSocialPostUrl(sourcePost, sourceAccount);
return (
);
})}
{!show.length &&
暂无评论数据
}
);
};
const ExposureChart = () => (
);
// ========== 08 · REPORTS ==========
const REPORT_PERIODS = [
{ id: '7d', label: '近 7 天', days: 7 },
{ id: '14d', label: '近 14 天', days: 14 },
{ id: '30d', label: '近 30 天', days: 30 },
{ id: 'all', label: '全部同步', days: null },
];
const DOMESTIC_SOCIAL_PLATFORMS = new Set(['小红书', '视频号', '微信公众号', '抖音', '微博', 'B站', 'B 站']);
const getSocialRegion = (platform = '') => DOMESTIC_SOCIAL_PLATFORMS.has(platform) ? 'domestic' : 'overseas';
const reportRegionLabel = (region) => ({ domestic: '国内', overseas: '海外' }[region] || '未归类');
const REPORT_PLATFORM_ORDER = ['微信公众号', '视频号', '小红书', 'Instagram', 'YouTube'];
const reportPlatformDisplayName = (platform = '') => platform === '视频号' ? '微信视频号' : platform;
const normalizeReportPlatformName = (platform = '') => {
const text = String(platform || '').trim().toLowerCase();
if (/公众号|official/.test(text)) return '微信公众号';
if (/视频号|channels/.test(text)) return '视频号';
if (/小红书|red/.test(text)) return '小红书';
if (/instagram|ig/.test(text)) return 'Instagram';
if (/youtube|yt/.test(text)) return 'YouTube';
return platform || '';
};
const compareReportPlatformOrder = (a = '', b = '') => {
const aIndex = REPORT_PLATFORM_ORDER.indexOf(normalizeReportPlatformName(a));
const bIndex = REPORT_PLATFORM_ORDER.indexOf(normalizeReportPlatformName(b));
const safeA = aIndex === -1 ? REPORT_PLATFORM_ORDER.length : aIndex;
const safeB = bIndex === -1 ? REPORT_PLATFORM_ORDER.length : bIndex;
return safeA - safeB || String(a).localeCompare(String(b), 'zh-CN');
};
function formatCompactNumber(value) {
if (value >= 1000000) return `${(value / 1000000).toFixed(2)}M`;
if (value >= 10000) return `${(value / 10000).toFixed(1)}w`;
return value.toLocaleString();
}
const BEIJING_TIME_ZONE = 'Asia/Shanghai';
const formatBeijingTime = (value, fallback = '', options = {}) => {
if (!value) return fallback;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
return date.toLocaleString('zh-CN', {
timeZone: BEIJING_TIME_ZONE,
hour12: false,
...options,
});
};
function downloadReportFile(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
const parseSocialDate = (value = '') => {
if (!value) return null;
const date = new Date(String(value).replace(/\//g, '-'));
return Number.isNaN(date.getTime()) ? null : date;
};
const getReportPostDateValue = (post = {}) => post.manual_metrics?.publish_time
|| post.manual_metrics?.published_at
|| post.published_at
|| post.publish_time
|| post.created_at
|| post.raw?.published_at
|| post.raw?.publish_time
|| '';
const getReportPostDate = (post = {}) => parseSocialDate(getReportPostDateValue(post));
const formatReportDateOnly = (value, fallback = '') => formatBeijingTime(value, fallback, { year: 'numeric', month: 'numeric', day: 'numeric' });
const buildReportDateRange = ({ posts = [], periodConfig = {}, generatedAt = new Date() }) => {
const endDate = generatedAt instanceof Date ? generatedAt : new Date(generatedAt);
const validEndDate = Number.isNaN(endDate.getTime()) ? new Date() : endDate;
const datedPosts = posts.map(getReportPostDate).filter(Boolean).sort((a, b) => a - b);
const startDate = periodConfig.days
? new Date(validEndDate.getTime() - periodConfig.days * 24 * 60 * 60 * 1000)
: datedPosts[0] || null;
const actualEndDate = periodConfig.days ? validEndDate : datedPosts[datedPosts.length - 1] || validEndDate;
const label = startDate
? `${formatReportDateOnly(startDate)} - ${formatReportDateOnly(actualEndDate)}`
: formatReportDateOnly(actualEndDate, periodConfig.label || '');
return {
start: startDate ? startDate.toISOString() : '',
end: actualEndDate.toISOString(),
label,
};
};
const getReportFollowerGrowth = (account = {}) => {
const value = account.follower_delta_period ?? account.follower_delta_7d ?? account.follower_growth ?? account.followers_delta ?? account.new_followers ?? account.followersGrowth;
return value === undefined || value === null || value === '' ? null : socialNumber(value);
};
const getReportFollowers = (account = {}) => {
const growthValue = getReportFollowerGrowth(account);
if (growthValue !== null) return growthValue;
return socialNumber(account.followers ?? account.follower_count ?? account.followers_count ?? account.subscriber_count ?? account.raw?.edge_followed_by?.count);
};
const normalizeFollowerGrowthDate = (value = '') => {
const raw = String(value || '').trim();
const match = raw.match(/20\d{2}(?:[-/年])\d{1,2}(?:[-/月])\d{1,2}/);
if (!match) return '';
const [year, month, day] = match[0].match(/\d+/g) || [];
if (!year || !month || !day) return '';
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
};
const buildFollowerGrowthTrend = (accounts = []) => {
const byDate = new Map();
accounts.forEach(account => {
const region = getSocialRegion(account.platform);
(Array.isArray(account.follower_growth_history) ? account.follower_growth_history : []).forEach(item => {
const date = normalizeFollowerGrowthDate(item.date);
if (!date) return;
const row = byDate.get(date) || {
date,
domestic: 0,
overseas: 0,
total: 0,
platforms: {},
};
const delta = socialNumber(item.delta);
row[region] += delta;
row.total += delta;
row.platforms[account.platform] = (row.platforms[account.platform] || 0) + delta;
byDate.set(date, row);
});
});
return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date));
};
const normalizeReportDateKey = (value) => {
if (!value) return '';
const raw = String(value).trim();
const rawDate = normalizeFollowerGrowthDate(raw);
if (rawDate) return rawDate;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return normalizeFollowerGrowthDate(value);
return date.toISOString().slice(0, 10);
};
const enumerateDateKeys = (start = '', end = '') => {
const startDate = new Date(`${start}T00:00:00Z`);
const endDate = new Date(`${end}T00:00:00Z`);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || startDate > endDate) return [];
const dates = [];
for (let current = new Date(startDate); current <= endDate; current.setUTCDate(current.getUTCDate() + 1)) {
dates.push(current.toISOString().slice(0, 10));
}
return dates;
};
const roundChartMax = (value = 0, step = 1) => Math.max(step * 2, Math.ceil(Math.max(0, value) / step) * step);
const buildChartTicks = (maxValue = 0, step = 1) => {
const max = roundChartMax(maxValue, step);
const ticks = [];
for (let value = max; value >= 0; value -= step) ticks.push(value);
return ticks;
};
const getPostMasterBriefId = (post = {}) => post.primaryMasterBriefId
|| post.briefMapping?.primaryMasterBriefId
|| (Array.isArray(post.masterBriefIds) ? post.masterBriefIds.find(id => id !== 'montx-auto-china-2026') : '')
|| 'unmapped-master-brief';
const getPostMasterBriefTitle = (post = {}) => cleanReportLabel(
post.briefMapping?.masterBriefGroup
|| post.excelCampaign
|| post.briefMapping?.unifiedTopic
|| post.manual_title
|| post.title
|| '未命名 Master Brief'
);
const cleanReportLabel = (value = '') => String(value).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
const looksLikeSocialAccountId = (value = '') => /^@?[a-f0-9]{16,}$/i.test(cleanReportLabel(value)) || /^@?(user|id|uid)[-_]?[a-z0-9]{8,}$/i.test(cleanReportLabel(value));
const getReportAccountName = (account = {}) => {
const handle = cleanReportLabel(account.handle);
const accountName = cleanReportLabel(account.account_name || account.name);
if (accountName && accountName.length <= 28) return accountName;
if (handle && !looksLikeSocialAccountId(handle)) return handle;
if (accountName) return accountName.slice(0, 28);
return cleanReportLabel(account.account_id) || '未命名账号';
};
const PLATFORM_METRIC_CONFIG = {
'小红书': { viewSource: 'views', viewLabel: '浏览/播放', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: true },
'视频号': { viewSource: 'plays', viewLabel: '播放', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: true },
'微信公众号': { viewSource: 'reads', viewLabel: '阅读', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: false },
'抖音': { viewSource: 'plays', viewLabel: '播放', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: true },
'微博': { viewSource: 'views', viewLabel: '阅读/播放', interactionFormula: 'likes + comments + shares + reposts', comparableWhenViewReady: false },
'B站': { viewSource: 'views', viewLabel: '播放/阅读', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: true },
'B 站': { viewSource: 'views', viewLabel: '播放/阅读', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: true },
'bilibili': { viewSource: 'views', viewLabel: '播放/阅读', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: true },
'Instagram': { viewSource: 'views', viewLabel: 'Reels views / plays', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: true },
'YouTube': { viewSource: 'views', viewLabel: 'Views', interactionFormula: 'likes + comments + shares', comparableWhenViewReady: true },
};
const METRIC_CANDIDATES = {
organicImpressions: ['organic_impressions', 'organicImpressions', 'impressions', 'impression_count', 'raw.organic_impressions', 'raw.impressions', 'raw.impression_count'],
adImpressions: ['ad_impressions', 'adImpressions', 'paid_impressions', 'raw.ad_impressions', 'raw.paid_impressions'],
reachUsers: ['reach_users', 'reachUsers', 'reach', 'raw.reach_users', 'raw.reach'],
views: ['views', 'view_count', 'play_count', 'plays', 'read_count', 'raw.view_count', 'raw.play_count', 'raw.video_view_count', 'raw.video_play_count', 'raw.read_count'],
viewUsers: ['view_users', 'viewUsers', 'viewer_count', 'raw.view_users', 'raw.viewer_count'],
reads: ['reads', 'read_count', 'raw.read_count', 'raw.read_num'],
plays: ['plays', 'play_count', 'views', 'raw.play_count', 'raw.video_play_count'],
thumbnailImpressions: ['thumbnail_impressions', 'thumbnailImpressions', 'raw.thumbnail_impressions'],
ctr: ['ctr', 'click_through_rate', 'raw.ctr', 'raw.click_through_rate'],
likes: ['likes', 'like_count', 'raw.likes', 'raw.liked_count', 'raw.like_count'],
comments: ['comments', 'comment_count', 'raw.comments', 'raw.comment_count'],
shares: ['shares', 'share_count', 'raw.shares', 'raw.share_count'],
shareUsers: ['share_users', 'shareUsers', 'raw.share_users'],
reposts: ['reposts', 'repost_count', 'forward_count', 'raw.reposts', 'raw.repost_count', 'raw.forward_count'],
saves: ['saves', 'collects', 'collected_count', 'raw.saves', 'raw.collect_count', 'raw.collected_count'],
saveUsers: ['save_users', 'saveUsers', 'favorite_users', 'raw.save_users', 'raw.favorite_users'],
favorites: ['favorites', 'favorite_count', 'raw.favorite_count'],
danmaku: ['danmaku_count', 'danmaku', 'raw.danmaku_count'],
coins: ['coin_count', 'coins', 'raw.coin_count'],
dislikes: ['dislike_count', 'dislikes', 'raw.dislike_count'],
downloads: ['download_count', 'downloads', 'raw.download_count'],
totalInteractions: ['total_interactions', 'totalInteractions', 'raw.total_interactions'],
watchTime: ['watch_time_seconds', 'watch_time', 'watchTime', 'raw.watch_time_seconds', 'raw.watch_time'],
avgWatchTime: ['avg_watch_time_seconds', 'avg_watch_time', 'avgWatchTime', 'raw.avg_watch_time_seconds', 'raw.avg_watch_time'],
avgViewPercentage: ['avg_view_percentage', 'avgViewPercentage', 'raw.avg_view_percentage'],
completionRate: ['completion_rate', 'completionRate', 'raw.completion_rate'],
profileVisits: ['profile_visits', 'profileVisits', 'raw.profile_visits'],
followersGained: ['followers_gained', 'followersGained', 'new_followers', 'raw.followers_gained'],
followersLost: ['followers_lost', 'followersLost', 'raw.followers_lost'],
linkClicks: ['link_clicks', 'linkClicks', 'raw.link_clicks'],
conversionCount: ['conversion_count', 'conversionCount', 'raw.conversion_count'],
};
const getMetricByPath = (row = {}, path = '') => path.split('.').reduce((value, key) => value?.[key], row);
const MANUAL_METRIC_CANDIDATES = {
views: ['manual_metrics.views', 'manual_metrics.plays', 'manual_metrics.reads'],
reads: ['manual_metrics.reads', 'manual_metrics.views'],
plays: ['manual_metrics.plays', 'manual_metrics.views'],
likes: ['manual_metrics.likes'],
comments: ['manual_metrics.comments'],
shares: ['manual_metrics.shares', 'manual_metrics.reposts', 'manual_metrics.forward_count'],
reposts: ['manual_metrics.reposts', 'manual_metrics.forward_count'],
saves: ['manual_metrics.saves', 'manual_metrics.collects', 'manual_metrics.favorites'],
favorites: ['manual_metrics.favorites', 'manual_metrics.saves'],
totalInteractions: ['manual_metrics.totalInteractions', 'manual_metrics.interaction'],
};
const pickNativeMetric = (row = {}, metric) => {
const candidates = METRIC_CANDIDATES[metric] || [metric];
let native = 0;
for (const path of candidates) {
const value = getMetricByPath(row, path);
if (value !== undefined && value !== null && value !== '') {
native = socialNumber(value);
if (native > 0) return native;
}
}
const manualCandidates = MANUAL_METRIC_CANDIDATES[metric] || [`manual_metrics.${metric}`];
for (const path of manualCandidates) {
const value = getMetricByPath(row, path);
if (value !== undefined && value !== null && value !== '') {
const manual = socialNumber(value);
if (manual > 0) return manual;
}
}
return native;
};
const sumNativeMetric = (rows = [], metric) => rows.reduce((sum, row) => sum + pickNativeMetric(row, metric), 0);
const getPlatformMetricConfig = (platform = '') => PLATFORM_METRIC_CONFIG[platform] || {
viewSource: 'views',
viewLabel: 'Views / reads',
interactionFormula: 'likes + comments + shares',
comparableWhenViewReady: true,
};
const getPostContentViews = (post = {}) => {
const config = getPlatformMetricConfig(post.platform);
return pickNativeMetric(post, config.viewSource) || pickNativeMetric(post, 'views') || pickNativeMetric(post, 'plays') || pickNativeMetric(post, 'reads');
};
const getPostInteractionParts = (post = {}) => ({
likes: pickNativeMetric(post, 'likes'),
comments: pickNativeMetric(post, 'comments'),
shares: pickNativeMetric(post, 'shares') + pickNativeMetric(post, 'reposts'),
});
const getPostEngagementsCore = (post = {}) => {
const parts = getPostInteractionParts(post);
return parts.likes + parts.comments + parts.shares;
};
const isPostMetricPending = (post = {}) => post.metric_status === 'pending_metrics' && !getPostContentViews(post);
const getSnapshotDate = (syncState = {}) => {
const source = syncState.lastSync || new Date().toISOString();
const date = new Date(source);
return Number.isNaN(date.getTime()) ? new Date().toISOString().slice(0, 10) : date.toISOString().slice(0, 10);
};
const getStandardContentFields = (post = {}, syncState = {}) => ({
platform: post.platform || '',
account_id: post.account_id || '',
content_id: post.content_id || post.post_id || '',
content_type: post.content_type || '',
title: cleanReportLabel(post.title || post.caption || post.description || '未命名内容'),
cover_url: getPostCoverUrl(post),
published_at: post.published_at || post.publish_time || '',
snapshot_date: post.snapshot_date || getSnapshotDate(syncState),
data_channel: post.data_channel || post.source_channel || (post.campaign?.includes('Sync') ? 'api_or_sync' : 'organic'),
raw_metrics_json: post.raw || null,
});
const getPlatformLimitation = ({ platform, platformPosts, contentViews, config, followerGrowthValue }) => {
if (!platformPosts.length) return `${platform} 本周期暂无有效发布,只保留账号状态,不参与内容效率判断。`;
if (!contentViews) return `${platform} 缺少 ${config.viewLabel} 字段;当前只能判断互动活跃,不能计算阅读/播放到互动转化 CVR。`;
if (!config.comparableWhenViewReady) return `${platform} 的 ${config.viewLabel} 属于平台原生口径,适合平台内诊断,不建议和视频/图文平台直接横比。`;
if (followerGrowthValue === null) return `${platform} 缺少历史粉丝基线,涨粉诊断需等待下一周期。`;
return `${platform} 核心字段覆盖完整,可进入跨平台对比,但仍需结合原生指标解释。`;
};
const getPlatformComparability = ({ platformPosts, contentViews, interaction, config }) => {
if (!platformPosts.length) return 'missing';
if (contentViews && config.comparableWhenViewReady) return 'ready';
if (interaction && !contentViews) return 'partial';
return 'native_only';
};
const extractPostTextValue = (value) => {
if (!value) return '';
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (Array.isArray(value)) return value.map(extractPostTextValue).filter(Boolean).join('\n');
if (typeof value === 'object') {
return [
value.text,
value.desc,
value.description,
value.title,
value.display_title,
value.caption?.text,
].map(extractPostTextValue).filter(Boolean).join('\n');
}
return '';
};
const getPostCaptionText = (post = {}) => [
post.title,
post.manual_title,
post.caption,
post.description,
post.text,
post.raw?.caption,
post.raw?.desc,
post.raw?.description,
post.raw?.display_title,
post.raw?.title,
post.raw?.edge_media_to_caption?.edges?.[0]?.node?.text,
].map(extractPostTextValue).filter(Boolean).join('\n');
const getPostHashtags = (post = {}) => {
const rawTags = [...(post.hashtags || []), ...(post.raw?.caption?.hashtags || [])];
const textTags = Array.from(getPostCaptionText(post).matchAll(/#[\p{L}\p{N}_-]+/gu)).map(match => match[0]);
return Array.from(new Set([...rawTags, ...textTags].map(tag => String(tag).replace(/^#/, '').trim()).filter(Boolean)));
};
const getPostContentScore = (post = {}) => getPostEngagementsCore(post) + Math.round(getPostContentViews(post) * 0.08);
const isUgcPost = (post = {}) => Boolean(post.is_ugc || post.ugc || post.source_type === 'ugc' || /ugc|用户|自来水/i.test(`${post.content_type || ''} ${post.campaign || ''} ${post.title || ''}`));
const isExternalReportPost = (post = {}) => isExternalSocialPost(post);
const isReportCoverUrlSupported = (url = '') => (/^https?:\/\//i.test(String(url || '')) || /^assets\/social-covers\//i.test(String(url || ''))) && !/wx\.qlogo\.cn/i.test(String(url || ''));
const getPostCoverUrl = (post = {}) => [
post.cached_cover_url,
post.local_cover_url,
post.media_url,
post.cover_url,
post.thumbnail_url,
post.image_url,
post.raw?.media_url,
post.raw?.cover_url,
post.raw?.full_cover_url,
post.raw?.share_cover_url,
post.raw?.thumbnail_url,
post.raw?.display_url,
post.raw?.image_url,
post.raw?.CoverImgUrl,
post.raw?.CoverImgUrl_16_9,
post.raw?.SuggestedCoverImg?.url,
post.raw?.mediaItem?.cover_url,
post.raw?.mediaItem?.coverUrl,
post.raw?.mediaItem?.thumb_url,
post.raw?.mediaItem?.thumbUrl,
post.raw?.images?.[0]?.url,
post.raw?.images_list?.[0]?.url,
post.raw?.images_list?.[0]?.info_list?.[0]?.url,
].find(isReportCoverUrlSupported) || '';
const PLATFORM_LOGO_META = {
'小红书': { abbr: '小红书', src: 'assets/platform-logos/xiaohongshu.jpg' },
'视频号': { abbr: '视频号', src: 'assets/platform-logos/wechat-channels.jpg' },
'微信公众号': { abbr: '公众号', src: 'assets/platform-logos/wechat-official.jpg' },
Instagram: { abbr: 'IG', src: 'assets/platform-logos/instagram.png' },
YouTube: { abbr: 'YT', src: 'assets/platform-logos/youtube.jpg' },
抖音: { abbr: '抖音' },
微博: { abbr: '微博' },
B站: { abbr: 'B站' },
};
const buildReportCommentSample = (comment = {}, posts = []) => {
const sourcePost = posts.find(post => post.post_id === comment.post_id) || {};
const platform = comment.platform || sourcePost.platform || '';
const sourceAccount = getSocialAccount(sourcePost.account_id);
const concern = getCommentConcern(comment);
return {
comment_id: comment.comment_id,
post_id: comment.post_id,
platform,
region: getSocialRegion(platform),
user_name: comment.user_name,
text: comment.text,
sentiment: socialNumber(comment.sentiment),
keyword: comment.keyword || comment.risk_tag || '',
risk_tag: comment.risk_tag || '',
user_concern_category: concern.id,
user_concern_label: concern.label,
user_concern_short: concern.short || concern.label,
comment_level: socialNumber(comment.comment_level || (comment.parent_comment_id ? 2 : 1)),
parent_comment_id: comment.parent_comment_id || '',
action_status: comment.action_status || 'observed',
source_post_title: cleanReportLabel(sourcePost.title || sourcePost.caption || '未匹配到原帖'),
source_post_type: sourcePost.content_type || '',
source_post_url: getSocialPostUrl(sourcePost, sourceAccount),
};
};
const buildReportContentRow = (post = {}, syncState = {}, accounts = []) => {
const standardFields = getStandardContentFields(post, syncState);
const views = getPostContentViews(post);
const interactionParts = getPostInteractionParts(post);
const interaction = getPostEngagementsCore(post);
const account = accounts.find(item => item.account_id === post.account_id) || getSocialAccount(post.account_id);
return {
...standardFields,
post_id: post.post_id,
region: getSocialRegion(post.platform),
publish_time: post.publish_time || '',
url: getSocialPostUrl(post, account),
media_url: post.media_url || '',
source_type: post.source_type || '',
creator_name: getReportAccountName(account),
creator_handle: post.creator_handle || account.handle || '',
views,
interaction,
...interactionParts,
engagements_core: interaction,
view_to_interaction_rate: views ? interaction / views : null,
engagement_rate_by_view: views ? interaction / views : null,
score: getPostContentScore(post),
hashtags: getPostHashtags(post),
comparison_text: getPostCaptionText(post),
};
};
const buildCommentPlatformBreakdown = ({ platforms = [], comments = [], posts = [] }) => platforms.map(platformRow => {
const platformComments = comments.filter(comment => {
const sourcePost = posts.find(post => post.post_id === comment.post_id) || {};
return (comment.platform || sourcePost.platform) === platformRow.platform;
});
const positiveComments = platformComments.filter(isPositiveComment);
const riskComments = platformComments.filter(isRiskComment);
const neutralComments = platformComments.filter(comment => !isPositiveComment(comment) && !isRiskComment(comment));
const positive = positiveComments.length;
const risk = riskComments.length;
const neutral = neutralComments.length;
const keywordMap = new Map();
platformComments.forEach(comment => {
const concern = getCommentConcern(comment);
const keyword = String(concern.label || comment.keyword || comment.risk_tag || '').replace(/^#/, '').trim();
if (!keyword) return;
keywordMap.set(keyword, (keywordMap.get(keyword) || 0) + 1);
});
const topKeywords = Array.from(keywordMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([keyword, count]) => ({ keyword, count }));
return {
platform: platformRow.platform,
region: platformRow.region,
posts: platformRow.posts,
total: platformComments.length,
positive,
neutral,
risk,
wordClouds: {
positive: buildCommentWordCloud(positiveComments),
neutral: buildCommentWordCloud(neutralComments),
risk: buildCommentWordCloud(riskComments),
},
topKeywords,
samples: platformComments.slice(0, 3).map(comment => buildReportCommentSample(comment, posts)),
insight: platformComments.length
? `${platformRow.platform} 当前同步 ${platformComments.length} 条评论,主要关注 ${topKeywords.length ? topKeywords.map(item => item.keyword).join('、') : '未标记话题'};该平台反馈仅代表本平台用户语境。`
: `${platformRow.platform} 当前暂无同步评论,不能推断该平台用户关注点。`,
};
});
const COMMENT_WORD_STOPWORDS = new Set(['这个', '没有', '可以', '我们', '你们', '就是', '还是', '已经', '不是', '什么', '怎么', '感觉', '一个', 'the', 'and', 'for', 'this', 'that', 'with', 'from']);
const COMMENT_EMOJI_PATTERN = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}](?:\uFE0F|\uFE0E)?/gu;
const isCommentEmojiToken = (value = '') => /^[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}](?:\uFE0F|\uFE0E)?$/u.test(String(value || ''));
const buildCommentWordCloud = (comments = []) => {
const keywordMap = new Map();
comments.forEach(comment => {
const explicit = cleanReportLabel(comment.keyword || comment.risk_tag || '');
const text = cleanReportLabel(comment.text || '');
const emojiCandidates = Array.from(text.matchAll(COMMENT_EMOJI_PATTERN)).map(match => match[0]);
const candidates = explicit
? [explicit, ...emojiCandidates]
: [
...Array.from(text.matchAll(/[\u4e00-\u9fa5]{2,8}|[A-Za-z][A-Za-z0-9_-]{2,}/g)).map(match => match[0]),
...emojiCandidates,
];
candidates.forEach(raw => {
const keyword = String(raw || '').replace(/^#/, '').trim();
if (!keyword || COMMENT_WORD_STOPWORDS.has(keyword.toLowerCase())) return;
keywordMap.set(keyword, (keywordMap.get(keyword) || 0) + 1);
});
});
const max = Math.max(...keywordMap.values(), 1);
return Array.from(keywordMap.entries())
.sort((a, b) => b[1] - a[1] || Number(isCommentEmojiToken(b[0])) - Number(isCommentEmojiToken(a[0])) || a[0].localeCompare(b[0], 'zh-CN'))
.slice(0, 12)
.map(([keyword, count]) => ({ keyword, count, weight: Math.max(1, Math.ceil((count / max) * 4)) }));
};
const CONTENT_FAMILY_RULES = [
{ id: 'p10-platform', label: 'P10 全域立体出行平台', patterns: ['p10', '全域立体出行平台', 'all-terrain 3d mobility'] },
{ id: 'v10-mobile-pod', label: 'V10 全域移动整备舱 / Mobile Logistics Pod', patterns: ['全域移动整备舱', 'mobile logistics pod', '不止是车'] },
{ id: 'v10-adaptive-hub', label: 'V10 Adaptive Mobile Hub', patterns: ['adaptive mobile hub'] },
{ id: 'v10-home-industrial-art', label: 'V10 折叠家的工业艺术品', patterns: ['折叠进工业艺术品', 'folding home', 'industrial art', '天地间筑家'] },
{ id: 'global-debut', label: 'MONTX 全球首发 / Global Debut', patterns: ['全球首发', 'global debut', '领现在,越未来', '全域跨界新物种'] },
{ id: 'new-breed-teaser', label: '硬核方盒 · 新物种预热', patterns: ['硬核方盒', 'redefine the order', 'new breed lands'] },
{ id: 'nomad-co-creation', label: '牛马野逍遥共创内容', patterns: ['牛马野逍遥', '座驾你来定义'] },
{ id: 'v10-exterior', label: 'V10 外观视频', patterns: ['v10外观', '外观视频'] },
{ id: 'v10-scene', label: 'V10 场景视频', patterns: ['v10场景', '场景视频'] },
{ id: 'v10-function', label: 'V10 功能视频', patterns: ['v10功能', '功能视频'] },
{ id: 'launch-recap', label: '车展发布会速览', patterns: ['车展发布会速览', '发布会速览'] },
{ id: 'leadership-quotes', label: '领导金句盘点', patterns: ['领导金句'] },
{ id: 'v10-teaser', label: 'V10 预热视频', patterns: ['v10预热', '预热视频'] },
];
const normalizeContentText = (value = '') => cleanReportLabel(value).toLowerCase().replace(/montx|领越|「|」|"|“|”|:|:|\|||/g, ' ').replace(/\s+/g, ' ').trim();
const getContentFamily = (row = {}) => {
const text = normalizeContentText(`${row.title || ''} ${row.content_type || ''}`);
const rule = CONTENT_FAMILY_RULES.find(item => item.patterns.some(pattern => text.includes(pattern.toLowerCase())));
if (rule) return { id: rule.id, label: rule.label, confidence: 'rule' };
const fallback = text.split(/\s+/).slice(0, 8).join(' ') || row.content_id || row.post_id || 'unknown-content';
return { id: `title:${fallback}`, label: cleanReportLabel(row.title || '未命名内容').slice(0, 36), confidence: 'title' };
};
const CONTENT_COMPARISON_EQUIVALENTS = [
[/mobile logistics pod/gi, '全域移动整备舱'],
[/all[-\s]?terrain 3d mobility platform/gi, '全域立体出行平台'],
[/adaptive mobile hub/gi, 'adaptive mobile hub'],
[/global debut/gi, '全球首发'],
[/new breed/gi, '新物种'],
[/no boundaries/gi, '无边界'],
[/lead the present[,]?\s*transcend the future/gi, '领现在越未来'],
[/folding\s+home|folding\s+["“”']?home["“”']?/gi, '折叠家'],
[/industrial art/gi, '工业艺术品'],
[/more than a vehicle/gi, '不止是车'],
[/tough boxy build/gi, '硬核方盒'],
[/redefine the order/gi, '重构秩序'],
[/co[-\s]?creation/gi, '共创'],
];
const normalizeComparisonText = (value = '') => {
let text = cleanReportLabel(value).toLowerCase();
CONTENT_COMPARISON_EQUIVALENTS.forEach(([pattern, replacement]) => {
text = text.replace(pattern, replacement);
});
return text
.replace(/https?:\/\/\S+/g, ' ')
.replace(/#[\p{L}\p{N}_-]+/gu, ' ')
.replace(/montx|领越|gac|auto china 2026|autochina2026/g, ' ')
.replace(/[「」"“”'‘’`´::||,,.。!!??()[\]【】{}<>《》/\\_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
};
const getComparisonTokens = (value = '') => {
const text = normalizeComparisonText(value);
const tokens = new Set();
Array.from(text.matchAll(/[a-z0-9]+/g)).forEach(match => {
if (match[0].length >= 2) tokens.add(match[0]);
});
Array.from(text.matchAll(/[\u4e00-\u9fa5]{2,}/g)).forEach(match => {
const word = match[0];
if (word.length <= 4) {
tokens.add(word);
} else {
for (let i = 0; i < word.length - 1; i += 1) tokens.add(word.slice(i, i + 2));
for (let i = 0; i < word.length - 3; i += 2) tokens.add(word.slice(i, i + 4));
}
});
return tokens;
};
const getTokenSimilarity = (a = new Set(), b = new Set()) => {
if (!a.size || !b.size) return 0;
let intersection = 0;
a.forEach(token => {
if (b.has(token)) intersection += 1;
});
return (2 * intersection) / (a.size + b.size);
};
const getComparisonSignature = (row = {}) => {
const text = normalizeComparisonText(`${row.title || ''} ${row.comparison_text || ''}`);
const product = /p10/.test(text) ? 'p10' : (/v10/.test(text) ? 'v10' : '');
const concepts = [
[/不止是车|more than a vehicle|三段式|滑移方向盘|全域移动整备舱/, 'mobile-pod'],
[/折叠家|工业艺术品|天地间筑家|folding home|industrial art/, 'industrial-art'],
[/全域立体出行平台|all terrain 3d mobility|无人区|地空|3d mobility/, 'p10-platform'],
[/全球首发|global debut/, 'global-debut'],
[/adaptive mobile hub|全域跨界新物种|新物种|无边界|no boundaries/, 'new-breed'],
[/硬核方盒|重构秩序|新物种降临|4 24/, 'teaser'],
[/牛马野逍遥|freeroad|座驾你来定义|共创/, 'co-creation'],
[/领导金句/, 'leadership-quotes'],
].filter(([pattern]) => pattern.test(text)).map(([, id]) => id);
return [product, ...concepts].filter(Boolean).join(':') || '';
};
const getComparisonProfile = (row = {}) => {
const title = normalizeComparisonText(row.title || '');
const body = normalizeComparisonText(row.comparison_text || row.title || '');
return {
title,
body,
signature: getComparisonSignature(row),
titleTokens: getComparisonTokens(title),
bodyTokens: getComparisonTokens(body),
};
};
const getContentMatchScore = (row = {}, group = {}) => {
const profile = getComparisonProfile(row);
return (group.rows || []).reduce((best, sample) => {
const sampleProfile = sample.comparisonProfile || getComparisonProfile(sample);
const titleSimilarity = getTokenSimilarity(profile.titleTokens, sampleProfile.titleTokens);
const bodySimilarity = getTokenSimilarity(profile.bodyTokens, sampleProfile.bodyTokens);
const exactTitle = Boolean(profile.title && sampleProfile.title && profile.title === sampleProfile.title);
const sameSignature = Boolean(profile.signature && sampleProfile.signature && profile.signature === sampleProfile.signature);
const matched = exactTitle
|| titleSimilarity >= 0.72
|| (titleSimilarity >= 0.46 && bodySimilarity >= 0.22)
|| (sameSignature && titleSimilarity >= 0.42)
|| (sameSignature && titleSimilarity >= 0.3 && bodySimilarity >= 0.18);
const score = (exactTitle ? 1 : 0) + titleSimilarity * 0.62 + bodySimilarity * 0.38 + (sameSignature ? 0.18 : 0);
return matched && score > best.score ? { matched, score, titleSimilarity, bodySimilarity, exactTitle, sameSignature } : best;
}, { matched: false, score: 0, titleSimilarity: 0, bodySimilarity: 0, exactTitle: false, sameSignature: false });
};
const chooseComparisonGroupTitle = (rows = []) => {
const candidates = [...rows].sort((a, b) => {
const aChinese = /[\u4e00-\u9fa5]/.test(a.title || '') ? 1 : 0;
const bChinese = /[\u4e00-\u9fa5]/.test(b.title || '') ? 1 : 0;
return bChinese - aChinese || socialNumber(b.interaction) - socialNumber(a.interaction) || socialNumber(b.views) - socialNumber(a.views);
});
return cleanReportLabel(candidates[0]?.title || '未命名内容').slice(0, 52);
};
const buildContentComparisons = ({ contentRows = [], accounts = [] }) => {
const allPlatforms = Array.from(new Set(accounts.map(account => account.platform).filter(Boolean)));
const comparisonGroups = [];
contentRows.forEach(row => {
const comparisonProfile = getComparisonProfile(row);
const rowWithProfile = { ...row, comparisonProfile };
const bestGroup = comparisonGroups.reduce((best, group) => {
const match = getContentMatchScore(rowWithProfile, group);
return match.matched && match.score > best.score ? { group, ...match } : best;
}, { group: null, matched: false, score: 0 });
const prev = bestGroup.group || {
content_group_id: `content-match-${comparisonGroups.length + 1}`,
title: cleanReportLabel(row.title || '未命名内容').slice(0, 52),
confidence: 'title-copy-match',
rows: [],
platforms: new Set(),
regions: new Set(),
cover_url: '',
cover_urls: new Set(),
url: '',
views: 0,
likes: 0,
comments: 0,
shares: 0,
interaction: 0,
matchEvidence: [],
};
prev.rows.push(rowWithProfile);
prev.platforms.add(row.platform);
prev.regions.add(row.region);
if (!prev.cover_url && row.cover_url) prev.cover_url = row.cover_url;
if (row.cover_url) prev.cover_urls.add(row.cover_url);
if (!prev.url && row.url) prev.url = row.url;
prev.views += socialNumber(row.views);
prev.likes += socialNumber(row.likes);
prev.comments += socialNumber(row.comments);
prev.shares += socialNumber(row.shares);
prev.interaction += socialNumber(row.interaction);
if (bestGroup.group) {
prev.matchEvidence.push({
post_id: row.post_id,
platform: row.platform,
titleSimilarity: bestGroup.titleSimilarity,
bodySimilarity: bestGroup.bodySimilarity,
exactTitle: bestGroup.exactTitle,
});
} else {
comparisonGroups.push(prev);
}
});
return comparisonGroups
.map(group => {
const platformMap = new Map();
group.rows.forEach(row => {
const candidate = {
platform: row.platform,
region: row.region,
posts: 1,
sampleCount: 1,
sampleTitle: row.title,
sampleContentType: row.content_type || '',
cover_url: row.cover_url || '',
cover_urls: new Set(row.cover_url ? [row.cover_url] : []),
url: row.url || '',
latestPublishedAt: row.published_at || row.publish_time || '',
views: socialNumber(row.views),
likes: socialNumber(row.likes),
comments: socialNumber(row.comments),
shares: socialNumber(row.shares),
interaction: socialNumber(row.interaction),
};
const prev = platformMap.get(row.platform) || {
platform: row.platform,
region: row.region,
posts: 0,
sampleCount: 0,
sampleTitle: row.title,
sampleContentType: row.content_type || '',
cover_url: row.cover_url || '',
cover_urls: new Set(row.cover_url ? [row.cover_url] : []),
url: row.url || '',
latestPublishedAt: row.published_at || row.publish_time || '',
views: 0,
likes: 0,
comments: 0,
shares: 0,
interaction: 0,
};
const sampleCount = prev.sampleCount + 1;
const prevScore = socialNumber(prev.interaction) * 1000000 + socialNumber(prev.views);
const candidateScore = candidate.interaction * 1000000 + candidate.views;
const next = candidateScore > prevScore ? { ...candidate, cover_urls: prev.cover_urls } : prev;
next.posts = 1;
next.sampleCount = sampleCount;
if (!next.sampleContentType && row.content_type) next.sampleContentType = row.content_type;
if (!next.cover_url && row.cover_url) next.cover_url = row.cover_url;
if (row.cover_url) prev.cover_urls.add(row.cover_url);
if (!next.url && row.url) next.url = row.url;
const publishedAt = row.published_at || row.publish_time || '';
if (publishedAt && (!next.latestPublishedAt || publishedAt > next.latestPublishedAt)) next.latestPublishedAt = publishedAt;
platformMap.set(row.platform, next);
});
const platformRows = Array.from(platformMap.values())
.map(row => ({
...row,
content_type: row.sampleContentType,
view_to_interaction_rate: row.views ? row.interaction / row.views : null,
}))
.sort((a, b) => socialNumber(b.interaction) - socialNumber(a.interaction) || socialNumber(b.views) - socialNumber(a.views));
const bestPlatform = [...platformRows].sort((a, b) => b.interaction - a.interaction || b.views - a.views)[0] || null;
const missingPlatforms = allPlatforms.filter(platform => !group.platforms.has(platform));
const representativeViews = platformRows.reduce((sum, row) => sum + socialNumber(row.views), 0);
const representativeLikes = platformRows.reduce((sum, row) => sum + socialNumber(row.likes), 0);
const representativeComments = platformRows.reduce((sum, row) => sum + socialNumber(row.comments), 0);
const representativeShares = platformRows.reduce((sum, row) => sum + socialNumber(row.shares), 0);
const representativeInteraction = platformRows.reduce((sum, row) => sum + socialNumber(row.interaction), 0);
const duplicateSamples = platformRows.reduce((sum, row) => sum + Math.max(0, socialNumber(row.sampleCount) - 1), 0);
const rate = representativeViews ? representativeInteraction / representativeViews : null;
return {
content_group_id: group.content_group_id,
title: chooseComparisonGroupTitle(group.rows),
cover_url: group.cover_url || platformRows.find(row => row.cover_url)?.cover_url || '',
cover_urls: Array.from(new Set([...group.cover_urls, ...platformRows.flatMap(row => Array.from(row.cover_urls || []))])).filter(Boolean),
url: group.url,
confidence: group.confidence,
matchEvidence: group.matchEvidence,
platformCount: group.platforms.size,
platforms: Array.from(group.platforms),
missingPlatforms,
regions: Array.from(group.regions),
duplicateSamples,
views: representativeViews,
likes: representativeLikes,
comments: representativeComments,
shares: representativeShares,
interaction: representativeInteraction,
view_to_interaction_rate: rate,
bestPlatform,
platformRows,
diagnosis: bestPlatform
? `${group.title} 在 ${bestPlatform.platform} 的互动贡献最高;代表发布总阅读/播放 ${formatCompactNumber(representativeViews)},互动转化 CVR ${rate === null ? '待计算' : `${(rate * 100).toFixed(2)}%`}。${duplicateSamples ? '同平台多条命中内容已取最高表现样本,不累加重复发布。' : ''}${missingPlatforms.length ? `尚未覆盖:${missingPlatforms.join('、')}。` : '已覆盖当前全部账号平台。'}`
: `${group.title} 暂无可比较的平台表现。`,
};
})
.filter(group => group.platformRows.length >= 2)
.sort((a, b) => b.interaction - a.interaction || b.views - a.views)
.slice(0, 5);
};
const buildMontxContentDiagnosis = ({ topPosts = [], topics = [], platforms = [], dataCoverage = {} }) => {
const strongestPlatform = [...platforms].sort((a, b) => b.views - a.views || b.interaction - a.interaction)[0];
const topicWords = topics.slice(0, 6).map(topic => topic.keyword);
return {
strategicJudgement: 'MONTX 当前内容资产已经形成“高端机能主义 + 数字游民生活方式 + 文明秩序感 + 新物种品类切割”的强识别方向;下一步要把品牌语气共识升级为可持续的栏目、场景、人群与转化路径。',
currentContentSummary: [
topPosts.length ? `当前高表现内容仍以产品定义、展会节点和功能表达为主,代表内容包括「${topPosts[0].title.slice(0, 28)}」。` : '当前缺少足够的高表现内容样本,暂不能稳定总结内容主题。',
topicWords.length ? `已出现可沉淀关键词:${topicWords.join('、')};这些词适合进入选题标签库,但需要分层使用。` : '当前话题词样本不足,建议继续采集标题、caption、hashtag 与评论关键词。',
strongestPlatform ? `${strongestPlatform.platform} 当前贡献最高阅读/播放或互动,应优先复盘其内容形态并判断能否跨平台复用。` : '平台表现样本不足,暂不能判断内容复用优先级。',
],
toneLayering: [
{ level: '核心母语', words: ['新物种', '全域移动整备舱', '机能美学', '场景自由'], usage: '品牌定位、产品定义、长期资产沉淀。' },
{ level: '高频可用词', words: ['秩序', '整备', '边界', '掌控', '全域'], usage: '日常内容可用,但必须搭配具体场景或功能证据。' },
{ level: '低频慎用词', words: ['文明底牌', '方舟', '堡垒', '图腾', '末世', '生存宣言'], usage: '仅用于发布会、车展、重磅产品稿,避免日常推文套话化。' },
],
columnMatrix: [
{ column: '产品定义类', goal: '解释 V10 / P10 是什么', writingRule: '可以高冷、硬核、宣言式,但必须给出产品结构证据。' },
{ column: '场景应用类', goal: '让用户看到如何使用', writingRule: '小红书/视频号要生活化,强调办公、收纳、拍摄、雨天、长途等具体颗粒度。' },
{ column: '共创社群类', goal: '沉淀牛马野逍遥和用户参与', writingRule: '保持精英邀请感,减少距离感,突出参与机制和反馈闭环。' },
{ column: '品牌世界观类', goal: '沉淀 New Breed / No Boundaries', writingRule: '适合公众号和官网资产,不宜每篇都用宏大词。' },
{ column: '技术解释类', goal: '把线控、滑移方向盘、空间编程讲清楚', writingRule: '降低文学性,用技术转译和图解逻辑建立信任。' },
],
nextDirections: [
{ priority: 'P0', direction: '真实使用场景内容', reason: '当前内容偏产品与品牌概念,需要补“人如何使用这台车”的证据。', suggestedFormats: ['小红书图文', '视频号短视频', 'Instagram Reel'], evidence: ['topPosts', 'platforms'] },
{ priority: 'P0', direction: '栏目化技术转译', reason: '线控、空间编程、生物性智能等概念需要长期解释,否则只会停留在酷词。', suggestedFormats: ['公众号长文', 'B站/YouTube 技术短片', 'FAQ 卡片'], evidence: ['contentRows', 'topics'] },
{ priority: 'P1', direction: '搜索语言纠偏内容', reason: '正文不自称房车,但可在对比、纠偏、SEO 和话题标签中承接用户搜索语言。', suggestedFormats: ['“如果你还在用房车理解 V10”系列', '小红书搜索向标题', '公众号解释稿'], evidence: ['topic gaps'] },
{ priority: 'P1', direction: '海外双语语气分流', reason: 'Command / Conquer 一类硬核词不适合所有海外场景,需要技术版和生活方式版两套表达。', suggestedFormats: ['Instagram 英文 caption', 'YouTube Shorts 标题', '海外官网短文案'], evidence: ['overseas platforms'] },
{ priority: 'P2', direction: 'UGC 与第三方证言', reason: dataCoverage.ugc ? '已有 UGC 样本,可继续扩展真实证言。' : '当前 UGC 数据不足,信任内容还缺真实用户和第三方视角。', suggestedFormats: ['用户故事', '共创反馈', '真实场景复盘'], evidence: ['ugc', 'comments'] },
],
assumptionsToValidate: [
'“高净值数字游民”“末世生存感”“女性受众需要柔性硬核”等判断应标记为待验证假设,不应直接入库为客户铁律。',
'“权威感/情绪价值/亲和力”的比例表达应降级为内容测试假设,用平台数据和评论反馈验证。',
'小红书不能只继承公众号高冷语气,必须验证生活化颗粒度对收藏、评论和搜索进入的影响。',
],
};
};
const buildRestrainedIssueLabels = (words = []) => {
const labels = new Set();
const text = words.join(' ');
if (/量产|上市|开卖|交付|什么时候/i.test(text)) labels.add('量产与上市节奏');
if (/ppt|概念|真假|真实|落地|画饼/i.test(text)) labels.add('产品真实性与落地证据');
if (/牛马|打工|自嘲|冒犯|阴阳/i.test(text)) labels.add('表达方式与用户身份感受');
if (/价格|贵|权益|优惠|订金|定金/i.test(text)) labels.add('价格、权益与规则清晰度');
if (/安全|续航|性能|通过|越野|可靠/i.test(text)) labels.add('性能与安全证据边界');
return Array.from(labels).slice(0, 4);
};
const buildExpertReviews = ({ commentSentiment = {}, issueLabels = [], platforms = [], topics = [], externalVoiceRows = [] }) => {
const strongestPlatform = [...platforms].sort((a, b) => b.interaction - a.interaction || b.views - a.views)[0];
const topicNames = topics.slice(0, 3).map(item => item.keyword).join('、');
const riskNote = issueLabels.length ? `需关注${issueLabels.join('、')}。` : '当前未形成明确高频风险议题。';
return [
{
role: 'Chief Social Media Marketing Officer',
title: '社媒运营判断',
level: '运营建议',
judgement: strongestPlatform ? `${reportPlatformDisplayName(strongestPlatform.platform)} 当前更适合承担内容验证与放大角色,但各平台不能直接复用同一套表达。` : '当前平台样本不足,暂不能判断稳定的平台角色。',
action: '将平台拆解从“数据对比”升级为“平台角色 + 内容适配 + 评论运营”三段式诊断。',
evidence: strongestPlatform ? `${reportPlatformDisplayName(strongestPlatform.platform)}互动或阅读/播放领先` : '平台数据覆盖不足',
},
{
role: 'Chief Brand Growth Officer',
title: '品牌增长判断',
level: '增长机会',
judgement: 'MONTX 已具备较强识别度,但还需要把声量转化为信任、搜索、询盘和长期栏目资产。',
action: topicNames ? `围绕 ${topicNames} 建立栏目化内容与搜索承接路径。` : '先补齐内容主题、搜索词和转化路径的验证。',
evidence: topicNames || '有效话题词样本不足',
},
{
role: 'Chief PR Officer',
title: '舆情与声誉判断',
level: commentSentiment.risk ? 'S1 · 轻度争议' : 'S0 · 监测',
judgement: `${riskNote} 目前更适合用解释型内容和评论区温和回应处理,不建议放大为公开声明。`,
action: '建立评论区二级内容机制:置顶解释、FAQ 回复、风险评论人工复核。',
evidence: commentSentiment.total ? `基于 ${commentSentiment.total} 条自营后台评论` : '后台评论不足',
},
{
role: 'Chief Legal Officer',
title: '法务与合规判断',
level: externalVoiceRows.length ? 'L1 · 需边界说明' : 'L0 · 常规监测',
judgement: '内容建议涉及产品能力、上市交付、用户素材和外部声量引用时,需要保留证据、授权和适用条件。',
action: '对性能、交付、用户评论、UGC/KOL 二次传播建立“证据/授权/审批”三项检查。',
evidence: externalVoiceRows.length ? '已接入外部声量内容' : '外部声量样本不足',
},
];
};
function buildSocialAssetReport({ brand, accounts, posts, comments, period, regionFilter, syncState }) {
const periodConfig = REPORT_PERIODS.find(item => item.id === period) || REPORT_PERIODS[1];
const generatedAtDate = new Date();
const periodStart = periodConfig.days ? new Date(generatedAtDate.getTime() - periodConfig.days * 24 * 60 * 60 * 1000) : null;
const rawPeriodPosts = posts.filter(post => {
const publishDate = getReportPostDate(post);
if (periodStart && publishDate && publishDate < periodStart) return false;
return regionFilter === 'all' || getSocialRegion(post.platform) === regionFilter;
});
const periodRange = buildReportDateRange({ posts: rawPeriodPosts, periodConfig, generatedAt: generatedAtDate });
const externalPeriodPosts = rawPeriodPosts.filter(isExternalReportPost);
const periodPosts = rawPeriodPosts.filter(post => !isExternalReportPost(post));
const externalAccountIds = new Set(externalPeriodPosts.map(post => post.account_id).filter(Boolean));
const accountIds = new Set(periodPosts.map(post => post.account_id));
const reportAccounts = accounts.filter(account => {
const regionOk = regionFilter === 'all' || getSocialRegion(account.platform) === regionFilter;
return regionOk && !externalAccountIds.has(account.account_id) && (accountIds.size === 0 || accountIds.has(account.account_id) || account.brand_id === brand?.id);
});
const postIds = new Set(periodPosts.map(post => post.post_id));
const externalPostIds = new Set(externalPeriodPosts.map(post => post.post_id));
const reportComments = comments.filter(comment => postIds.has(comment.post_id));
const externalReportComments = comments.filter(comment => externalPostIds.has(comment.post_id));
const totalViews = periodPosts.reduce((sum, post) => sum + getPostContentViews(post), 0);
const totalLikes = periodPosts.reduce((sum, post) => sum + getPostInteractionParts(post).likes, 0);
const totalComments = periodPosts.reduce((sum, post) => sum + getPostInteractionParts(post).comments, 0) + reportComments.length;
const totalShares = periodPosts.reduce((sum, post) => sum + getPostInteractionParts(post).shares, 0);
const totalInteraction = totalLikes + totalComments + totalShares;
const followers = reportAccounts.reduce((sum, account) => sum + getReportFollowers(account), 0);
const growthValues = reportAccounts.map(getReportFollowerGrowth).filter(value => value !== null);
const followerGrowth = growthValues.length ? growthValues.reduce((sum, value) => sum + value, 0) : null;
const followerGrowthTrend = buildFollowerGrowthTrend(reportAccounts);
const regions = ['domestic', 'overseas'].map(region => {
const regionPosts = periodPosts.filter(post => getSocialRegion(post.platform) === region);
const regionPostIds = new Set(regionPosts.map(post => post.post_id));
const regionAccountIds = new Set(regionPosts.map(post => post.account_id));
const regionAccounts = reportAccounts.filter(account => getSocialRegion(account.platform) === region && (regionAccountIds.has(account.account_id) || account.brand_id === brand?.id));
const regionGrowthValues = regionAccounts.map(getReportFollowerGrowth).filter(value => value !== null);
const regionLikes = regionPosts.reduce((sum, post) => sum + getPostInteractionParts(post).likes, 0);
const regionComments = regionPosts.reduce((sum, post) => sum + getPostInteractionParts(post).comments, 0) + reportComments.filter(comment => regionPostIds.has(comment.post_id)).length;
const regionShares = regionPosts.reduce((sum, post) => sum + getPostInteractionParts(post).shares, 0);
return {
region,
label: reportRegionLabel(region),
accounts: regionAccounts.length,
posts: regionPosts.length,
views: regionPosts.reduce((sum, post) => sum + getPostContentViews(post), 0),
interaction: regionLikes + regionComments + regionShares,
likes: regionLikes,
comments: regionComments,
shares: regionShares,
followers: regionAccounts.reduce((sum, account) => sum + getReportFollowers(account), 0),
followerGrowth: regionGrowthValues.length ? regionGrowthValues.reduce((sum, value) => sum + value, 0) : null,
platforms: Array.from(new Set([...regionAccounts.map(account => account.platform), ...regionPosts.map(post => post.platform)].filter(Boolean))),
};
});
const platforms = Array.from(new Set([...reportAccounts.map(account => account.platform), ...periodPosts.map(post => post.platform)].filter(Boolean)))
.map(platform => {
const platformPosts = periodPosts.filter(post => post.platform === platform);
const platformAccountIds = new Set(platformPosts.map(post => post.account_id));
const platformAccounts = reportAccounts.filter(account => account.platform === platform && (platformAccountIds.size === 0 || platformAccountIds.has(account.account_id) || account.brand_id === brand?.id));
const platformPostIds = new Set(platformPosts.map(post => post.post_id));
const platformComments = reportComments.filter(comment => comment.platform === platform || platformPostIds.has(comment.post_id));
const config = getPlatformMetricConfig(platform);
const nativeMetrics = {
views: sumNativeMetric(platformPosts, 'views'),
view_users: sumNativeMetric(platformPosts, 'viewUsers'),
reads: sumNativeMetric(platformPosts, 'reads'),
plays: sumNativeMetric(platformPosts, 'plays'),
likes: sumNativeMetric(platformPosts, 'likes'),
comments: sumNativeMetric(platformPosts, 'comments'),
shares: sumNativeMetric(platformPosts, 'shares'),
share_users: sumNativeMetric(platformPosts, 'shareUsers'),
repost_forward_count: sumNativeMetric(platformPosts, 'reposts'),
saves: sumNativeMetric(platformPosts, 'saves'),
save_favorite_users: sumNativeMetric(platformPosts, 'saveUsers'),
favorites: sumNativeMetric(platformPosts, 'favorites'),
danmaku_count: sumNativeMetric(platformPosts, 'danmaku'),
coin_count: sumNativeMetric(platformPosts, 'coins'),
dislike_count: sumNativeMetric(platformPosts, 'dislikes'),
download_count: sumNativeMetric(platformPosts, 'downloads'),
total_interactions: sumNativeMetric(platformPosts, 'totalInteractions'),
watch_time_seconds: sumNativeMetric(platformPosts, 'watchTime'),
avg_watch_time_seconds: platformPosts.length ? sumNativeMetric(platformPosts, 'avgWatchTime') / platformPosts.length : 0,
avg_view_percentage: platformPosts.length ? sumNativeMetric(platformPosts, 'avgViewPercentage') / platformPosts.length : 0,
completion_rate: platformPosts.length ? sumNativeMetric(platformPosts, 'completionRate') / platformPosts.length : 0,
profile_visits: sumNativeMetric(platformPosts, 'profileVisits'),
followers_gained: sumNativeMetric(platformPosts, 'followersGained'),
followers_lost: sumNativeMetric(platformPosts, 'followersLost'),
link_clicks: sumNativeMetric(platformPosts, 'linkClicks'),
conversion_count: sumNativeMetric(platformPosts, 'conversionCount'),
};
const contentViews = nativeMetrics[config.viewSource] || nativeMetrics.views || nativeMetrics.plays || nativeMetrics.reads || 0;
const likes = nativeMetrics.likes;
const postComments = nativeMetrics.comments;
const shares = nativeMetrics.shares + nativeMetrics.repost_forward_count;
const interactionComments = postComments + platformComments.length;
const interaction = likes + interactionComments + shares;
const followersTotal = platformAccounts.reduce((sum, account) => sum + getReportFollowers(account), 0);
const platformGrowthValues = platformAccounts.map(getReportFollowerGrowth).filter(value => value !== null);
const followerGrowthValue = platformGrowthValues.length ? platformGrowthValues.reduce((sum, value) => sum + value, 0) : null;
const topPostInPlatform = [...platformPosts].sort((a, b) => getPostContentScore(b) - getPostContentScore(a))[0];
const topPostsInPlatform = [...platformPosts]
.sort((a, b) => getPostContentScore(b) - getPostContentScore(a))
.slice(0, 3)
.map(post => buildReportContentRow(post, syncState, accounts));
const mix = { likes, comments: interactionComments, shares };
const leadingInteraction = Object.entries(mix).sort((a, b) => b[1] - a[1])[0] || ['likes', 0];
const leadingLabel = { likes: '点赞', comments: '评论', shares: '分享', saves: '收藏' }[leadingInteraction[0]];
const accountNames = platformAccounts.map(getReportAccountName).filter(Boolean);
const comparability = getPlatformComparability({ platformPosts, contentViews, interaction, config });
const limitation = getPlatformLimitation({ platform, platformPosts, contentViews, config, followerGrowthValue });
const accountInsight = platformAccounts.length
? `${platform} 当前纳入 ${platformAccounts.length} 个账号:${accountNames.slice(0, 3).join('、') || '未命名账号'};粉丝数 ${formatCompactNumber(followersTotal)},${followerGrowthValue === null ? '涨粉待历史基线。' : `按期初 0 口径,本周期净增 ${formatCompactNumber(followerGrowthValue)}。`}`
: `${platform} 当前没有可归属账号数据,账号诊断仅能基于发布内容反推。`;
const interactionInsight = platformPosts.length
? contentViews
? `${platform} 本周期发布 ${platformPosts.length} 条,${config.viewLabel} ${formatCompactNumber(contentViews)},点赞 ${formatCompactNumber(likes)}、评论 ${formatCompactNumber(interactionComments)}、转发 ${formatCompactNumber(shares)},阅读/播放到互动转化 CVR ${(interaction / Math.max(contentViews, 1) * 100).toFixed(2)}%;主要互动来自${leadingLabel} ${formatCompactNumber(leadingInteraction[1])}${topPostInPlatform ? `,代表内容为「${cleanReportLabel(topPostInPlatform.title || '未命名内容').slice(0, 24)}」。` : '。'}`
: `${platform} 本周期发布 ${platformPosts.length} 条,${config.viewLabel} 字段为 0,但已同步到点赞/评论/转发 ${formatCompactNumber(interaction)};需要补齐阅读/播放口径后再判断内容转化 CVR。`
: `${platform} 本周期暂无有效发布,互动诊断暂不成立。`;
return {
platform,
region: getSocialRegion(platform),
accounts: platformAccounts.length,
accountNames,
posts: platformPosts.length,
views: contentViews,
interaction,
likes,
comments: interactionComments,
shares,
followers: followersTotal,
followerGrowth: followerGrowthValue,
engagementRate: contentViews ? interaction / contentViews : 0,
normalized: {
views: contentViews,
interaction,
likes,
comments: interactionComments,
shares,
engagements_core: interaction,
engagements_platform: interaction + nativeMetrics.danmaku_count + nativeMetrics.coin_count + nativeMetrics.download_count,
followers: followersTotal,
followerGrowth: followerGrowthValue,
view_to_interaction_rate: contentViews ? interaction / contentViews : null,
engagement_rate_by_view: contentViews ? interaction / contentViews : null,
engagementRate: contentViews ? interaction / contentViews : 0,
},
nativeMetrics,
metricMapping: {
viewSource: config.viewSource,
viewLabel: config.viewLabel,
interactionFormula: 'likes + comments + shares',
},
dataQuality: {
views: contentViews ? 'ready' : platformPosts.length ? 'missing' : 'missing',
followerGrowth: followerGrowthValue === null ? 'missing_baseline' : 'ready',
comparability,
},
interactionMix: mix,
topPost: topPostInPlatform ? {
post_id: topPostInPlatform.post_id,
title: cleanReportLabel(topPostInPlatform.title || '未命名内容'),
url: getSocialPostUrl(topPostInPlatform, platformAccounts.find(account => account.account_id === topPostInPlatform.account_id) || getSocialAccount(topPostInPlatform.account_id)),
views: getPostContentViews(topPostInPlatform),
interaction: getPostEngagementsCore(topPostInPlatform),
...getPostInteractionParts(topPostInPlatform),
} : null,
topPosts: topPostsInPlatform,
diagnosis: {
accountInsight,
interactionInsight,
limitation,
},
accountInsight,
interactionInsight,
limitation,
status: !platformPosts.length ? 'no_posts' : contentViews ? (comparability === 'native_only' ? 'native_only' : 'ready') : 'missing_views',
};
})
.sort((a, b) => compareReportPlatformOrder(a.platform, b.platform) || b.views - a.views || b.interaction - a.interaction || a.platform.localeCompare(b.platform, 'zh-CN'));
const contentRows = periodPosts.map(post => buildReportContentRow(post, syncState, accounts));
const interactionTrend = Array.isArray(syncState.assetInteractionTrend) ? syncState.assetInteractionTrend : [];
const externalContentRows = externalPeriodPosts.map(post => buildReportContentRow(post, syncState, accounts));
const topPosts = [...contentRows]
.sort((a, b) => b.score - a.score)
.slice(0, 6);
const contentComparisons = buildContentComparisons({ contentRows, accounts: reportAccounts });
const externalVoiceRows = [...externalContentRows].sort((a, b) => b.score - a.score);
const externalPlatforms = Array.from(new Set(externalContentRows.map(row => row.platform).filter(Boolean))).map(platform => ({
platform,
region: getSocialRegion(platform),
posts: externalContentRows.filter(row => row.platform === platform).length,
}));
const externalCommentBreakdown = buildCommentPlatformBreakdown({ platforms: externalPlatforms, comments: externalReportComments, posts: externalPeriodPosts });
const externalVoice = {
overview: {
posts: externalContentRows.length,
accounts: new Set(externalContentRows.map(row => row.account_id).filter(Boolean)).size,
views: externalContentRows.reduce((sum, row) => sum + socialNumber(row.views), 0),
interaction: externalContentRows.reduce((sum, row) => sum + socialNumber(row.interaction), 0),
likes: externalContentRows.reduce((sum, row) => sum + socialNumber(row.likes), 0),
comments: externalContentRows.reduce((sum, row) => sum + socialNumber(row.comments), 0) + externalReportComments.length,
shares: externalContentRows.reduce((sum, row) => sum + socialNumber(row.shares), 0),
},
contentRows: externalVoiceRows,
comments: {
total: externalReportComments.length,
byPlatform: externalCommentBreakdown,
samples: externalReportComments.slice(0, 6).map(comment => buildReportCommentSample(comment, externalPeriodPosts)),
},
};
const ugc = externalVoiceRows;
const topicMap = new Map();
periodPosts.forEach(post => {
getPostHashtags(post).forEach(keyword => {
const prev = topicMap.get(keyword) || { keyword, source: 'hashtag', frequency: 0, views: 0, interaction: 0, likes: 0, comments: 0, shares: 0, platforms: new Set() };
const parts = getPostInteractionParts(post);
prev.frequency += 1;
prev.views += getPostContentViews(post);
prev.interaction += getPostEngagementsCore(post);
prev.likes += parts.likes;
prev.comments += parts.comments;
prev.shares += parts.shares;
prev.platforms.add(post.platform);
topicMap.set(keyword, prev);
});
});
reportComments.forEach(comment => {
const keyword = String(comment.keyword || comment.risk_tag || '').replace(/^#/, '').trim();
if (!keyword) return;
const post = periodPosts.find(item => item.post_id === comment.post_id) || {};
const prev = topicMap.get(keyword) || { keyword, source: 'comment', frequency: 0, views: 0, interaction: 0, likes: 0, comments: 0, shares: 0, platforms: new Set() };
const parts = getPostInteractionParts(post);
prev.frequency += 1;
prev.views += getPostContentViews(post);
prev.interaction += getPostEngagementsCore(post);
prev.likes += parts.likes;
prev.comments += parts.comments + 1;
prev.shares += parts.shares;
prev.platforms.add(comment.platform || post.platform);
topicMap.set(keyword, prev);
});
const topics = Array.from(topicMap.values())
.map(item => ({ ...item, platforms: Array.from(item.platforms) }))
.sort((a, b) => b.interaction - a.interaction || b.frequency - a.frequency)
.slice(0, 8);
const positiveComments = reportComments.filter(isPositiveComment);
const riskComments = reportComments.filter(isRiskComment);
const neutralComments = reportComments.filter(comment => !positiveComments.includes(comment) && !riskComments.includes(comment));
const commentSamples = reportComments.map(comment => buildReportCommentSample(comment, periodPosts));
const commentPlatformBreakdown = buildCommentPlatformBreakdown({ platforms, comments: reportComments, posts: periodPosts });
const commentSentimentTotal = reportComments.length;
const commentSentiment = {
total: commentSentimentTotal,
positive: positiveComments.length,
neutral: neutralComments.length,
risk: riskComments.length,
positiveRate: commentSentimentTotal ? positiveComments.length / commentSentimentTotal : 0,
neutralRate: commentSentimentTotal ? neutralComments.length / commentSentimentTotal : 0,
riskRate: commentSentimentTotal ? riskComments.length / commentSentimentTotal : 0,
topPositiveWords: buildCommentWordCloud(positiveComments).slice(0, 3).map(item => item.keyword),
topRiskWords: buildCommentWordCloud(riskComments).slice(0, 3).map(item => item.keyword),
};
const restrainedIssueLabels = buildRestrainedIssueLabels(commentSentiment.topRiskWords);
const topPost = topPosts[0];
const strongTopics = topics.slice(0, 3).map(item => item.keyword);
const bestRegion = [...regions].sort((a, b) => b.views - a.views)[0];
const weakRegion = [...regions].sort((a, b) => a.views - b.views)[0];
const lessonsGood = [
topPost ? `高表现内容集中在「${topPost.title.slice(0, 28)}」:${topPost.platform} 贡献 ${formatCompactNumber(topPost.views)} 阅读/播放。` : '',
strongTopics.length ? `有效内容关键词:${strongTopics.join('、')},建议沉淀为后续选题标签。` : '',
commentSentiment.total ? `用户总体舆情中正向反馈 ${commentSentiment.positive} 条,占 ${Math.round(commentSentiment.positiveRate * 100)}%;${commentSentiment.topPositiveWords.length ? `认可点集中在「${commentSentiment.topPositiveWords.join('、')}」。` : '说明当前内容已形成一定兴趣与认可。'}` : '',
bestRegion?.views ? `${bestRegion.label}区域当前阅读/播放规模领先,占总阅读/播放 ${Math.round((bestRegion.views / Math.max(totalViews, 1)) * 100)}%。` : '',
].filter(Boolean);
const lessonsImprove = [
followerGrowth === null ? '涨粉缺少历史基线,需在账号同步中保存上一周期 followers 才能形成增长诊断。' : '',
!reportComments.length ? '后台评论数据不足,暂不能提炼用户反馈与风险语义。' : '',
commentSentiment.risk ? `用户总体舆情中风险反馈 ${commentSentiment.risk} 条,占 ${Math.round(commentSentiment.riskRate * 100)}%;${restrainedIssueLabels.length ? `需优先解释${restrainedIssueLabels.join('、')}相关疑问。` : '需补充回应口径和 FAQ。'}` : '',
!externalVoiceRows.length ? '未识别到外部声量内容,需要接入 earned_media / UGC / KOL 来源。' : '',
weakRegion && weakRegion.posts === 0 ? `${weakRegion.label}区域暂无有效发布,区域对比诊断不完整。` : '',
].filter(Boolean);
const dataCoverage = {
overview: periodPosts.length > 0,
regions: regions.some(row => row.posts > 0),
platforms: platforms.length > 0,
contentComparisons: contentComparisons.length > 0,
topPosts: topPosts.length > 0,
externalVoice: externalVoiceRows.length > 0,
ugc: externalVoiceRows.length > 0,
topics: topics.length > 0,
comments: reportComments.length > 0,
followerGrowth: followerGrowth !== null,
missing: [
!externalVoiceRows.length ? '外部声量来源' : '',
!platforms.length ? '平台拆解' : '',
!reportComments.length ? '后台评论' : '',
followerGrowth === null ? '历史粉丝基线' : '',
].filter(Boolean),
};
const contentDiagnosis = buildMontxContentDiagnosis({ topPosts, topics, platforms, dataCoverage });
contentDiagnosis.expertReviews = buildExpertReviews({ commentSentiment, issueLabels: restrainedIssueLabels, platforms, topics, externalVoiceRows });
contentDiagnosis.restrainedIssueLabels = restrainedIssueLabels;
return {
reportType: 'SocialAssetReport',
generatedAt: formatBeijingTime(generatedAtDate),
brand: brand?.name || BRAND?.name || 'MONTX',
period: periodConfig.label,
periodRange,
regionFilter,
sync: {
status: syncState.status || 'idle',
lastSync: syncState.lastSync || '',
nextRun: syncState.nextRun || '',
errors: syncState.errors || [],
},
overview: {
accounts: reportAccounts.length,
posts: periodPosts.length,
publishedPosts: periodPosts.filter(post => post.status === 'published').length,
views: totalViews,
interaction: totalInteraction,
likes: totalLikes,
comments: totalComments,
shares: totalShares,
engagements_core: totalInteraction,
followers,
followerGrowth,
view_to_interaction_rate: totalViews ? totalInteraction / totalViews : null,
engagement_rate_by_view: totalViews ? totalInteraction / totalViews : null,
engagementRate: totalViews ? totalInteraction / totalViews : 0,
},
followerSync: {
status: followerGrowth === null ? 'missing_history_baseline' : 'ready',
explanation: followerGrowth === null
? '当前没有可用的粉丝净增字段,粉丝数会回退到平台返回的真实总量;外部声量账号不计入自营粉丝。'
: '本报表按期初粉丝为 0 处理,粉丝数优先使用本周期净涨粉推算;外部声量账号不计入自营粉丝。',
},
followerGrowthTrend,
interactionTrend,
regions,
platforms,
contentRows,
contentComparisons,
topPosts,
ugc,
externalVoice,
topics,
comments: {
total: reportComments.length,
positive: positiveComments.length,
neutral: neutralComments.length,
risk: riskComments.length,
wordClouds: {
positive: buildCommentWordCloud(positiveComments),
neutral: buildCommentWordCloud(neutralComments),
risk: buildCommentWordCloud(riskComments),
},
byPlatform: commentPlatformBreakdown,
samples: commentSamples.slice(0, 6),
},
contentDiagnosis,
lessons: { good: lessonsGood, improve: lessonsImprove, sentiment: commentSentiment },
dataCoverage,
};
}
const Reports = ({ workspaceContext }) => {
const [period, setPeriod] = React.useState('14d');
const [viewMode, setViewMode] = React.useState('deck');
const [publishing, setPublishing] = React.useState(false);
const [publishStatus, setPublishStatus] = React.useState(null);
const [syncedAssets, setSyncedAssets] = React.useState({ accounts: [], posts: [], comments: [], assetInteractionTrend: [], lastSync: '', nextRun: '', status: 'idle', errors: [] });
const [reportTranslations, setReportTranslations] = React.useState({});
const [reportTranslationStatus, setReportTranslationStatus] = React.useState('idle');
const reportRef = React.useRef(null);
const brandId = workspaceContext?.brandId || BRAND?.id || 'montx';
const activeBrand = (WORKSPACE_BRANDS || []).find(brand => brand.id === brandId) || BRAND;
const baseDataset = getMonitorDataset(brandId);
const accounts = mergeSocialRows(baseDataset.accounts, syncedAssets.accounts, 'account_id');
const posts = mergeSocialRows(baseDataset.posts, syncedAssets.posts, 'post_id');
const comments = mergeSocialRows(baseDataset.comments, syncedAssets.comments || [], 'comment_id');
const periodLabel = REPORT_PERIODS.find(item => item.id === period)?.label || REPORT_PERIODS[1].label;
const SocialAssetReport = buildSocialAssetReport({ brand: activeBrand, accounts, posts, comments, period, regionFilter: 'all', syncState: syncedAssets });
const reportTranslationCandidates = React.useMemo(() => {
const items = [];
SocialAssetReport.externalVoice.contentRows.forEach(row => {
const key = socialTranslationKey('report-post', row.post_id);
if (needsChineseTranslation(row.title) && !reportTranslations[key]) items.push({ id: key, type: 'external_report_title', text: row.title });
});
SocialAssetReport.externalVoice.comments.byPlatform.forEach(platformRow => {
platformRow.samples.forEach(comment => {
const key = socialTranslationKey('report-comment', comment.comment_id);
if (needsChineseTranslation(comment.text) && !reportTranslations[key]) items.push({ id: key, type: 'external_report_comment', text: comment.text });
});
});
return items.slice(0, 24);
}, [SocialAssetReport, reportTranslations]);
const reportTranslationSignature = reportTranslationCandidates.map(item => item.id).join('|');
const reportTranslatedCount = Object.values(reportTranslations).filter(item => item?.translation).length;
const applySyncPayload = React.useCallback((data = {}) => {
setSyncedAssets({
accounts: data.accounts || [],
posts: data.posts || [],
comments: data.comments || [],
assetInteractionTrend: data.assetInteractionTrend || [],
lastSync: data.lastFinishedAt || '',
nextRun: data.nextRunAt || '',
status: data.status || 'idle',
errors: data.errors || [],
});
}, []);
React.useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const days = REPORT_PERIODS.find(item => item.id === period)?.days || 0;
const response = await fetch(`/api/social/sync/status${days ? `?days=${days}` : ''}`);
const data = await response.json();
if (!cancelled && response.ok) applySyncPayload(data);
} catch (error) {
if (!cancelled) setSyncedAssets(prev => ({ ...prev, errors: [{ platform: 'local', message: error.message || '读取同步状态失败' }] }));
}
};
load();
const int = setInterval(load, 30000);
return () => {
cancelled = true;
clearInterval(int);
};
}, [applySyncPayload, period]);
React.useEffect(() => {
if (!reportTranslationSignature || reportTranslationStatus === 'loading') return;
let cancelled = false;
const items = reportTranslationCandidates;
setReportTranslationStatus('loading');
fetch('/api/social/translate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ items }),
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ data }) => {
if (cancelled) return;
setReportTranslationStatus(data.ok === false ? 'error' : 'ready');
setReportTranslations(prev => {
const next = { ...prev };
(data.translations || []).forEach(item => {
next[item.id] = {
translation: item.translation || '',
language: item.language || '',
error: item.error || '',
source: data.source || 'unknown',
};
});
return next;
});
})
.catch(error => {
if (cancelled) return;
setReportTranslationStatus('error');
setReportTranslations(prev => {
const next = { ...prev };
items.forEach(item => {
next[item.id] = { translation: '', language: '', error: error.message || '翻译失败', source: 'client-error' };
});
return next;
});
});
return () => {
cancelled = true;
};
}, [reportTranslationSignature]);
const publishPublicDeck = async () => {
setPublishing(true);
setPublishStatus({ state: 'loading', message: `正在生成${viewMode === 'deck' ? '16:9 画布' : '连续长页面'}报告并同步公网...` });
try {
const response = await fetch('/api/reports/publish', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
SocialAssetReport,
translations: reportTranslations,
period,
periodLabel: SocialAssetReport.periodRange?.label || periodLabel,
viewMode,
renderedHtml: reportRef.current?.outerHTML || '',
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '同步公网失败');
setPublishStatus({
state: data.ok ? 'ready' : 'error',
message: data.message || (data.ok ? '已推送公网。' : 'GitHub 推送失败。'),
publicUrl: data.ok ? data.publicUrl : '',
reportId: data.reportId,
error: data.git?.error || '',
});
} catch (error) {
setPublishStatus({ state: 'error', message: error.message || '同步公网失败' });
} finally {
setPublishing(false);
}
};
return (
社媒资产运营报表 · Social Asset Operating Report
基于真实同步账号、已发布内容与后台评论生成运营看板;缺失数据会明确标记,不用示例内容补位。
{publishStatus && (
{publishStatus.state === 'ready' ? '同步完成' : publishStatus.state === 'loading' ? '同步中' : '同步失败'}
{publishStatus.message}
{publishStatus.publicUrl &&
{publishStatus.publicUrl}}
{publishStatus.error &&
{publishStatus.error}}
)}
{REPORT_PERIODS.map(item => )}
sync · {syncedAssets.status || 'idle'}
{reportTranslationStatus === 'loading' ? `DeepSeek 翻译中 · 已译 ${reportTranslatedCount}` : `外部声量译文 ${reportTranslatedCount}`}
{syncedAssets.lastSync ? `最近同步(北京时间):${formatBeijingTime(syncedAssets.lastSync)}` : '尚未读取到同步完成时间'}
{syncedAssets.errors?.length > 0 && 同步异常 {syncedAssets.errors.length} 项,报告仅基于已取回数据。}
{viewMode === 'deck' ? '画布预览模式' : '连续长页面模式'}
{viewMode === 'deck' ? '同步公网将发布 16:9 分页画布,适合导出 PDF / PPT。' : '同步公网将发布连续长页面,适合在线阅读和滚动浏览。'}
{viewMode === 'deck'
?
: }
);
};
const ReportLongReport = ({ report, periodLabel = '', translations = {}, reportRef }) => (
SocialAssetReport · 连续长页面
{report.brand} 社媒资产运营报表
{report.periodRange?.label || periodLabel} · 国内 + 海外
Long View
1. 发布一览及整体数据
数据来自社媒同步状态和本地社媒资产表;同步异常不会补齐为示例内容,覆盖情况展示在右侧思考过程。
高表现发布内容
2. 国内 & 海外阅读/播放、互动、涨粉情况
{(report.regions || []).map(row => (
{row.label}
{row.platforms.join(' / ') || '暂无平台'}
))}
粉丝每日增长趋势
互动数据趋势与发布节点
平台拆解:平台内容分析
{(report.platforms || []).length ? : }
3. 优质外部声量内容
本节只展示 earned media / KOL / UGC 等外部内容,不计入自营账号、平台表现、话题词和内容横向比较。非中文标题由 DeepSeek 自动翻译。
{report.externalVoice?.contentRows?.length ? : }
4. 外部声量内容评论分析
{report.externalVoice?.comments?.byPlatform?.length ? : }
5. 有效话题词分析
计算逻辑:仅基于自营账号内容;从标题、caption、hashtags 与自营评论 keyword / risk_tag 抽取词项,按出现频次与对应内容互动贡献排序。外部声量内容已排除,避免把第三方传播语境混入自营选题判断。
{(report.topics || []).length ? : }
6. 内容横向比较
{(report.contentComparisons || []).length ? : }
7. Lesson & Learn / 内容创作建议
8. 用户评论反馈与风险提炼
{report.comments?.byPlatform?.length ? (
) : }
);
const chunkReportItems = (items = [], size = 1) => {
const chunks = [];
for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size));
return chunks.length ? chunks : [[]];
};
const deckText = (value = '', max = 84) => {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text;
};
const limitCommentRows = (rows = [], sampleLimit = 4) => rows.map(row => ({
...row,
samples: (row.samples || []).slice(0, sampleLimit),
}));
const ReportDeckFrame = ({ report, periodLabel, title, kicker, pageNumber, totalPages, children, variant = '' }) => (
{children}
);
const ReportDeckTopPostsTable = ({ rows = [] }) => (
rows.length ? (
| 内容 | 区域 | 阅读/播放 | 点赞 | 评论 | 转发 | 互动转化 CVR |
{rows.map(post => (
|
{post.platform} · {post.content_type || '内容'}
|
{reportRegionLabel(post.region)} |
{formatCompactNumber(post.views)} |
{formatCompactNumber(post.likes)} |
{formatCompactNumber(post.comments)} |
{formatCompactNumber(post.shares)} |
{post.views ? `${(((post.likes || 0) + (post.comments || 0) + (post.shares || 0)) / post.views * 100).toFixed(2)}%` : '-'} |
))}
) :
);
const ReportDeckRegionDetail = ({ row = {}, topPosts = [], platforms = [] }) => {
const regionPlatforms = platforms.filter(item => item.region === row.region);
return (
{row.label || '未归类'}
{(row.platforms || []).map(reportPlatformDisplayName).join(' / ') || '暂无平台'}
区域平台构成
{regionPlatforms.length ? regionPlatforms.slice(0, 5).map(platform => (
{reportPlatformDisplayName(platform.platform)}
{formatCompactNumber(platform.views || 0)}
{platform.view_to_interaction_rate === null ? '-' : `${((platform.view_to_interaction_rate || 0) * 100).toFixed(2)}%`}
)) :
}
{row.label || '区域'}高表现发布内容
post.region === row.region).slice(0, 5)}/>
);
};
const ReportDeckContentOverviewPage = ({ overview = {}, rows = [], title = '高表现发布内容', emptyTitle = '暂无发布数据', emptyBody = '没有可用于排序的真实内容。' }) => {
const interaction = overview.interaction ?? ((overview.likes || 0) + (overview.comments || 0) + (overview.shares || 0));
const engagementRate = overview.engagementRate ?? (overview.views ? interaction / overview.views : 0);
return (
{title}
{rows.length ? : }
);
};
const getReportCommentTone = (comment = {}) => {
if (isRiskComment(comment)) return '风险';
if (isPositiveComment(comment)) return '正向';
return '中性';
};
const ReportExternalCommentSamples = ({ samples = [], translations = {} }) => (
{samples.length ? samples.map(comment => (
{reportPlatformDisplayName(comment.platform)}
{getReportCommentTone(comment)}
{deckText(comment.text || '暂无评论文本', 82)}
{translations[socialTranslationKey('report-comment', comment.comment_id)]?.translation &&
译:{deckText(translations[socialTranslationKey('report-comment', comment.comment_id)].translation, 52)}}
)) :
}
);
const ReportDeckExternalCommentAnalysis = ({ commentRows = [], samples = [], translations = {} }) => {
const topSamples = [...(samples || [])]
.sort((a, b) => Number(isRiskComment(b)) - Number(isRiskComment(a)) || Math.abs(socialNumber(b.sentiment)) - Math.abs(socialNumber(a.sentiment)))
.slice(0, 5);
return (
{commentRows.length ? (
| 平台 | 正向数量 | 中性数量 | 风险数量 |
{commentRows.map(row => (
| {reportPlatformDisplayName(row.platform)} |
{formatCompactNumber(row.positive || 0)} |
{formatCompactNumber(row.neutral || 0)} |
{formatCompactNumber(row.risk || 0)} |
))}
) :
}
外部声量评论反馈 Top 5
按风险优先,其次按情绪强度排序;显示评论来源内容
);
};
const ReportCommentWordCloud = ({ words = [] }) => (
{words.length ? words.map(item => (
{item.keyword}{item.count}
)) : 暂无关键词}
);
const ReportOwnedCommentOverview = ({ comments = {} }) => {
const scopeLabel = comments.scopeLabel || '自营账号';
const rows = [
{ key: 'positive', label: '正向', value: comments.positive || 0, note: '认可 / 兴趣 / 购买意向', tone: 'positive' },
{ key: 'neutral', label: '中性', value: comments.neutral || 0, note: '询问 / 信息补充 / 普通反馈', tone: 'neutral' },
{ key: 'risk', label: '风险', value: comments.risk || 0, note: '质疑 / 误解 / 需响应议题', tone: 'risk' },
];
return (
{scopeLabel === '自营账号' ? '评论反馈总览' : `${scopeLabel} 评论反馈总览`}
仅基于{scopeLabel}已同步评论生成;词云来自真实 keyword、risk_tag 与评论文本关键词,用于判断用户关注点和潜在风险议题。
{formatCompactNumber(comments.total || 0)}comments
{rows.map(row => (
{row.label}
{formatCompactNumber(row.value)}
{row.note}
))}
);
};
const ReportDeckTopicList = ({ topics = [] }) => (
{topics.map(topic => (
#{deckText(topic.keyword, 22)}
{deckText(topic.platforms?.join(' / ') || topic.source || '内容词', 38)}
{topic.frequency} 次 · 阅读/播放 {formatCompactNumber(topic.views)} · 互动 {formatCompactNumber((topic.likes || 0) + (topic.comments || 0) + (topic.shares || 0))}
{topic.analysis || buildTopicAnalysis(topic)}
))}
);
const buildTopicAnalysis = (topic = {}) => {
const platforms = topic.platforms?.length ? topic.platforms.map(reportPlatformDisplayName).join(' / ') : '当前平台';
const interaction = (topic.likes || 0) + (topic.comments || 0) + (topic.shares || 0);
const rate = topic.views ? `${(interaction / topic.views * 100).toFixed(2)}%` : '待补阅读/播放';
if (topic.source === 'comment') {
return `来自评论反馈的用户关注点,在 ${platforms} 出现 ${topic.frequency || 0} 次;可用于后续评论回应、FAQ 或风险解释内容,关联内容互动转化 CVR ${rate}。`;
}
return `来自标题、文案或 hashtag 的内容主题,在 ${platforms} 覆盖 ${topic.frequency || 0} 条内容;说明该方向已形成可复用选题标签,关联内容互动转化 CVR ${rate}。`;
};
const ReportToneLayer = ({ layer = {}, compact = false }) => (
{layer.level}
{(layer.words || []).map(word => {word})}
{compact ? deckText(layer.usage || '', 42) : layer.usage}
);
const ReportDeckDiagnosisSummary = ({ diagnosis }) => {
if (!diagnosis) return ;
return (
内容诊断判断
{deckText(diagnosis.strategicJudgement, 128)}
目前内容写了什么
{(diagnosis.currentContentSummary || []).slice(0, 3).map(item =>
{deckText(item, 86)}
)}
语气资产分层
{(diagnosis.toneLayering || []).slice(0, 3).map(layer => (
))}
);
};
const ReportDeckColumnMatrix = ({ items = [] }) => (
长期栏目矩阵
{items.map(item => (
{deckText(item.column, 18)}
{deckText(item.goal, 34)}
{deckText(item.writingRule, 76)}
))}
);
const ReportDeckNextDirections = ({ items = [] }) => (
下一阶段新增方向
{items.map(item => (
{item.priority}
{deckText(item.direction, 30)}
{deckText(item.reason, 88)}
{deckText((item.suggestedFormats || []).join(' / '), 66)}
))}
);
const ReportDeckExpertReviews = ({ reviews = [] }) => (
{reviews.map(item => (
{item.title}
{item.level}
{item.role}
{deckText(item.judgement, 92)}
{deckText(item.action, 78)}
))}
);
const ReportDeckAssumptions = ({ items = [] }) => (
待验证假设
{items.map((item, index) => (
H{index + 1}
{deckText(item, 118)}
))}
);
const ReportLessonLearn = ({ lessons = {}, compact = false }) => {
const sentiment = lessons.sentiment || {};
const goodItems = lessons.good?.length ? lessons.good : ['真实数据不足,暂不生成正向结论。'];
const improveItems = lessons.improve?.length ? lessons.improve : ['暂无明显数据缺口。'];
const textLimit = compact ? 108 : 180;
return (
用户总体舆情
{sentiment.total ? `基于 ${formatCompactNumber(sentiment.total)} 条自营后台评论` : '后台评论数据不足'}
做得好的地方
{goodItems.slice(0, compact ? 3 : 6).map(item =>
{compact ? deckText(item, textLimit) : item}
)}
待改进建议
{improveItems.slice(0, compact ? 3 : 6).map(item =>
{compact ? deckText(item, textLimit) : item}
)}
);
};
const buildReportDeckPages = ({ report, periodLabel, translations = {} }) => {
const pages = [];
const push = (title, kicker, body, variant = '') => pages.push({ title, kicker, body, variant });
const overview = report.overview || {};
const regions = report.regions || [];
const platforms = report.platforms || [];
const externalVoice = report.externalVoice || { contentRows: [], comments: { byPlatform: [] } };
const reportComments = report.comments || { byPlatform: [], positive: 0, neutral: 0, risk: 0 };
const lessons = report.lessons || { good: [], improve: [] };
const reportDateRangeLabel = report.periodRange?.label || periodLabel;
push('核心摘要', '00 · Executive Summary', (
{report.brand || 'MONTX'}
社媒资产运营报表
{reportDateRangeLabel} · 国内 + 海外 · 基于真实同步内容、评论与账号数据生成
), 'cover');
push('发布一览及整体数据', '01 · Publishing Overview', (
));
push('国内 & 海外表现', '02 · Region Performance', (
{regions.map(row => (
{row.label}
{row.platforms.join(' / ') || '暂无平台'}
))}
));
['domestic', 'overseas'].forEach((region, index) => {
const label = reportRegionLabel(region);
push(`粉丝每日增长趋势 · ${label}`, `02${index ? 'C' : 'B'} · Follower Growth`, (
));
});
['domestic', 'overseas'].forEach((region, index) => {
const label = reportRegionLabel(region);
push(`互动数据趋势与发布节点 · ${label}`, `02${index ? 'E' : 'D'} · Interaction Trend`, (
));
});
push('平台聚合数据', '03 · Platform Aggregate', (
));
chunkReportItems(platforms, 1).forEach((chunk, index) => {
const platformTitle = chunk[0]?.platform ? reportPlatformDisplayName(chunk[0].platform) : `平台内容分析${index ? `(续 ${index + 1})` : ''}`;
push(`平台拆解:${platformTitle}`, '03 · Platform Breakdown', (
chunk.length ? :
));
});
chunkReportItems(externalVoice.contentRows || [], 5).forEach((chunk, index) => {
push(`优质外部声量内容${index ? `(续 ${index + 1})` : ''}`, '04 · Earned Media', (
));
});
push('外部声量内容评论分析', '05 · Earned Comments', (
));
{
const topicChunk = (report.topics || []).slice(0, 4);
push('有效话题词分析', '06 · Effective Topics', (
计算逻辑:仅基于自营账号内容;从标题、caption、hashtags 与自营评论 keyword / risk_tag 抽取词项,按出现频次与对应内容互动贡献排序。
{topicChunk.length ?
: }
));
}
chunkReportItems(report.contentComparisons || [], 1).forEach((chunk, index) => {
const contentTitle = chunk[0]?.title ? deckText(chunk[0].title, 28) : `内容 ${index + 1}`;
push(`内容横向比较 · ${contentTitle}`, '07 · Content Comparison', (
chunk.length ? :
));
});
push('Lesson & Learn / 内容创作建议', '08 · Lesson & Learn', (
));
push('诊断与风险边界', '09 · Expert Review', (
));
push('用户评论反馈与风险', '10 · Owned Comments', (
reportComments.total ? :
));
(reportComments.byPlatform || []).forEach(row => {
const platformName = reportPlatformDisplayName(row.platform || '');
push(`用户评论反馈与风险 · ${platformName}`, '10 · Owned Comments', (
));
});
push('内容创作诊断:品牌语气与现状', '11 · Content Diagnosis', (
));
chunkReportItems(report.contentDiagnosis?.columnMatrix || [], 5).forEach((chunk, index) => {
push(`内容栏目矩阵${index ? `(续 ${index + 1})` : ''}`, '12 · Content System', (
chunk.length ? :
));
});
chunkReportItems(report.contentDiagnosis?.nextDirections || [], 3).forEach((chunk, index) => {
push(`新增内容方向${index ? `(续 ${index + 1})` : ''}`, '13 · Next Directions', (
chunk.length ? :
));
});
push('待验证假设', '14 · Hypothesis', (
));
return pages;
};
const ReportDeck = ({ report, periodLabel = '', translations = {}, reportRef }) => {
const pages = buildReportDeckPages({ report, periodLabel, translations });
return (
{pages.map((page, index) => (
{page.body}
))}
);
};
const PublicDeckReport = () => {
const reportId = location.pathname.replace(/^\/+|\/+$/g, '');
const [payload, setPayload] = React.useState(null);
const [error, setError] = React.useState('');
React.useEffect(() => {
document.body.classList.add('deck-public-body');
const meta = document.createElement('meta');
meta.name = 'robots';
meta.content = 'noindex,nofollow';
document.head.appendChild(meta);
return () => {
document.body.classList.remove('deck-public-body');
meta.remove();
};
}, []);
React.useEffect(() => {
let cancelled = false;
fetch(`/reports/${reportId}/report.json`)
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ ok, data }) => {
if (cancelled) return;
if (!ok) throw new Error(data.error || '报告不存在');
setPayload(data);
})
.catch(err => {
if (!cancelled) setError(err.message || '报告加载失败');
});
return () => { cancelled = true; };
}, [reportId]);
if (error) return 报告加载失败{error}
;
if (!payload) return 加载报告中{reportId}
;
return (
{payload.SocialAssetReport?.brand || 'MONTX'} 社媒资产运营报表
{payload.reportId} · {formatBeijingTime(payload.publishedAt)}
{payload.viewMode === 'long'
?
:
}
);
};
const ReportPlatformLogo = ({ platform, small = false }) => {
const meta = PLATFORM_LOGO_META[platform] || { abbr: String(platform || '?').slice(0, 3) };
return (
{meta.src ?
: meta.abbr}
);
};
const getReportCoverRatioClass = (platform = '', contentType = '') => {
const text = `${platform} ${contentType}`.toLowerCase();
if (/youtube/.test(text)) return 'ratio-wide';
if (/视频号|reel|short|竖屏|vertical/.test(text)) return 'ratio-vertical';
if (/instagram/.test(text)) return /video|视频|reel/.test(text) ? 'ratio-vertical' : 'ratio-square';
if (/小红书/.test(text)) return /video|视频/.test(text) ? 'ratio-vertical' : 'ratio-portrait';
if (/公众号|wechat/.test(text)) return 'ratio-article';
return 'ratio-portrait';
};
const ReportContentCover = ({ src, title, platform, contentType = '', compact = false, candidates = [], preferVertical = false }) => {
const fallbackLogoSrc = PLATFORM_LOGO_META[platform]?.src || '';
const sources = React.useMemo(() => {
const rawSources = Array.from(new Set([src, ...candidates].filter(Boolean)));
const isUnstableSource = (url = '') => /rednotecdn\.com|finder\.video\.qq\.com/i.test(String(url));
return Array.from(new Set([
...rawSources.filter(url => !isUnstableSource(url)),
fallbackLogoSrc,
...rawSources.filter(isUnstableSource),
].filter(Boolean)));
}, [src, candidates, fallbackLogoSrc]);
const [orderedSources, setOrderedSources] = React.useState(sources);
const [sourceIndex, setSourceIndex] = React.useState(0);
const [loaded, setLoaded] = React.useState(false);
const [naturalRatio, setNaturalRatio] = React.useState('');
React.useEffect(() => {
let cancelled = false;
setOrderedSources(sources);
setSourceIndex(0);
if (!preferVertical || compact || !sources.length) return undefined;
const scoreSource = (url, index) => new Promise(resolve => {
if (/assets\/platform-logos\//.test(url)) {
resolve({ url, index, loaded: true, width: 1, height: 1, score: -10 });
return;
}
const image = new Image();
image.referrerPolicy = 'no-referrer';
image.onload = () => {
const width = image.naturalWidth || 1;
const height = image.naturalHeight || 1;
const ratio = width / height;
const isVertical = height > width * 1.08;
const isLandscape = width > height * 1.08;
resolve({
url,
index,
loaded: true,
width,
height,
score: (isVertical ? 300 : isLandscape ? 100 : 200) + Math.min(height, 2400) / 10000 - index / 100,
});
};
image.onerror = () => resolve({ url, index, loaded: false, width: 0, height: 0, score: -100 - index });
image.src = url;
});
Promise.all(sources.map(scoreSource)).then(results => {
if (cancelled) return;
const sorted = results
.filter(item => item.loaded)
.sort((a, b) => b.score - a.score || a.index - b.index)
.map(item => item.url);
const failed = results.filter(item => !item.loaded).map(item => item.url);
const nextSources = Array.from(new Set([...sorted, ...failed]));
setOrderedSources(nextSources.length ? nextSources : sources);
setSourceIndex(0);
});
return () => { cancelled = true; };
}, [sources.join('|'), preferVertical, compact]);
React.useEffect(() => { setSourceIndex(0); }, [orderedSources.join('|')]);
React.useEffect(() => { setLoaded(false); setNaturalRatio(''); }, [sourceIndex, orderedSources.join('|')]);
const activeSrc = orderedSources[sourceIndex] || '';
React.useEffect(() => {
if (!activeSrc || loaded) return undefined;
if (/assets\/platform-logos\//.test(activeSrc)) return undefined;
const timer = window.setTimeout(() => {
setSourceIndex(index => index + 1 < orderedSources.length ? index + 1 : orderedSources.length);
}, 2500);
return () => window.clearTimeout(timer);
}, [activeSrc, loaded, orderedSources.length]);
return (
{activeSrc ?

{
const image = event.currentTarget;
if (image.naturalWidth && image.naturalHeight) setNaturalRatio(`${image.naturalWidth} / ${image.naturalHeight}`);
setLoaded(true);
}} onError={() => {
setSourceIndex(index => index + 1 < orderedSources.length ? index + 1 : orderedSources.length);
}}/> : null}
{activeSrc ? '' : 'NO COVER'}
);
};
const ReportContentLink = ({ title, url, className = '' }) => {
return ;
};
const ReportMiniMetric = ({ label, value, note }) => (
{label}
{value}
{note ? {note} : null}
);
const getPlatformContentAngle = (platform = '', topPost = {}) => {
const title = String(topPost.title || '');
const hashtags = (topPost.hashtags || []).slice(0, 3);
const keywordText = hashtags.length ? hashtags.join('、') : cleanReportLabel(title).slice(0, 18);
if (platform === '微信公众号') return `长文平台能承接定义解释;「${keywordText || '产品定义'}」把品牌概念转成可阅读叙事。`;
if (platform === '视频号') return `视频号依赖首屏和现场感;「${keywordText || '现场/产品画面'}」降低理解成本,利于播放后的轻互动。`;
if (platform === '小红书') return `小红书依赖可搜索场景词;「${keywordText || '场景关键词'}」比抽象宣言更容易被理解。`;
if (platform === 'Instagram') return `Instagram 依赖视觉识别和短语传播;「${keywordText || '视觉主题'}」适合形成海外记忆点。`;
if (platform === 'YouTube') return `YouTube 依赖标题和画面解释力;「${keywordText || '产品/场景'}」适合承接搜索与长尾播放。`;
return `高表现内容集中在「${keywordText || '核心主题'}」,说明具体场景和清晰标题更有效。`;
};
const getPlatformInsightCards = (row = {}) => {
const topPost = (row.topPosts || [])[0] || row.topPost || null;
if (!topPost) {
return [
{
label: '平台内容分析',
text: `${reportPlatformDisplayName(row.platform)} 本周期暂无可分析的高表现内容。需要至少 1 条真实发布内容,并包含阅读/播放、点赞、评论、转发字段,才能判断标题、封面、场景词和互动结构是否有效。`,
},
];
}
const mixEntries = Object.entries({
likes: topPost.likes,
comments: topPost.comments,
shares: topPost.shares,
}).sort((a, b) => socialNumber(b[1]) - socialNumber(a[1]));
const [leadingKey, leadingValue] = mixEntries[0] || ['likes', 0];
const leadingLabel = { likes: '点赞', comments: '评论', shares: '分享', saves: '收藏' }[leadingKey] || '互动';
const avgViews = row.posts ? (row.views || 0) / row.posts : 0;
const avgInteraction = row.posts ? (row.interaction || 0) / row.posts : 0;
const viewLift = avgViews && topPost.views ? topPost.views / avgViews : 0;
const interactionLift = avgInteraction && topPost.interaction ? topPost.interaction / avgInteraction : 0;
const liftText = interactionLift >= viewLift && interactionLift >= 1.15
? `互动约为均值 ${interactionLift.toFixed(1)} 倍`
: viewLift >= 1.15
? `阅读/播放约为均值 ${viewLift.toFixed(1)} 倍`
: '综合排序领先';
const cvrValue = typeof topPost.view_to_interaction_rate === 'number' ? topPost.view_to_interaction_rate : null;
const cvr = cvrValue === null || !topPost.views
? 'CVR 待补阅读/播放'
: `CVR ${(cvrValue * 100).toFixed(2)}%`;
const title = cleanReportLabel(topPost.title || '未命名内容');
const conciseTitle = title.length > 20 ? `${title.slice(0, 20)}...` : title;
const angle = getPlatformContentAngle(row.platform, topPost);
return [
{
label: '平台内容分析',
text: `《${conciseTitle}》是本平台高表现样本,${liftText}。${angle} 当前互动由${leadingLabel}主导(${formatCompactNumber(leadingValue)}),说明内容先完成了兴趣确认。下一轮建议沿用这篇的主题入口与表达颗粒度,继续测试封面/标题首句,并补强评论引导。${cvr}。`,
},
];
};
const ReportEmptyState = ({ title, body }) => (
{title}
{body}
);
const ReportRegionChart = ({ rows }) => {
const maxViews = Math.max(...rows.map(row => row.views), 1);
return (
{rows.map(row => (
{row.label}
{formatCompactNumber(row.views)}
{row.followerGrowth === null ? '涨粉待基线' : `涨粉 +${formatCompactNumber(row.followerGrowth)}`}
))}
);
};
const REPORT_CHART_COLORS = ['var(--accent)', 'var(--info)', 'var(--warn)', 'var(--danger)', 'var(--accent-2)', 'var(--ink-3)', 'var(--hairline-2)'];
const getReportChartColor = (index = 0) => REPORT_CHART_COLORS[index % REPORT_CHART_COLORS.length];
const getTrendPlatformNames = (rows = []) => Array.from(new Set(rows.flatMap(row => Object.keys(row.platforms || {}))))
.sort((a, b) => compareReportPlatformOrder(a, b));
const getTrendPlatformMetric = (row = {}, platform = '', key = 'total') => {
const value = row.platforms?.[platform];
if (typeof value === 'number') return key === 'total' || key === 'views' ? socialNumber(value) : 0;
if (!value || typeof value !== 'object') return 0;
if (key === 'total') return socialNumber(value.total ?? value.views ?? value.followers ?? value.delta);
return socialNumber(value[key]);
};
const getTrendPlatformsByRegion = (rows = [], region = '') => getTrendPlatformNames(rows)
.filter(platform => !region || getSocialRegion(platform) === region);
const ReportFollowerGrowthTrend = ({ rows = [], briefRows = [], compact = false, region = '' }) => {
if (!rows.length) {
return ;
}
const platforms = getTrendPlatformsByRegion(rows, region);
const scopedRows = rows.map(row => {
const platformTotal = platforms.reduce((sum, platform) => sum + getTrendPlatformMetric(row, platform, 'total'), 0);
return {
...row,
scopedTotal: platformTotal || socialNumber(region ? row[region] : row.total),
};
});
if (region && !platforms.length && !scopedRows.some(row => row.scopedTotal)) {
return ;
}
const maxValue = Math.max(...scopedRows.map(row => row.scopedTotal || 0), 1);
const axisStep = 100;
const axisMax = roundChartMax(maxValue, axisStep);
const axisTicks = buildChartTicks(maxValue, axisStep);
const total = scopedRows.reduce((sum, row) => sum + socialNumber(row.scopedTotal), 0);
const scopeLabel = region ? reportRegionLabel(region) : '全平台';
const dateLabel = (date = '') => date.slice(5).replace('-', '/');
const platformTotals = platforms.map((platform, index) => ({
platform,
total: rows.reduce((sum, row) => sum + getTrendPlatformMetric(row, platform, 'total'), 0),
color: getReportChartColor(index),
}));
const briefRowsByDate = new Map(filterInteractionRowsByRegion(briefRows, region).map(row => [row.date, row.briefs || []]));
return (
粉丝每日增长趋势 · {scopeLabel}
{rows[0]?.date} - {rows[rows.length - 1]?.date} · y 轴每格 +{axisStep}
{platformTotals.map(item => (
{reportPlatformDisplayName(item.platform)} +{formatCompactNumber(item.total)}
))}
合计 +{formatCompactNumber(total)}
{axisTicks.map(value => {value ? `+${formatCompactNumber(value)}` : '0'})}
{scopedRows.map(row => (
{(briefRowsByDate.get(row.date) || []).length > 0 && (
{(briefRowsByDate.get(row.date) || []).slice(0, 3).map(brief => (
{deckText(brief.title, 18)}
))}
)}
{platforms.map((platform, index) => {
const value = getTrendPlatformMetric(row, platform, 'total');
if (!value) return null;
return (
);
})}
+{formatCompactNumber(row.scopedTotal || 0)}
{dateLabel(row.date)}
))}
);
};
const filterInteractionRowsByRegion = (rows = [], region = '') => {
if (!region) return rows;
return rows.map(row => {
const platformEntries = Object.entries(row.platforms || {})
.filter(([platform]) => getSocialRegion(platform) === region);
const scopedPlatforms = Object.fromEntries(platformEntries);
const metrics = platformEntries.reduce((sum, [, value]) => {
if (typeof value === 'number') {
sum.views += socialNumber(value);
return sum;
}
sum.views += socialNumber(value?.views);
sum.likes += socialNumber(value?.likes);
sum.comments += socialNumber(value?.comments);
sum.shares += socialNumber(value?.shares);
sum.interaction += socialNumber(value?.interaction) || socialNumber(value?.likes) + socialNumber(value?.comments) + socialNumber(value?.shares);
return sum;
}, { views: 0, likes: 0, comments: 0, shares: 0, interaction: 0 });
const briefRows = (row.briefs || []).filter(brief => (brief.platforms || []).some(platform => getSocialRegion(platform) === region));
return {
...row,
...metrics,
platforms: scopedPlatforms,
briefs: briefRows,
};
});
};
const ReportInteractionTrend = ({ rows = [], region = '' }) => {
if (!rows.length) {
return ;
}
const scopedRows = filterInteractionRowsByRegion(rows, region);
const scopeLabel = region ? reportRegionLabel(region) : '全平台';
if (region && !scopedRows.some(row => row.views || row.likes || row.comments || row.shares || row.interaction || (row.briefs || []).length)) {
return ;
}
const maxViews = Math.max(...scopedRows.map(row => row.views || 0), 1);
const axisStep = 10000;
const axisMax = roundChartMax(maxViews, axisStep);
const axisTicks = buildChartTicks(maxViews, axisStep);
const maxInteraction = Math.max(...scopedRows.map(row => Math.max(row.likes || 0, row.comments || 0, row.shares || 0, row.interaction || 0)), 1);
const total = scopedRows.reduce((sum, row) => ({
views: sum.views + socialNumber(row.views),
likes: sum.likes + socialNumber(row.likes),
comments: sum.comments + socialNumber(row.comments),
shares: sum.shares + socialNumber(row.shares),
}), { views: 0, likes: 0, comments: 0, shares: 0 });
const dateLabel = (date = '') => date.slice(5).replace('-', '/');
return (
互动数据趋势与发布节点 · {scopeLabel}
{rows[0]?.date} - {rows[rows.length - 1]?.date} · x 轴按天展示,y 轴每格 1 万
浏览/播放 {formatCompactNumber(total.views)}
点赞 {formatCompactNumber(total.likes)}
评论 {formatCompactNumber(total.comments)}
转发 {formatCompactNumber(total.shares)}
{axisTicks.map(value => {formatCompactNumber(value)})}
{scopedRows.map(row => (
{(row.briefs || []).slice(0, 3).map(brief => (
{deckText(brief.title, 18)}
))}
{dateLabel(row.date)}
))}
浏览/播放
点赞
评论
转发
);
};
const ReportPlatformTopPosts = ({ rows = [] }) => (
平台内高表现发布内容
{rows.length > 3 ? `Top 3 / 共 ${rows.length} 条` : '按阅读/播放与互动综合排序'}
{rows.length ? (
{rows.slice(0, 3).map((post, index) => (
{index + 1}
{post.content_type || '内容'}
阅读/播放 {formatCompactNumber(post.views || 0)}
点赞 {formatCompactNumber(post.likes || 0)}
评论 {formatCompactNumber(post.comments || 0)}
转发 {formatCompactNumber(post.shares || 0)}
))}
) :
}
);
const ReportPlatformAggregateTable = ({ rows = [] }) => (
rows.length ? (
| 平台 Platform |
账号 Account |
粉丝 Followers |
发布 Posts |
阅读/播放 Views |
点赞 Likes |
评论 Comments |
转发 Shares |
互动转化 CVR |
{rows.map(row => (
|
{reportPlatformDisplayName(row.platform)}
|
{String(row.accounts || 0)} |
{formatCompactNumber(row.followers || 0)} |
{String(row.posts || 0)} |
{formatCompactNumber(row.normalized?.views || row.views || 0)} |
{formatCompactNumber(row.likes || 0)} |
{formatCompactNumber(row.comments || 0)} |
{formatCompactNumber(row.shares || 0)} |
{`${((row.engagementRate || 0) * 100).toFixed(2)}%`} |
))}
) :
);
const ReportPlatformBreakdown = ({ rows }) => (
{rows.map(row => (
{reportPlatformDisplayName(row.platform)}
{reportRegionLabel(row.region)} · {row.accountNames.slice(0, 2).join(' / ') || '暂无账号名'}
阅读/播放口径:{row.metricMapping.viewSource} / {row.metricMapping.viewLabel}
互动公式:{row.metricMapping.interactionFormula}
可比性:{row.dataQuality.comparability}
{getPlatformInsightCards(row).map(card => (
{card.label}{card.text}
))}
))}
);
const ReportContentComparison = ({ rows }) => (
{rows.map(row => (
{row.platformCount} 个平台 · {row.platforms.join(' / ')} · {row.regions.map(reportRegionLabel).join(' + ')}
{row.bestPlatform ? `最强平台:${row.bestPlatform.platform}` : '待比较'}
平台
代表发布
阅读/播放
点赞
评论
转发
转化率
平台汇总
{row.platformRows.length}平台
{formatCompactNumber(row.views)}
{formatCompactNumber(row.likes)}
{formatCompactNumber(row.comments)}
{formatCompactNumber(row.shares)}
{row.view_to_interaction_rate === null ? '-' : `${(row.view_to_interaction_rate * 100).toFixed(2)}%`}
{row.platformRows.slice(0, 4).map(platform => (
{reportPlatformDisplayName(platform.platform)}
{platform.posts}
{formatCompactNumber(platform.views)}
{formatCompactNumber(platform.likes)}
{formatCompactNumber(platform.comments)}
{formatCompactNumber(platform.shares)}
{platform.view_to_interaction_rate === null ? '-' : `${(platform.view_to_interaction_rate * 100).toFixed(2)}%`}
))}
{row.diagnosis}
))}
);
const ReportCommentPlatformBreakdown = ({ rows, translations = {}, translationScope = '' }) => {
const regions = ['domestic', 'overseas'];
return (
{regions.map(region => {
const regionRows = rows
.filter(row => row.region === region)
.sort((a, b) => b.total - a.total || b.posts - a.posts || a.platform.localeCompare(b.platform, 'zh-CN'));
if (!regionRows.length) return null;
return (
{reportRegionLabel(region)}平台评论反馈
{regionRows.map(row => (
{row.platform}
{row.posts} 条发布 · {row.total} 条评论
{row.total ? `风险 ${row.risk}` : '暂无评论'}
{row.topKeywords.length ? row.topKeywords.map(item => {item.keyword} × {item.count}) : 暂无关注问题}
{row.insight}
{row.samples.length ? (
{row.samples.map(comment => (
{comment.user_name || comment.platform || '匿名'}
{comment.source_post_type || '内容'} · {comment.comment_level === 2 ? '二级评论' : '一级评论'} · {monitorActionLabel?.[comment.action_status] || comment.action_status}
{comment.user_concern_short || comment.user_concern_label || comment.keyword || comment.risk_tag || '其他'}{comment.text}
{translationScope && translations[socialTranslationKey(translationScope, comment.comment_id)]?.translation && 译:{translations[socialTranslationKey(translationScope, comment.comment_id)].translation}}
))}
) : }
))}
);
})}
);
};
const ReportContentDiagnosis = ({ diagnosis }) => {
if (!diagnosis) return null;
return (
内容诊断判断
{diagnosis.strategicJudgement}
目前内容写了什么
{diagnosis.currentContentSummary.map(item =>
{item}
)}
语气资产分层
{diagnosis.toneLayering.map(layer => (
))}
长期栏目矩阵
{diagnosis.columnMatrix.map(item => (
{item.column}
{item.goal}
{item.writingRule}
))}
{diagnosis.expertReviews?.map(item => (
{item.title}
{item.level}
{item.role}
{item.judgement}
{item.action}
))}
下一阶段新增方向
{diagnosis.nextDirections.map(item => (
{item.priority}
{item.direction}
{item.reason}
{item.suggestedFormats.join(' / ')}
))}
待验证假设
{diagnosis.assumptionsToValidate.map(item =>
{item}
)}
);
};
const ReportContentTable = ({ rows, translations = {} }) => (
| 内容 | 平台 | 阅读/播放 | 点赞 | 评论 | 转发 |
{rows.map(row => (
{translations[socialTranslationKey('report-post', row.post_id)]?.translation && 译:{translations[socialTranslationKey('report-post', row.post_id)].translation}}{row.creator_name || row.creator_handle || ''} |
{row.platform} |
{formatCompactNumber(row.views)} |
{formatCompactNumber(row.likes)} |
{formatCompactNumber(row.comments)} |
{formatCompactNumber(row.shares)} |
))}
);
const ReportTopicList = ({ topics }) => (
{topics.map(topic => (
#{topic.keyword}
{topic.platforms.join(' / ') || topic.source}
{topic.frequency} 次 · 阅读/播放 {formatCompactNumber(topic.views)} · 赞 {formatCompactNumber(topic.likes)} · 评 {formatCompactNumber(topic.comments)} · 转 {formatCompactNumber(topic.shares)}
))}
);
// ========== 09 · REVIEW / LEARNING ==========
const reviewApiUrl = (pathname) => (window.location.protocol === 'file:' ? `http://localhost:4173${pathname}` : pathname);
const reviewPostIds = (post = {}) => [post.post_id, post.content_id, post.id].filter(Boolean);
const reviewPostBriefIds = (post = {}) => [
post.primaryMasterBriefId,
post.briefMapping?.primaryMasterBriefId,
...(post.masterBriefIds || []),
...(post.briefMapping?.masterBriefIds || []),
].filter(Boolean);
const reviewPostViews = (post = {}) => Math.max(getPostContentViews(post), socialNumber(post.organic_impressions));
const reviewPostComments = (post = {}, commentsByPost = new Map()) => Math.max(pickNativeMetric(post, 'comments'), (commentsByPost.get(post.post_id) || []).length);
const reviewPostInteraction = (post = {}, commentsByPost = new Map()) => pickNativeMetric(post, 'likes') + reviewPostComments(post, commentsByPost) + pickNativeMetric(post, 'shares') + pickNativeMetric(post, 'reposts') + pickNativeMetric(post, 'saves');
const reviewMatchesPost = (initiative = {}, post = {}) => {
const ids = new Set(initiative.assetPostIds || []);
if (reviewPostIds(post).some(id => ids.has(id))) return true;
if (ids.size) return false;
const briefIds = new Set(reviewPostBriefIds(post));
if (initiative.id && briefIds.has(initiative.id)) return true;
const parentIds = [post.parentCampaignId, post.campaignId, post.briefMapping?.parentCampaignId].filter(Boolean);
if (initiative.id && parentIds.includes(initiative.id)) return true;
if (briefIds.size || parentIds.length) return false;
const labels = initiative.assetCampaignLabels?.length ? initiative.assetCampaignLabels : [initiative.name];
return labels.some(label => label && [post.campaign, post.product, post.content_type, post.title, post.caption].join(' ').includes(label));
};
const reviewGroupCount = (rows = [], getKey = item => item) => {
const map = new Map();
rows.forEach(row => {
const key = getKey(row);
if (!key) return;
map.set(key, (map.get(key) || 0) + 1);
});
return [...map.entries()].sort((a, b) => b[1] - a[1]);
};
const reviewLearningItem = (type, text, source = '') => ({ type, text, source });
const buildReviewModel = ({ initiatives = [], social = {}, organicHeat = {} }) => {
const posts = social.posts || [];
const comments = social.comments || [];
const commentsByPost = new Map();
comments.forEach(comment => {
const list = commentsByPost.get(comment.post_id) || [];
list.push(comment);
commentsByPost.set(comment.post_id, list);
});
const activeInitiative = initiatives.find(item => item.status === 'active' && posts.some(post => reviewMatchesPost(item, post)))
|| initiatives.find(item => posts.some(post => reviewMatchesPost(item, post)))
|| initiatives[0]
|| null;
const linkedPosts = activeInitiative ? posts.filter(post => reviewMatchesPost(activeInitiative, post)) : posts;
const ownedPosts = linkedPosts.filter(post => !isExternalSocialPost(post));
const externalPosts = linkedPosts.filter(isExternalSocialPost);
const linkedPostIds = new Set(linkedPosts.map(post => post.post_id));
const linkedComments = comments.filter(comment => linkedPostIds.has(comment.post_id));
const riskComments = linkedComments.filter(isRiskComment);
const totalViews = linkedPosts.reduce((sum, post) => sum + reviewPostViews(post), 0);
const totalInteraction = linkedPosts.reduce((sum, post) => sum + reviewPostInteraction(post, commentsByPost), 0);
const engagementRate = totalViews ? totalInteraction / totalViews : null;
const avgSentiment = linkedComments.length
? linkedComments.reduce((sum, item) => sum + socialNumber(item.sentiment), 0) / linkedComments.length
: null;
const topPosts = [...linkedPosts]
.sort((a, b) => reviewPostInteraction(b, commentsByPost) - reviewPostInteraction(a, commentsByPost) || reviewPostViews(b) - reviewPostViews(a))
.slice(0, 6);
const platformRows = reviewGroupCount(linkedPosts, post => post.platform).map(([platform]) => {
const platformPosts = linkedPosts.filter(post => post.platform === platform);
const views = platformPosts.reduce((sum, post) => sum + reviewPostViews(post), 0);
const interaction = platformPosts.reduce((sum, post) => sum + reviewPostInteraction(post, commentsByPost), 0);
return { platform, posts: platformPosts.length, views, interaction, engagementRate: views ? interaction / views : null };
}).sort((a, b) => b.interaction - a.interaction || b.views - a.views);
const creatorRows = reviewGroupCount(externalPosts, post => post.creator_handle || post.creator_name || post.account_name || post.author_name).map(([creator]) => {
const creatorPosts = externalPosts.filter(post => (post.creator_handle || post.creator_name || post.account_name || post.author_name) === creator);
const interaction = creatorPosts.reduce((sum, post) => sum + reviewPostInteraction(post, commentsByPost), 0);
const views = creatorPosts.reduce((sum, post) => sum + reviewPostViews(post), 0);
const topPost = [...creatorPosts].sort((a, b) => reviewPostInteraction(b, commentsByPost) - reviewPostInteraction(a, commentsByPost))[0];
return { creator, platform: topPost?.platform || '', posts: creatorPosts.length, views, interaction, topTitle: topPost?.title || topPost?.caption || '未命名内容' };
}).sort((a, b) => b.interaction - a.interaction || b.views - a.views).slice(0, 8);
const heatScores = organicHeat.scores || [];
const heatPatterns = organicHeat.patterns || [];
const brandLearnings = [
riskComments.length ? reviewLearningItem('warn', `本轮有 ${riskComments.length} 条评论进入风险/复核视图,优先沉淀高频风险词和回应口径。`, 'Social Sync') : null,
...reviewGroupCount(riskComments, item => item.risk_tag || item.keyword || item.pr_level).slice(0, 2).map(([tag, count]) => reviewLearningItem('warn', `风险信号「${tag}」出现 ${count} 次,需要人工确认是否写入品牌边界。`, 'Comment Review')),
social.commentReview?.lastReviewedAt ? reviewLearningItem('ok', `评论批量复核最近完成于 ${formatBeijingTime(social.commentReview.lastReviewedAt, social.commentReview.lastReviewedAt)}。`, 'Comment Review') : null,
].filter(Boolean);
const creatorLearnings = creatorRows.slice(0, 3).map(row => reviewLearningItem('new', `${row.creator} 在 ${row.platform || '未标记平台'} 有 ${row.posts} 条外部内容,互动 ${formatCompactNumber(row.interaction)};需人工判断是否进入达人画像。`, 'External Voice'));
const contentLearnings = [
...heatPatterns.slice(0, 3).map(pattern => reviewLearningItem('ok', `${pattern.title || pattern.pattern_label || '内容模式'}:${pattern.summary || '已从 Organic Heat 识别为可复盘模式。'}`, 'Organic Heat')),
...heatScores.slice(0, 2).map(score => reviewLearningItem('new', `内容「${cleanReportLabel(score.title || score.content_id || '').slice(0, 28) || score.content_id}」Organic Heat ${score.organic_heat_score ?? '-'}${score.benchmark_percentile ? `,benchmark P${Math.round(score.benchmark_percentile)}` : ''}。`, 'Organic Heat')),
];
const strategyLearnings = [
activeInitiative ? reviewLearningItem('ok', `复盘对象:${activeInitiative.name};已关联 ${linkedPosts.length} 条内容、${linkedComments.length} 条评论。`, 'Campaign State') : null,
platformRows[0] ? reviewLearningItem('new', `${platformRows[0].platform} 当前互动贡献最高:${formatCompactNumber(platformRows[0].interaction)} 互动,${formatCompactNumber(platformRows[0].views)} 阅读/播放。`, 'Performance Attribution') : null,
engagementRate !== null ? reviewLearningItem('ok', `本轮互动转化 CVR ${(engagementRate * 100).toFixed(2)}%,可作为下一轮策略推演的真实基线。`, 'Performance Attribution') : null,
].filter(Boolean);
const writebackCandidates = [
...brandLearnings.map(item => `[ 品牌 ] ${item.text}`),
...creatorLearnings.map(item => `[ 达人 ] ${item.text}`),
...contentLearnings.map(item => `[ 内容 ] ${item.text}`),
...strategyLearnings.map(item => `[ 策略 ] ${item.text}`),
].slice(0, 12);
return {
activeInitiative,
linkedPosts,
linkedComments,
ownedPosts,
externalPosts,
totals: { posts: linkedPosts.length, views: totalViews, interaction: totalInteraction, comments: linkedComments.length, riskComments: riskComments.length, avgSentiment, engagementRate },
topPosts,
platformRows,
creatorRows,
learningGroups: [
{ title: '品牌学习', intro: '客户真正喜欢什么表达、反复否掉什么表达、品牌边界在哪里被强化。', findings: brandLearnings },
{ title: '达人学习', intro: '哪类达人适合哪种命题、执行是否稳定、评论区质量是否值得沉淀。', findings: creatorLearnings },
{ title: '内容学习', intro: '标题、钩子、脚本结构和 CTA 是否被 Organic Heat 与 benchmark 支持。', findings: contentLearnings },
{ title: '策略学习', intro: '命题、平台、人群组合和节奏是否形成下一轮策略规则。', findings: strategyLearnings },
],
writebackCandidates,
};
};
const normalizeFlywheelReviewModel = (review = {}) => {
const summary = review.campaignReviewSummary || {};
const assets = review.learningAssets || {};
const writeBackQueue = review.writeBackQueue || [];
return {
raw: review,
activeInitiative: review.initiative || null,
linkedPosts: review.performanceAttribution?.topPosts || [],
linkedComments: [],
ownedPosts: { length: summary.ownedPosts || 0 },
externalPosts: { length: summary.externalPosts || 0 },
totals: {
posts: summary.posts || 0,
ownedPosts: summary.ownedPosts || 0,
externalPosts: summary.externalPosts || 0,
views: summary.views || 0,
interaction: summary.interaction || 0,
comments: summary.comments || 0,
riskComments: summary.riskComments || 0,
avgSentiment: summary.avgSentiment ?? null,
engagementRate: summary.engagementRate ?? null,
},
topPosts: review.performanceAttribution?.topPosts || [],
platformRows: review.performanceAttribution?.platforms || [],
creatorRows: review.performanceAttribution?.creators || [],
learningGroups: [
{ title: '品牌学习', intro: '客户真正喜欢什么表达、反复否掉什么表达、品牌边界在哪里被强化。', findings: assets.brand || [] },
{ title: '达人学习', intro: '哪类达人适合哪种命题、执行是否稳定、评论区质量是否值得沉淀。', findings: assets.creator || [] },
{ title: '内容学习', intro: '标题、钩子、脚本结构和 CTA 是否被 Organic Heat 与 benchmark 支持。', findings: assets.content || [] },
{ title: '策略学习', intro: '命题、平台、人群组合和节奏是否形成下一轮策略规则。', findings: assets.strategy || [] },
],
writebackCandidates: writeBackQueue.map(item => item.label || item.item?.text).filter(Boolean),
writeBackQueue,
upstream: review.upstream || {},
downstreamHandoffs: review.downstreamHandoffs || [],
strategyRecommendations: review.strategyUpdateRecommendation || [],
contentPatternUpdate: review.contentPatternUpdate || {},
};
};
const Review = ({ onNav, workspaceContext }) => {
const brandStoreReady = Boolean(window.BrandMemoryStore?.useBrandMemoryProfiles);
const brandState = brandStoreReady ? window.BrandMemoryStore.useBrandMemoryProfiles() : { brands: [], setBrands: () => {}, ready: false };
const brandId = workspaceContext?.brandId || BRAND?.id || 'montx';
const brand = brandStoreReady ? window.BrandMemoryStore.getBrandProfile(brandId, brandState.brands) : null;
const [state, setState] = React.useState({ initiatives: [], social: { posts: [], comments: [] }, organicHeat: {}, flywheel: null, loading: true, error: '' });
const [writeStatus, setWriteStatus] = React.useState('');
const loadReviewData = React.useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: '' }));
try {
const [flywheelRes, initiativesRes, socialRes, heatRes] = await Promise.all([
fetch(reviewApiUrl('/api/review/flywheel')),
fetch(reviewApiUrl('/api/initiatives')),
fetch(reviewApiUrl('/api/social/sync/status')),
fetch(reviewApiUrl('/api/market/organic-heat?source=owned&limit=120')),
]);
const [flywheelData, initiativesData, socialData, heatData] = await Promise.all([flywheelRes.json(), initiativesRes.json(), socialRes.json(), heatRes.json()]);
setState({
initiatives: initiativesData.initiatives || initiativesData.campaigns || [],
social: socialData || { posts: [], comments: [] },
organicHeat: heatData || {},
flywheel: flywheelRes.ok ? flywheelData : null,
loading: false,
error: flywheelRes.ok ? '' : (flywheelData.error || '复盘闭环接口未返回'),
});
} catch (error) {
setState(prev => ({ ...prev, loading: false, error: error.message || '复盘数据读取失败' }));
}
}, []);
React.useEffect(() => { loadReviewData(); }, [loadReviewData]);
const model = React.useMemo(
() => state.flywheel ? normalizeFlywheelReviewModel(state.flywheel) : buildReviewModel(state),
[state.flywheel, state.initiatives, state.social, state.organicHeat]
);
const hasEvidence = model.linkedPosts.length || model.linkedComments.length || model.writebackCandidates.length;
const writeBackToSystem = async () => {
if (!model.raw?.reviewId || !model.activeInitiative?.id) {
setWriteStatus('缺少复盘记录,无法写回');
return;
}
setWriteStatus('writing');
try {
const response = await fetch(reviewApiUrl('/api/review/flywheel/writeback'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ initiativeId: model.activeInitiative.id, review: model.raw, targets: ['initiative', 'strategy_handoff'] }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '写回失败');
setWriteStatus(`已写回策略对象,并生成 Strategy handoff:${data.strategyHandoff?.handoff_id || 'ready'}`);
loadReviewData();
} catch (error) {
setWriteStatus(error.message || '写回失败');
}
};
const writeBackToBrandMemory = () => {
if (!brandStoreReady || !brand || !model.writeBackQueue?.length) {
setWriteStatus('品牌记忆未就绪或暂无可写回项');
return;
}
const stampedAt = new Date().toLocaleString('zh-CN', { hour12: false });
const learning = {
id: `review-learning-${model.raw?.reviewId || Date.now()}`,
status: 'pending',
type: 'learning-flywheel-review',
source: 'review-flywheel',
title: `${model.activeInitiative?.name || 'Workspace'} 复盘学习项`,
text: `复盘沉淀 ${model.writeBackQueue.length} 条待确认学习项,覆盖品牌、达人、内容和策略。`,
createdAt: stampedAt,
reviewId: model.raw?.reviewId || '',
writeBackQueue: model.writeBackQueue,
evidence: model.raw?.campaignReviewSummary || {},
};
brandState.setBrands(prev => prev.map(item => item.id === brand.id
? window.BrandMemoryStore.normalizeBrandProfile({
...item,
learnings: [...(item.learnings || []).filter(row => row.id !== learning.id), learning],
updatedAt: stampedAt,
})
: item
));
setWriteStatus('已写入 Brand Memory 待确认学习项');
};
return (
复盘 · 学习闭环写回长期记忆
根据 PRD,复盘只吸收真实结果,并沉淀品牌、达人、内容和策略四类学习资产。
{state.error &&
}
{writeStatus &&
{writeStatus === 'writing' ? '正在写回学习闭环...' : writeStatus}
}
Campaign Review Summary · {model.activeInitiative?.name || '未选择复盘对象'}
{state.loading ? '读取中' : hasEvidence ? '真实同步数据' : '待数据沉淀'}
{model.learningGroups.map(group => )}
长期记忆 · 待人工确认写回
{model.writebackCandidates.length}
{model.writebackCandidates.length
? model.writebackCandidates.map((item, index) => )
: }
);
};
const ReviewFlowCard = ({ upstream = {}, downstream = [], recommendations = [] }) => {
const upstreamRows = [
['Brand Memory', upstream.brandMemory?.status || 'client-local', upstream.brandMemory?.requiredForWriteback ? '写回前需人工确认' : ''],
['Market Sensing', upstream.marketSensing?.status || 'waiting', `${upstream.marketSensing?.signals || 0} signals · ${upstream.marketSensing?.reviewQueue || 0} reviews`],
['Strategy / Thesis', upstream.strategy?.status || 'waiting', upstream.strategy?.thesis?.title || '策略母题待读取'],
['Brief', upstream.brief?.status || 'waiting', upstream.brief?.masterBrief?.title || 'Master Brief 待生成'],
['Publishing Monitor', upstream.publishingMonitor?.status || 'waiting-sync', `${upstream.publishingMonitor?.posts || 0} posts · ${upstream.publishingMonitor?.comments || 0} comments`],
['Organic Heat', upstream.organicHeat?.status || 'waiting-data', `${upstream.organicHeat?.aggregate?.count || 0} scored contents`],
];
return (
上下游闭环 · PRD Information Flow
Step 1-9
上游输入
{upstreamRows.map(row => (
{row[0]}
{row[1]}
{row[2]}
))}
复盘输出
{['Campaign Review Summary', 'Performance Attribution', 'Creator Learning Update', 'Content Pattern Update', 'Strategy Update Recommendation'].map(item => (
{item}
))}
下游动作
{downstream.length ? downstream.map(item => (
{item.title}
{item.target_module} · {item.status}
)) :
}
{recommendations.slice(0, 2).map(item => (
建议:{item.title}
))}
);
};
const ResultKPI = ({ label, actual, meta, tone }) => {
const style = tone === 'warn'
? { borderColor: 'color-mix(in oklch, var(--warn) 25%, var(--hairline))' }
: tone === 'ok'
? { borderColor: 'var(--accent-line)', background: 'var(--accent-tint)' }
: {};
return (
);
};
const LearningCard = ({ title, intro, findings = [] }) => {
const icons = { ok: '✓', warn: '⚠', new: '✦' };
const colors = { ok: 'var(--accent)', warn: 'var(--warn)', new: 'var(--info)' };
return (
{title}
{findings.length || '待沉淀'}
{intro}
{findings.length ? findings.map((finding, index) => (
{icons[finding.type] || '•'}
{finding.text}{finding.source}
)) :
}
);
};
const ReviewEmptyState = ({ text }) => (
{text}
);
const ReviewAttributionCard = ({ title, rows = [], topPosts = [] }) => (
{title}
{rows.length}
{rows.length ? (
| 平台 | 内容 | 阅读/播放 | 互动 | CVR |
{rows.slice(0, 6).map(row => (
| {row.platform} |
{row.posts} |
{formatCompactNumber(row.views)} |
{formatCompactNumber(row.interaction)} |
{row.engagementRate === null ? '-' : `${(row.engagementRate * 100).toFixed(2)}%`} |
))}
) :
}
{topPosts.length ? (
{topPosts.slice(0, 3).map(post => 代表内容:{post.platform} · {cleanReportLabel(post.title || post.caption || '未命名内容').slice(0, 42)})}
) : null}
);
const CreatorLearningCard = ({ rows = [] }) => (
Creator Learning Update · 外部声量
{rows.length}
{rows.length ? (
| 来源/达人 | 平台 | 内容 | 阅读/播放 | 互动 |
{rows.map(row => (
| {row.creator} |
{row.platform || '-'} |
{row.posts} |
{formatCompactNumber(row.views)} |
{formatCompactNumber(row.interaction)} |
))}
) :
}
);
const MemoryCandidate = ({ text }) => (
{text}
);
Object.assign(window, { Brief, Content, Monitor, Reports, Review });