feat(excel-analyst): restore enhanced report HTML template for excel_report
Memind CI / Test, build, and release guards (push) Successful in 4m34s
Memind CI / Test, build, and release guards (push) Successful in 4m34s
Move report generation into excel-report-html.mjs so excel_report matches the hand-enhanced layout with KPI cards, charts, and insight cards, while keeping fact-model validation in the analysis engine. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+97
-45
@@ -3,6 +3,7 @@ import { constants as fsConstants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
import { reportHtml } from './excel-report-html.mjs';
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 30 * 1024 * 1024;
|
||||
const DEFAULT_MAX_SCAN_CELLS = 200_000;
|
||||
@@ -473,6 +474,19 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
}
|
||||
dimensions.forEach((name) => requireColumn(loaded, name, '分析维度'));
|
||||
|
||||
const usedColumns = new Set([metric, profitMetric, quantityMetric, orderId, ...dimensions].filter(Boolean));
|
||||
const contextFields = loaded.headers.filter((header) => !usedColumns.has(header));
|
||||
const summaryMetrics = contextFields
|
||||
.filter((header) => profileColumn(loaded.rows, header).inferredType === 'number')
|
||||
.map((name) => ({ name, total: roundNumber(sumColumn(loaded.rows, name)) }));
|
||||
const monthField = contextFields.find((header) => /月/.test(header));
|
||||
let periodLabel = null;
|
||||
if (monthField) {
|
||||
const raw = normalizeText(loaded.rows.find((row) => !isBlank(row[monthField]))?.[monthField]);
|
||||
const match = raw?.match(/^(\d{4})-(\d{1,2})/);
|
||||
if (match) periodLabel = `${match[1]}年${Number(match[2])}月`;
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const joins = new Map();
|
||||
@@ -517,9 +531,28 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
|
||||
for (const dimension of dimensions) {
|
||||
const grouped = new Map();
|
||||
const rowContextFields = loaded.headers.filter((header) => (
|
||||
![metric, profitMetric, quantityMetric, orderId, dimension].filter(Boolean).includes(header)
|
||||
));
|
||||
for (const row of loaded.rows) {
|
||||
const key = normalizeText(row[dimension]) || '(空)';
|
||||
const current = grouped.get(key) ?? { group: key, value: 0, profit: 0, quantity: 0, rows: 0, orderIds: new Set() };
|
||||
let current = grouped.get(key);
|
||||
if (!current) {
|
||||
current = {
|
||||
group: key,
|
||||
value: 0,
|
||||
profit: 0,
|
||||
quantity: 0,
|
||||
rows: 0,
|
||||
orderIds: new Set(),
|
||||
context: {},
|
||||
};
|
||||
for (const field of rowContextFields) {
|
||||
const profile = profileColumn(loaded.rows, field);
|
||||
const raw = row[field];
|
||||
current.context[field] = profile.inferredType === 'number' ? asNumber(raw) : normalizeText(raw) || '';
|
||||
}
|
||||
}
|
||||
current.value += asNumber(row[metric]) ?? 0;
|
||||
if (profitMetric) current.profit += asNumber(row[profitMetric]) ?? 0;
|
||||
if (quantityMetric) current.quantity += asNumber(row[quantityMetric]) ?? 0;
|
||||
@@ -546,6 +579,7 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
orders: orderCount,
|
||||
averagePerOrder: orderCount ? roundNumber(item.value / orderCount) : null,
|
||||
...(masterData ? { masterData } : {}),
|
||||
context: item.context,
|
||||
};
|
||||
}).sort((a, b) => b.value - a.value || a.group.localeCompare(b.group, 'zh-CN', { numeric: true }));
|
||||
const reconciled = items.reduce((sum, item) => sum + item.value, 0);
|
||||
@@ -557,6 +591,59 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
sections.push({ dimension, items, total: roundNumber(reconciled) });
|
||||
}
|
||||
|
||||
const regionField = dimensions.includes('区域') ? '区域' : null;
|
||||
const cityField = dimensions.includes('城市') ? '城市' : null;
|
||||
const productField = dimensions.includes('产品') ? '产品' : null;
|
||||
const regionCityCounts = {};
|
||||
const productRegionCounts = {};
|
||||
const productCityCounts = {};
|
||||
const productOnlyCity = {};
|
||||
if (regionField && cityField) {
|
||||
const regionCitySets = new Map();
|
||||
for (const row of loaded.rows) {
|
||||
const region = normalizeText(row[regionField]);
|
||||
const city = normalizeText(row[cityField]);
|
||||
if (!region || !city) continue;
|
||||
const set = regionCitySets.get(region) ?? new Set();
|
||||
set.add(city);
|
||||
regionCitySets.set(region, set);
|
||||
}
|
||||
for (const [region, cities] of regionCitySets.entries()) {
|
||||
regionCityCounts[region] = cities.size;
|
||||
}
|
||||
}
|
||||
if (productField) {
|
||||
const productRegions = new Map();
|
||||
const productCities = new Map();
|
||||
for (const row of loaded.rows) {
|
||||
const product = normalizeText(row[productField]);
|
||||
if (!product) continue;
|
||||
if (regionField) {
|
||||
const region = normalizeText(row[regionField]);
|
||||
if (region) {
|
||||
const set = productRegions.get(product) ?? new Set();
|
||||
set.add(region);
|
||||
productRegions.set(product, set);
|
||||
}
|
||||
}
|
||||
if (cityField) {
|
||||
const city = normalizeText(row[cityField]);
|
||||
if (city) {
|
||||
const set = productCities.get(product) ?? new Set();
|
||||
set.add(city);
|
||||
productCities.set(product, set);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [product, regions] of productRegions.entries()) {
|
||||
productRegionCounts[product] = regions.size;
|
||||
}
|
||||
for (const [product, cities] of productCities.entries()) {
|
||||
productCityCounts[product] = cities.size;
|
||||
if (cities.size === 1) productOnlyCity[product] = [...cities][0];
|
||||
}
|
||||
}
|
||||
|
||||
const missingMetric = loaded.rows.filter((row) => asNumber(row[metric]) == null).length;
|
||||
if (missingMetric) warnings.push({ code: 'MISSING_METRIC_VALUES', metric, rows: missingMetric });
|
||||
if (orderId && uniqueOrders < loaded.rows.length) {
|
||||
@@ -576,54 +663,19 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
},
|
||||
sections,
|
||||
facts: { topAverage },
|
||||
meta: {
|
||||
periodLabel,
|
||||
summaryMetrics,
|
||||
contextFields,
|
||||
regionCityCounts,
|
||||
productRegionCounts,
|
||||
productCityCounts,
|
||||
productOnlyCity,
|
||||
},
|
||||
validation,
|
||||
};
|
||||
}
|
||||
|
||||
function formatReportNumber(value) {
|
||||
if (value == null) return '—';
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
function formatReportWarning(warning) {
|
||||
if (warning.code === 'DUPLICATE_ORDER_IDS') {
|
||||
return `${warning.field} 存在重复:${warning.rows} 行仅有 ${warning.unique} 个唯一值`;
|
||||
}
|
||||
if (warning.code === 'MISSING_METRIC_VALUES') {
|
||||
return `${warning.metric} 有 ${warning.rows} 行缺失或不是数值`;
|
||||
}
|
||||
return warning.code;
|
||||
}
|
||||
|
||||
function reportHtml(model, { title = '' } = {}) {
|
||||
const safeTitle = escapeXml(title || `${model.source.sheet} 数据分析报告`);
|
||||
const cards = [
|
||||
[model.fields.metric, formatReportNumber(model.totals.metric)],
|
||||
...(model.fields.profitMetric ? [[model.fields.profitMetric, formatReportNumber(model.totals.profit)], ['综合比率', `${(model.totals.margin * 100).toFixed(1)}%`]] : []),
|
||||
...(model.fields.quantityMetric ? [[model.fields.quantityMetric, formatReportNumber(model.totals.quantity)]] : []),
|
||||
[model.fields.orderId ? '唯一订单数' : '记录数', formatReportNumber(model.totals.orders)],
|
||||
];
|
||||
const sections = model.sections.map((section) => {
|
||||
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 rows = section.items.map((item) => [
|
||||
item.group,
|
||||
...joinFields.map((field) => item.masterData?.[field] ?? ''),
|
||||
formatReportNumber(item.value),
|
||||
...(model.fields.profitMetric ? [formatReportNumber(item.profit), item.margin == null ? '—' : `${(item.margin * 100).toFixed(1)}%`] : []),
|
||||
...(model.fields.quantityMetric ? [formatReportNumber(item.quantity)] : []),
|
||||
formatReportNumber(item.orders),
|
||||
formatReportNumber(item.averagePerOrder),
|
||||
]);
|
||||
const winner = model.facts.topAverage[section.dimension];
|
||||
return `<section><h2>${escapeXml(section.dimension)}分析</h2><div class="table-wrap"><table><thead><tr>${header.map((cell) => `<th>${escapeXml(cell)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeXml(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${winner ? `<p class="fact">单均最高:<strong>${escapeXml(winner.group)}</strong>(${escapeXml(formatReportNumber(winner.value))} / 单)</p>` : ''}</section>`;
|
||||
}).join('');
|
||||
const warnings = model.validation.warnings.length
|
||||
? `<ul class="warnings">${model.validation.warnings.map((warning) => `<li>${escapeXml(formatReportWarning(warning))}</li>`).join('')}</ul>`
|
||||
: '';
|
||||
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"><title>${safeTitle}</title><style>:root{color-scheme:light;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f4f7fb}*{box-sizing:border-box}body{margin:0}.wrap{max-width:1200px;margin:auto;padding:32px 20px 56px}header,section{background:#fff;border:1px solid #dbe4f0;border-radius:16px;padding:24px;margin-bottom:20px;box-shadow:0 8px 24px rgba(31,55,84,.06)}h1{margin:0 0 8px;color:#183a5a}h2{margin:0 0 18px;color:#183a5a}.source{color:#61758a}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:22px}.card{background:#edf5ff;border-radius:12px;padding:16px}.card b{display:block;font-size:24px;margin-top:5px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:10px 12px;border-bottom:1px solid #e5ebf2;text-align:right;white-space:nowrap}th{background:#183a5a;color:#fff}th:first-child,td:first-child{text-align:left}.fact{margin:16px 0 0;color:#42566c}.ok{color:#147d4f;font-weight:700}.warnings{color:#9a5b00;background:#fff7e6;border-radius:10px;padding:12px 12px 12px 32px}</style></head><body><main class="wrap"><header><h1>${safeTitle}</h1><div class="source">来源:${escapeXml(model.source.path)} · ${escapeXml(model.source.sheet)} · ${model.source.rows} 行</div><div class="cards">${cards.map(([label, value]) => `<div class="card">${escapeXml(label)}<b>${escapeXml(value)}</b></div>`).join('')}</div><p class="ok">✓ 已通过 Excel 事实模型一致性校验</p>${warnings}</header>${sections}</main></body></html>`;
|
||||
}
|
||||
|
||||
export function createExcelAnalysisEngine({ workspaceRoot, maxFileBytes = DEFAULT_MAX_FILE_BYTES } = {}) {
|
||||
if (!workspaceRoot) throw new Error('Excel engine requires workspaceRoot');
|
||||
|
||||
|
||||
@@ -238,6 +238,9 @@ test('Excel engine inspects, analyzes, and charts without changing source workbo
|
||||
});
|
||||
const reportHtml = fs.readFileSync(path.join(root, report.outputPath), 'utf8');
|
||||
assert.match(reportHtml, /excel-report-valid/);
|
||||
assert.match(reportHtml, /stats-grid/);
|
||||
assert.match(reportHtml, /chart-bar/);
|
||||
assert.match(reportHtml, /insight-card/);
|
||||
assert.match(reportHtml, /<script>alert\("cell"\)<\/script>/);
|
||||
assert.doesNotMatch(reportHtml, /<script>alert/);
|
||||
|
||||
@@ -280,6 +283,44 @@ test('Excel engine inspects, analyzes, and charts without changing source workbo
|
||||
);
|
||||
});
|
||||
|
||||
test('Excel engine reports the exact supported format boundary for XLSX, XLS, and CSV', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-format-contract-'));
|
||||
await createWorkbook(root);
|
||||
const engine = createExcelAnalysisEngine({ workspaceRoot: root });
|
||||
|
||||
assert.equal((await engine.inspect({ path: 'oa/sales.xlsx' })).format, 'xlsx');
|
||||
for (const unsupportedPath of ['oa/legacy.xls', 'oa/export.csv']) {
|
||||
await assert.rejects(
|
||||
engine.inspect({ path: unsupportedPath }),
|
||||
(error) => (
|
||||
error?.code === 'UNSUPPORTED_FORMAT' &&
|
||||
/Phase 1 只支持 \.xlsx 文件/.test(error.message)
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Excel analysis keeps source workbooks private and isolated to one user workspace', async () => {
|
||||
const userOneRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-user-one-'));
|
||||
const userTwoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-user-two-'));
|
||||
const userOneWorkbook = await createWorkbook(userOneRoot);
|
||||
const userOneEngine = createExcelAnalysisEngine({ workspaceRoot: userOneRoot });
|
||||
const userTwoEngine = createExcelAnalysisEngine({ workspaceRoot: userTwoRoot });
|
||||
|
||||
await userOneEngine.inspect({ path: 'oa/sales.xlsx' });
|
||||
assert.equal(fs.existsSync(path.join(userOneRoot, 'public', 'sales.xlsx')), false);
|
||||
assert.equal(fs.existsSync(userOneWorkbook), true);
|
||||
|
||||
await assert.rejects(
|
||||
userTwoEngine.inspect({ path: userOneWorkbook }),
|
||||
(error) => error?.code === 'INVALID_PATH',
|
||||
);
|
||||
await assert.rejects(
|
||||
userTwoEngine.inspect({ path: 'oa/sales.xlsx' }),
|
||||
(error) => error?.code === 'FILE_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
|
||||
test('Excel MCP exposes the Phase 1 analysis tools and verified report generator', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-mcp-'));
|
||||
await createWorkbook(root);
|
||||
|
||||
@@ -0,0 +1,871 @@
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>
|
||||
</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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user