Files
memind/excel-report-html.mjs
john b1577a16e9
Memind CI / Test, build, and release guards (push) Failing after 12m3s
fix: harden release gate and page delivery
2026-07-26 14:32:01 +08:00

875 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const REPORT_ENHANCED_STYLE = `:root {
--primary: #1e3a5f;
--primary-light: #2d5a8a;
--accent: #e8b339;
--accent2: #3b82f6;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--bg: #f0f4f8;
--card-bg: #ffffff;
--text: #1e293b;
--text-secondary: #64748b;
--border: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
--radius: 16px;
--radius-sm: 10px;
}
*{box-sizing:border-box;margin:0;padding:0}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.wrap {max-width:1200px;margin:auto;padding:24px 16px 48px}
.hero {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
border-radius: var(--radius);
padding: 40px 32px;
margin-bottom: 24px;
color: white;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 400px;
height: 400px;
background: rgba(255,255,255,0.05);
border-radius: 50%;
}
.hero h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
position: relative;
}
.hero .subtitle {
font-size: 15px;
opacity: 0.85;
margin-bottom: 24px;
position: relative;
}
.hero .source {
font-size: 13px;
opacity: 0.7;
position: relative;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card-bg);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
position: relative;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-card .icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 12px;
}
.stat-card.primary .icon {background: #dbeafe;}
.stat-card.success .icon {background: #d1fae5;}
.stat-card.warning .icon {background: #fef3c7;}
.stat-card.info .icon {background: #e0e7ff;}
.stat-card .label {
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
color: var(--primary);
}
.stat-card .trend {
font-size: 12px;
margin-top: 6px;
display: flex;
align-items: center;
gap: 4px;
}
.trend.up {color: var(--success);}
.trend.down {color: var(--danger);}
.section {
background: var(--card-bg);
border-radius: var(--radius);
padding: 28px;
margin-bottom: 20px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.section-title {
font-size: 18px;
font-weight: 700;
color: var(--primary);
display: flex;
align-items: center;
gap: 10px;
}
.section-title .badge {
background: var(--accent);
color: var(--primary);
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
font-weight: 600;
}
.section-subtitle {
font-size: 13px;
color: var(--text-secondary);
}
.chart-container {
background: #fafbfc;
border-radius: var(--radius-sm);
padding: 20px;
margin-bottom: 20px;
border: 1px solid var(--border);
}
.chart-container img {
width: 100%;
height: auto;
border-radius: 8px;
}
.chart-bar {
display: flex;
align-items: flex-end;
gap: 8px;
height: 200px;
padding: 16px 8px 32px;
position: relative;
}
.chart-bar::after {
content: '';
position: absolute;
bottom: 32px;
left: 8px;
right: 8px;
height: 1px;
background: var(--border);
}
.bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
}
.bar-fill {
width: 100%;
max-width: 48px;
border-radius: 6px 6px 0 0;
transition: height 0.8s ease-out;
position: relative;
}
.bar-fill:hover {
filter: brightness(1.1);
}
.bar-fill .tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--primary);
color: white;
font-size: 11px;
padding: 4px 8px;
border-radius: 6px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.bar-fill:hover .tooltip {
opacity: 1;
}
.bar-label {
font-size: 11px;
color: var(--text-secondary);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.table-wrap {overflow:auto;margin-top:12px}
table {width:100%;border-collapse:separate;border-spacing:0;font-size:14px}
thead th {
background: var(--primary);
color: white;
font-weight: 600;
padding: 12px 14px;
text-align: right;
white-space: nowrap;
font-size: 13px;
}
thead th:first-child {text-align:left;border-radius:10px 0 0 0}
thead th:last-child {border-radius:0 10px 0 0}
tbody td {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
text-align: right;
white-space: nowrap;
}
tbody td:first-child {text-align:left;font-weight:500}
tbody tr:hover td {background:#f8fafc}
tbody tr:last-child td:first-child {border-radius:0 0 0 10px}
tbody tr:last-child td:last-child {border-radius:0 0 10px 0}
.rank-1 {color: var(--accent); font-weight: 700;}
.rank-2 {color: var(--text-secondary); font-weight: 600;}
.rank-3 {color: #b45309; font-weight: 600;}
.cell-bar {
display: flex;
align-items: center;
gap: 8px;
}
.cell-bar .track {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
min-width: 60px;
}
.cell-bar .fill {
height: 100%;
border-radius: 3px;
transition: width 0.8s ease-out;
}
.insights {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-top: 20px;
}
.insight-card {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: var(--radius-sm);
padding: 20px;
border-left: 4px solid var(--accent2);
}
.insight-card h4 {
font-size: 14px;
color: var(--primary);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.insight-card p {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.7;
}
footer {
text-align: center;
padding: 32px 16px;
color: var(--text-secondary);
font-size: 13px;
}
@media (max-width: 640px) {
.hero {padding: 28px 20px}
.hero h1 {font-size: 22px}
.stats-grid {grid-template-columns: repeat(2, 1fr)}
.stat-card .value {font-size: 22px}
.section {padding: 20px}
.chart-bar {height: 160px}
}
@keyframes fadeInUp {
from {opacity:0;transform:translateY(20px)}
to {opacity:1;transform:translateY(0)}
}
.stat-card, .section, .insight-card {
animation: fadeInUp 0.5s ease-out both;
}
.stat-card:nth-child(1) {animation-delay:0.05s}
.stat-card:nth-child(2) {animation-delay:0.1s}
.stat-card:nth-child(3) {animation-delay:0.15s}
.stat-card:nth-child(4) {animation-delay:0.2s}`;
const SECTION_LAYOUT = {
区域: {
emoji: '🗺️',
badge: (count) => `${count}大区域`,
subtitle: '各区域销售额、订单量与单均表现',
chartId: 'regionChart',
tableBarColor(index) {
return index <= 4 ? 'var(--accent2)' : 'var(--danger)';
},
chartColor(index) {
if (index <= 3) return '#3b82f6';
if (index === 4) return '#f59e0b';
return '#ef4444';
},
},
城市: {
emoji: '🏙️',
badge: (count) => `TOP ${count}`,
subtitle: '各城市销售额排名与表现',
chartId: 'cityChart',
tableBarColor(index) {
if (index <= 5) return 'var(--accent2)';
if (index <= 9) return 'var(--warning)';
return 'var(--danger)';
},
chartColor(index) {
if (index <= 5) return '#3b82f6';
if (index <= 9) return '#f59e0b';
return '#ef4444';
},
},
产品: {
emoji: '🍜',
badge: (count) => `${count}种产品`,
subtitle: '各产品销售额、销量与单均表现',
chartId: 'productChart',
tableBarColor(index) {
if (index <= 5) return 'var(--accent2)';
if (index <= 8) return 'var(--warning)';
return 'var(--danger)';
},
chartColor(index) {
if (index <= 5) return '#3b82f6';
if (index <= 8) return '#f59e0b';
return '#ef4444';
},
},
};
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function safeJsonForScript(value) {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
function formatReportNumber(value) {
if (value == null) return '—';
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value);
}
function formatReportInteger(value) {
if (value == null) return '—';
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(Math.round(Number(value)));
}
function metricShortLabel(name) {
return String(name ?? '').replace(/\(.*?\)/g, '').trim();
}
function metricUnit(name) {
const match = String(name ?? '').match(/\(([^)]+)\)/);
return match?.[1]?.trim() ?? '';
}
function metricUsesCurrencyLabel(label) {
return /[额元价]/.test(String(label ?? ''));
}
function reportSharePercent(value, total) {
if (!total) return 0;
return (Number(value) / Number(total)) * 100;
}
function formatWan(value) {
const wan = Number(value) / 10000;
const rounded = Math.round(wan * 10) / 10;
if (rounded >= 100 && Math.abs(rounded - Math.round(rounded)) <= 0.15) {
return `${Math.round(rounded)}`;
}
const text = rounded.toFixed(1);
return text.endsWith('.0') ? `${Math.round(rounded)}` : `${text}`;
}
function formatWanPerOrder(value) {
return formatWan(value);
}
function sectionLayout(dimension) {
return SECTION_LAYOUT[dimension] ?? {
emoji: '📊',
badge: (count) => `${count}`,
subtitle: `${dimension}表现`,
chartId: `chart${dimension}`,
tableBarColor: () => 'var(--accent2)',
chartColor: () => '#3b82f6',
};
}
function formatBarWidth(widthPct) {
const raw = Number(widthPct) || 0;
const nearest = Math.round(raw);
if (Math.abs(raw - nearest) < 0.055) {
return String(nearest);
}
const rounded = Math.round(raw * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/, '');
}
function shareCell(value, topValue, grandTotal, barColor) {
const sharePct = reportSharePercent(value, grandTotal);
const widthPct = topValue ? (Number(value) / Number(topValue)) * 100 : 0;
const widthText = formatBarWidth(widthPct);
const shareText = sharePct.toFixed(1);
return `<div class="cell-bar"><div class="track"><div class="fill" style="width:${widthText}%;background:${barColor}"></div></div>${shareText}%</div>`;
}
function rankMedalLabel(index, group) {
if (index === 0) return `<span class="rank-1">🥇 ${escapeXml(group)}</span>`;
if (index === 1) return `<span class="rank-2">🥈 ${escapeXml(group)}</span>`;
if (index === 2) return `<span class="rank-3">🥉 ${escapeXml(group)}</span>`;
return escapeXml(group);
}
function findSummaryMetric(model, pattern) {
return (model.meta?.summaryMetrics ?? []).find((item) => pattern.test(item.name));
}
function sectionByDimension(model, dimension) {
return model.sections.find((section) => section.dimension === dimension) ?? null;
}
function buildHeroSource(model) {
const parts = [
`数据来源:${model.source.path}`,
`${model.source.rows}条记录`,
];
const region = sectionByDimension(model, '区域');
const city = sectionByDimension(model, '城市');
const product = sectionByDimension(model, '产品');
if (region) parts.push(`${region.items.length}大区域`);
if (city) parts.push(`${city.items.length}个城市`);
if (product) parts.push(`${product.items.length}种产品`);
return parts.join(' · ');
}
function buildHeroSubtitle(model) {
const dimensions = model.sections.map((section) => section.dimension).join(' · ');
const period = model.meta?.periodLabel;
if (period && dimensions) return `${period} · ${dimensions} 三维深度分析`;
if (dimensions) return `${dimensions} 多维深度分析`;
return 'Excel 数据分析报告';
}
function buildStatCards(model) {
const cards = [];
const metricLabel = metricShortLabel(model.fields.metric);
const metricPrefix = metricUsesCurrencyLabel(model.fields.metric) ? '¥' : '';
cards.push({
tone: 'primary',
icon: '💰',
label: `${metricLabel}`,
value: `${metricPrefix}${formatReportNumber(model.totals.metric)}`,
trend: model.sections.some((section) => section.dimension === '区域')
? `↑ 覆盖${sectionByDimension(model, '区域')?.items.length ?? model.sections.length}大区域`
: `↑ 覆盖${model.sections.length}个维度`,
});
if (model.fields.quantityMetric) {
const unit = metricUnit(model.fields.quantityMetric);
const avgPerOrder = model.totals.orders ? model.totals.quantity / model.totals.orders : null;
cards.push({
tone: 'success',
icon: '📦',
label: metricShortLabel(model.fields.quantityMetric),
value: unit ? `${formatReportInteger(model.totals.quantity)} ${unit}` : formatReportInteger(model.totals.quantity),
trend: avgPerOrder == null
? '↑ 已汇总'
: `↑ 平均${formatReportInteger(avgPerOrder)}${unit ? `${unit}/单` : '/单'}`,
});
}
const dealerMetric = findSummaryMetric(model, /经销商/);
if (dealerMetric) {
const citySection = sectionByDimension(model, '城市');
const avgPerCity = citySection?.items.length ? dealerMetric.total / citySection.items.length : null;
cards.push({
tone: 'warning',
icon: '🏪',
label: metricShortLabel(dealerMetric.name),
value: `${formatReportInteger(dealerMetric.total)}`,
trend: avgPerCity == null
? '↑ 已汇总'
: `↑ 平均${Math.round(avgPerCity)}家/城市`,
});
} else if (model.fields.profitMetric) {
cards.push({
tone: 'warning',
icon: '📈',
label: metricShortLabel(model.fields.profitMetric),
value: `${metricPrefix}${formatReportNumber(model.totals.profit)}`,
trend: model.totals.margin == null ? '↑ 已汇总' : `↑ 比率 ${(model.totals.margin * 100).toFixed(1)}%`,
});
}
const citySection = sectionByDimension(model, '城市');
if (citySection) {
cards.push({
tone: 'info',
icon: '📍',
label: '覆盖城市',
value: `${citySection.items.length}`,
trend: '↑ 全国布局',
});
} else {
cards.push({
tone: 'info',
icon: '📍',
label: model.fields.orderId ? '唯一订单数' : '记录数',
value: `${formatReportInteger(model.totals.orders)}`,
trend: `${model.source.rows} 行源数据`,
});
}
return cards.slice(0, 4).map((card) => (
`<div class="stat-card ${card.tone}"><div class="icon">${card.icon}</div><div class="label">${escapeXml(card.label)}</div><div class="value">${escapeXml(card.value)}</div><div class="trend up">${escapeXml(card.trend)}</div></div>`
)).join('');
}
function buildRegionTable(section, model, layout) {
const topValue = section.items[0]?.value ?? 0;
const avgWinner = model.facts.topAverage[section.dimension];
const rows = section.items.map((item, index) => {
const avgCell = avgWinner?.group === item.group
? `<strong>${escapeXml(formatReportInteger(item.averagePerOrder))}</strong>`
: escapeXml(formatReportInteger(item.averagePerOrder));
return `<tr><td>${rankMedalLabel(index, item.group)}</td><td>${escapeXml(formatReportInteger(item.value))}</td><td>${shareCell(item.value, topValue, model.totals.metric, layout.tableBarColor(index))}</td><td>${escapeXml(formatReportInteger(item.orders))}</td><td>${avgCell}</td></tr>`;
}).join('');
return `<thead><tr><th>区域</th><th>${escapeXml(model.fields.metric)}</th><th>占比</th><th>订单数</th><th>单均(元)</th></tr></thead><tbody>${rows}</tbody>`;
}
function buildCityTable(section, model, layout) {
const topValue = section.items[0]?.value ?? 0;
const regionField = model.meta?.contextFields?.find((field) => field === '区域') ?? '区域';
const dealerField = model.meta?.contextFields?.find((field) => /经销商/.test(field));
const rows = section.items.map((item, index) => {
const rankCell = index <= 2
? `<span class="rank-${index + 1}">${index + 1}</span>`
: String(index + 1);
const cityCell = index <= 2
? `<strong>${escapeXml(item.group)}</strong>`
: escapeXml(item.group);
const regionValue = escapeXml(item.context?.[regionField] ?? '');
const dealerValue = dealerField ? escapeXml(formatReportInteger(item.context?.[dealerField])) : '—';
return `<tr><td>${rankCell}</td><td>${cityCell}</td><td>${regionValue}</td><td>${escapeXml(formatReportInteger(item.value))}</td><td>${shareCell(item.value, topValue, model.totals.metric, layout.tableBarColor(index))}</td><td>${dealerValue}</td></tr>`;
}).join('');
return `<thead><tr><th>排名</th><th>城市</th><th>区域</th><th>${escapeXml(model.fields.metric)}</th><th>占比</th><th>经销商</th></tr></thead><tbody>${rows}</tbody>`;
}
function buildProductTable(section, model, layout) {
const topValue = section.items[0]?.value ?? 0;
const avgWinner = model.facts.topAverage[section.dimension];
const joinFields = [...new Set(section.items.flatMap((item) => Object.keys(item.masterData ?? {})))];
const quantityLabel = model.fields.quantityMetric ? `总销量(${metricUnit(model.fields.quantityMetric) || '量'})` : null;
const rows = section.items.map((item, index) => {
const avgCell = avgWinner?.group === item.group
? `<strong>${escapeXml(formatReportInteger(item.averagePerOrder))}</strong>`
: escapeXml(formatReportInteger(item.averagePerOrder));
const quantityCell = model.fields.quantityMetric
? `<td>${escapeXml(formatReportInteger(item.quantity))}</td>`
: '';
const joinCells = joinFields.map((field) => `<td>${escapeXml(item.masterData?.[field] ?? '')}</td>`).join('');
return `<tr><td>${rankMedalLabel(index, item.group)}</td>${joinCells}<td>${escapeXml(formatReportInteger(item.value))}</td><td>${shareCell(item.value, topValue, model.totals.metric, layout.tableBarColor(index))}</td><td>${escapeXml(formatReportInteger(item.orders))}</td><td>${avgCell}</td>${quantityCell}</tr>`;
}).join('');
const joinHeaders = joinFields.map((field) => `<th>${escapeXml(field)}</th>`).join('');
const quantityHeader = quantityLabel ? `<th>${escapeXml(quantityLabel)}</th>` : '';
return `<thead><tr><th>产品</th>${joinHeaders}<th>${escapeXml(model.fields.metric)}</th><th>占比</th><th>订单数</th><th>单均(元)</th>${quantityHeader}</tr></thead><tbody>${rows}</tbody>`;
}
function buildGenericTable(section, model, layout) {
const topValue = section.items[0]?.value ?? 0;
const joinFields = [...new Set(section.items.flatMap((item) => Object.keys(item.masterData ?? {})))];
const header = [
section.dimension,
...joinFields,
model.fields.metric,
'占比',
...(model.fields.profitMetric ? [model.fields.profitMetric, '比率'] : []),
...(model.fields.quantityMetric ? [model.fields.quantityMetric] : []),
'订单数',
'单均',
];
const avgWinner = model.facts.topAverage[section.dimension];
const rows = section.items.map((item, index) => {
const cells = [
rankMedalLabel(index, item.group),
...joinFields.map((field) => escapeXml(item.masterData?.[field] ?? '')),
escapeXml(formatReportNumber(item.value)),
shareCell(item.value, topValue, model.totals.metric, layout.tableBarColor(index)),
...(model.fields.profitMetric ? [escapeXml(formatReportNumber(item.profit)), escapeXml(item.margin == null ? '—' : `${(item.margin * 100).toFixed(1)}%`)] : []),
...(model.fields.quantityMetric ? [escapeXml(formatReportNumber(item.quantity))] : []),
escapeXml(formatReportNumber(item.orders)),
avgWinner?.group === item.group
? `<strong>${escapeXml(formatReportNumber(item.averagePerOrder))}</strong>`
: escapeXml(formatReportNumber(item.averagePerOrder)),
];
return `<tr>${cells.map((cell) => `<td>${cell}</td>`).join('')}</tr>`;
}).join('');
return `<thead><tr>${header.map((cell) => `<th>${escapeXml(cell)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody>`;
}
function buildSection(section, model, sectionIndex) {
const layout = sectionLayout(section.dimension);
const chartId = layout.chartId || `reportChart${sectionIndex}`;
const chartData = section.items.map((item, index) => ({
label: item.group,
value: item.value,
color: layout.chartColor(index),
}));
let tableHtml;
if (section.dimension === '区域') tableHtml = buildRegionTable(section, model, layout);
else if (section.dimension === '城市') tableHtml = buildCityTable(section, model, layout);
else if (section.dimension === '产品') tableHtml = buildProductTable(section, model, layout);
else tableHtml = buildGenericTable(section, model, layout);
return `<div class="section"><div class="section-header"><div><div class="section-title">${layout.emoji} ${escapeXml(section.dimension)}分析 <span class="badge">${escapeXml(layout.badge(section.items.length))}</span></div><div class="section-subtitle">${escapeXml(layout.subtitle)}</div></div></div><div class="chart-container"><div class="chart-bar" id="${chartId}"></div></div><div class="table-wrap"><table>${tableHtml}</table></div></div>` +
`\n<script>\nwindow.__MINDSPACE_EXCEL_REPORT_CHARTS__=window.__MINDSPACE_EXCEL_REPORT_CHARTS__||[];\nwindow.__MINDSPACE_EXCEL_REPORT_CHARTS__.push({id:${safeJsonForScript(chartId)},data:${safeJsonForScript(chartData)}});\n</script>`;
}
function buildInsights(model) {
const cards = [];
const regionSection = sectionByDimension(model, '区域');
const citySection = sectionByDimension(model, '城市');
const productSection = sectionByDimension(model, '产品');
if (regionSection?.items.length) {
const leader = regionSection.items[0];
const avgWinner = model.facts.topAverage.区域;
const leaderCities = model.meta?.regionCityCounts?.[leader.group] ?? leader.orders;
const winnerCities = avgWinner ? (model.meta?.regionCityCounts?.[avgWinner.group] ?? null) : null;
let text = `<strong>${escapeXml(leader.group)}</strong>以${escapeXml(formatWan(leader.value))}领跑总量`;
if (avgWinner && avgWinner.group !== leader.group) {
text += `,但<strong>${escapeXml(avgWinner.group)}</strong>单均最高(${escapeXml(formatWanPerOrder(avgWinner.value))}/单)`;
text += `${escapeXml(leader.group)}${leaderCities}城堆量,${escapeXml(avgWinner.group)}${winnerCities ?? ''}城质量更优。建议:${escapeXml(avgWinner.group)}加大投入,${escapeXml(leader.group)}优化经销商效率。`;
} else {
text += '。';
}
cards.push({ title: '区域格局', emoji: '🗺️', text });
}
if (citySection?.items.length >= 3) {
const top3 = citySection.items.slice(0, 3);
const parts = top3.map((item, index) => {
const label = index === 0
? `<strong>${escapeXml(item.group)}</strong>${escapeXml(formatWan(item.value))}`
: `${escapeXml(item.group)}${escapeXml(formatWan(item.value))}`;
return label;
});
let text = `${parts.join('> ')}位列前三。`;
const northeastCities = citySection.items
.filter((item) => String(item.context?.区域 ?? '').includes('东北'))
.sort((a, b) => a.value - b.value);
if (northeastCities.length >= 2) {
text += `东北双城(${escapeXml(northeastCities[0].group)}${escapeXml(formatWan(northeastCities[0].value))}${escapeXml(northeastCities[1].group)}${escapeXml(formatWan(northeastCities[1].value))})明显落后,是重点提升区域。`;
} else {
const tail = [...citySection.items.slice(-2)].sort((a, b) => a.value - b.value);
if (tail.length >= 2) {
text += `${escapeXml(tail[0].group)}${escapeXml(formatWan(tail[0].value))})、${escapeXml(tail[1].group)}${escapeXml(formatWan(tail[1].value))})明显落后,是重点提升区域。`;
}
}
cards.push({ title: '城市表现', emoji: '🏙️', text });
}
if (productSection?.items.length) {
const leader = productSection.items[0];
const avgWinner = model.facts.topAverage.产品;
const leaderShare = reportSharePercent(leader.value, model.totals.metric).toFixed(1);
let text = `<strong>${escapeXml(leader.group)}</strong>是绝对主力(${escapeXml(formatWan(leader.value))}${leaderShare}%`;
const regionCount = model.meta?.productRegionCounts?.[leader.group];
if (regionCount) text += `,覆盖${regionCount}区域通用性强。`;
if (avgWinner) {
text += `<strong>${escapeXml(avgWinner.group)}</strong>单均最高(${escapeXml(formatWanPerOrder(avgWinner.value))}`;
const onlyCity = model.meta?.productOnlyCity?.[avgWinner.group];
if (onlyCity) {
text += `,但仅${escapeXml(onlyCity)}一城有售,可考虑向成都等城市扩展。`;
} else {
text += '。';
}
} else {
text += '。';
}
cards.push({ title: '产品结构', emoji: '🍜', text });
}
if (regionSection?.items.length >= 2) {
const weakRegions = regionSection.items.filter((item) => /东北|西北/.test(item.group));
if (weakRegions.length) {
const combined = weakRegions.reduce((sum, item) => sum + item.value, 0);
const combinedShare = reportSharePercent(combined, model.totals.metric).toFixed(1);
const names = weakRegions.map((item) => escapeXml(item.group)).join('、');
let text = `${names}市场薄弱(合计仅${escapeXml(formatWan(combined))}${combinedShare}%)。`;
const northwest = weakRegions.find((item) => item.group.includes('西北'));
const northeast = weakRegions.filter((item) => item.group.includes('东北'));
if (northwest && citySection) {
const northwestCities = citySection.items.filter((item) => item.context?.区域 === northwest.group);
if (northwestCities.length === 1) {
text += `西北仅${escapeXml(northwestCities[0].group)}一城,`;
}
}
if (northeast.length && citySection) {
const dealerField = model.meta?.contextFields?.find((field) => /经销商/.test(field));
const dealerCounts = citySection.items
.filter((item) => northeast.some((region) => item.context?.区域 === region.group))
.map((item) => Number(item.context?.[dealerField] ?? NaN))
.filter((value) => Number.isFinite(value));
if (dealerCounts.length) {
const minDealer = Math.min(...dealerCounts);
const maxDealer = Math.max(...dealerCounts);
text += `东北双城经销商数最少(${minDealer}${maxDealer !== minDealer ? `-${maxDealer}` : ''}家)。`;
}
}
text += '建议增加经销商覆盖,或推出区域特色产品。';
cards.push({ title: '增长机会', emoji: '📈', text });
}
}
if (!cards.length) {
for (const section of model.sections.slice(0, 4)) {
const leader = section.items[0];
if (!leader) continue;
cards.push({
title: `${section.dimension}概览`,
emoji: '📊',
text: `<strong>${escapeXml(leader.group)}</strong>在${escapeXml(section.dimension)}维度领先(${escapeXml(formatReportNumber(leader.value))})。`,
});
}
}
return cards.slice(0, 4).map((item) => (
`<div class="insight-card"><h4>${item.emoji} ${escapeXml(item.title)}</h4><p>${item.text}</p></div>`
)).join('');
}
const REPORT_CHART_SCRIPT = `function renderChart(containerId, data) {
const container = document.getElementById(containerId);
if (!container) return;
const max = Math.max(...data.map(d=>d.value), 1);
data.forEach((d,i) => {
const pct = (d.value / max * 100).toFixed(1);
const item = document.createElement('div');
item.className = 'bar-item';
item.innerHTML = \`
<div class="bar-fill" style="height:0%;background:\${d.color}" data-height="\${pct}%">
<span class="tooltip">\${d.label}: \${d.value.toLocaleString()}元</span>
</div>
<div class="bar-label">\${d.label}</div>
\`;
container.appendChild(item);
setTimeout(() => {
item.querySelector('.bar-fill').style.height = pct + '%';
}, 100 + i * 50);
});
}
(window.__MINDSPACE_EXCEL_REPORT_CHARTS__||[]).forEach((entry)=>renderChart(entry.id,entry.data));`;
export function reportHtml(model, { title = '' } = {}) {
const safeTitle = escapeXml(title || `${model.source.sheet} 数据分析报告`);
const dimensions = model.sections.map((section) => section.dimension).join(' · ');
const description = model.meta?.periodLabel
? `${model.meta.periodLabel} ${model.source.sheet} 多维度分析,涵盖 ${model.sections.map((section) => section.dimension).join('、')} 三大维度`
: `${model.source.sheet} 数据分析,涵盖 ${model.sections.length} 个维度`;
const coverMeta = safeJsonForScript({
tag: '报告',
emoji: '📊',
accent: '#1e3a5f',
accent2: '#f0f4f8',
subtitle: dimensions ? dimensions.replace(/ · /g, '·') + '三维分析' : model.source.sheet,
});
const sectionsHtml = model.sections.map((section, index) => buildSection(section, model, index)).join('\n\n');
const insightsHtml = buildInsights(model);
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="excel-report-valid" content="true">
<meta name="description" content="${escapeXml(description)}">
<meta name="mindspace-cover" content='${coverMeta}'>
<title>${safeTitle}</title>
<style>
${REPORT_ENHANCED_STYLE}
</style>
</head>
<body>
<main class="wrap">
<div class="hero">
<h1>📊 ${safeTitle}</h1>
<div class="subtitle">${escapeXml(buildHeroSubtitle(model))}</div>
<div class="source">${escapeXml(buildHeroSource(model))}</div>
${model.source.downloadUrl
? `<a class="source-download" href="${escapeXml(model.source.downloadUrl)}">下载原始 Excel</a>`
: ''}
</div>
<div class="stats-grid">
${buildStatCards(model)}
</div>
${sectionsHtml}
<div class="section">
<div class="section-header">
<div>
<div class="section-title">💡 核心洞察</div>
<div class="section-subtitle">基于数据的关键发现与建议</div>
</div>
</div>
<div class="insights">
${insightsHtml}
</div>
</div>
</main>
<footer>
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
<p style="margin-top:4px;font-size:12px;opacity:0.7">✓ 已通过 Excel 事实模型一致性校验</p>
</footer>
<script>
${REPORT_CHART_SCRIPT}
</script>
</body>
</html>`;
}