feat: 搭建二期项目架构
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { inventoryChart } from './inventory.js';
|
||||
import { marginChart } from './margin.js';
|
||||
import { twoLineChart } from './twoline.js';
|
||||
import { growthChart } from './growth.js';
|
||||
import { fxChart } from './fx.js';
|
||||
import { assetsChart } from './ppmt_assets.js';
|
||||
import { profitChart } from './ppmt_profit.js';
|
||||
import { ipChart } from './ppmt_ip.js';
|
||||
import { categoryChart } from './ppmt_category.js';
|
||||
import { sankeyChart } from './ppmt_sankey.js';
|
||||
import { regionChannelYoyChart } from './ppmt_region_yoy.js';
|
||||
import { ppmtGrowthChart } from './ppmt_growth.js';
|
||||
import { ppmtMarginsChart } from './ppmt_margins.js';
|
||||
import { ppmtBridgeChart } from './ppmt_bridge.js';
|
||||
import { ppmtCoreProfitChart } from './ppmt_core_profit.js';
|
||||
import { ppmtCoreMarginChart } from './ppmt_core_margin.js';
|
||||
import { ppmtIpShareChart } from './ppmt_ip_share.js';
|
||||
import { ppmtIpShareHistoryChart } from './ppmt_ip_share_history.js';
|
||||
import { ppmtIpExMonstersChart } from './ppmt_ip_ex_monsters.js';
|
||||
|
||||
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
|
||||
export const chartRegistry = {
|
||||
'demo-inventory': inventoryChart,
|
||||
'demo-margin': marginChart,
|
||||
'demo-twoline': twoLineChart,
|
||||
'demo-growth': growthChart,
|
||||
'demo-fx': fxChart,
|
||||
'ppmt-assets': assetsChart,
|
||||
'ppmt-profit': profitChart,
|
||||
'ppmt-ip': ipChart,
|
||||
'ppmt-category': categoryChart,
|
||||
'ppmt-sankey': sankeyChart,
|
||||
'ppmt-region-yoy': regionChannelYoyChart,
|
||||
'ppmt-growth': ppmtGrowthChart,
|
||||
'ppmt-margins': ppmtMarginsChart,
|
||||
'ppmt-bridge': ppmtBridgeChart,
|
||||
'ppmt-core-profit': ppmtCoreProfitChart,
|
||||
'ppmt-core-margin': ppmtCoreMarginChart,
|
||||
'ppmt-ip-share': ppmtIpShareChart,
|
||||
'ppmt-ip-share-history': ppmtIpShareHistoryChart,
|
||||
'ppmt-ip-ex-monsters': ppmtIpExMonstersChart,
|
||||
};
|
||||
|
||||
export function getChartBuilder(key) {
|
||||
return chartRegistry[key];
|
||||
}
|
||||
+171
-78
@@ -1,99 +1,192 @@
|
||||
import { loadCSV } from './csv.js';
|
||||
import { inventoryChart } from './charts/inventory.js';
|
||||
import { marginChart } from './charts/margin.js';
|
||||
import { twoLineChart } from './charts/twoline.js';
|
||||
import { growthChart } from './charts/growth.js';
|
||||
import { fxChart } from './charts/fx.js';
|
||||
import { assetsChart } from './charts/ppmt_assets.js';
|
||||
import { profitChart } from './charts/ppmt_profit.js';
|
||||
import { ipChart } from './charts/ppmt_ip.js';
|
||||
import { categoryChart } from './charts/ppmt_category.js';
|
||||
import { sankeyChart } from './charts/ppmt_sankey.js';
|
||||
import { regionChannelYoyChart } from './charts/ppmt_region_yoy.js';
|
||||
import { ppmtGrowthChart } from './charts/ppmt_growth.js';
|
||||
import { ppmtMarginsChart } from './charts/ppmt_margins.js';
|
||||
import { ppmtBridgeChart } from './charts/ppmt_bridge.js';
|
||||
import { ppmtCoreProfitChart } from './charts/ppmt_core_profit.js';
|
||||
import { ppmtCoreMarginChart } from './charts/ppmt_core_margin.js';
|
||||
import { ppmtIpShareChart } from './charts/ppmt_ip_share.js';
|
||||
import { ppmtIpShareHistoryChart } from './charts/ppmt_ip_share_history.js';
|
||||
import { ppmtIpExMonstersChart } from './charts/ppmt_ip_ex_monsters.js';
|
||||
|
||||
/** 图表清单:加新图只需在这里加一行 */
|
||||
const CHARTS = [
|
||||
// 示例图表
|
||||
{ id: 'chart-inventory', csv: 'data/inventory.csv', build: inventoryChart, height: 620 },
|
||||
{ id: 'chart-margin', csv: 'data/margin.csv', build: marginChart, height: 620 },
|
||||
{ id: 'chart-twoline', csv: 'data/margin.csv', build: twoLineChart, height: 620 },
|
||||
{ id: 'chart-growth', csv: 'data/growth.csv', build: growthChart, height: 700 },
|
||||
{ id: 'chart-fx', csv: 'data/fx.csv', build: fxChart, height: 780 },
|
||||
// 泡泡玛特图表
|
||||
{ id: 'chart-ppmt-assets', csv: 'data/ppmt_assets.csv', build: assetsChart, height: 680 },
|
||||
{ id: 'chart-ppmt-profit', csv: 'data/ppmt_profit.csv', build: profitChart, height: 680 },
|
||||
{ id: 'chart-ppmt-ip', csv: 'data/ppmt_ip.csv', build: ipChart, height: 720 },
|
||||
{ id: 'chart-ppmt-category', csv: 'data/ppmt_category.csv', build: categoryChart, height: 680 },
|
||||
{ id: 'chart-ppmt-sankey', csv: 'data/ppmt_sankey_2026h1.csv', build: sankeyChart, height: 800 },
|
||||
{ id: 'chart-ppmt-region-yoy', csv: 'data/ppmt_region_channel_yoy.csv', build: regionChannelYoyChart, height: 620 },
|
||||
{ id: 'chart-ppmt-growth', csv: 'data/ppmt_growth.csv', build: ppmtGrowthChart, height: 700 },
|
||||
{ id: 'chart-ppmt-margins', csv: 'data/ppmt_margins.csv', build: ppmtMarginsChart, height: 680 },
|
||||
{ id: 'chart-ppmt-bridge', csv: 'data/ppmt_margin_bridge.csv', build: ppmtBridgeChart, height: 700 },
|
||||
{ id: 'chart-ppmt-core', csv: 'data/ppmt_core_profit.csv', build: ppmtCoreProfitChart, height: 720 },
|
||||
{ id: 'chart-ppmt-core-margin', csv: 'data/ppmt_core_margin.csv', build: ppmtCoreMarginChart, height: 680 },
|
||||
{ id: 'chart-ppmt-ip-share', csv: 'data/ppmt_ip_share_2026h1.csv', build: ppmtIpShareChart, height: 720 },
|
||||
{ id: 'chart-ppmt-ip-share-history', csv: 'data/ppmt_ip_share_history.csv', build: ppmtIpShareHistoryChart, height: 700 },
|
||||
{ id: 'chart-ppmt-ip-ex-monsters', csv: 'data/ppmt_ip_ex_monsters.csv', build: ppmtIpExMonstersChart, height: 700 },
|
||||
];
|
||||
import { getChartBuilder } from './charts/index.js';
|
||||
import {
|
||||
createAppShell,
|
||||
renderAnchors,
|
||||
renderCards,
|
||||
renderCompanies,
|
||||
renderTabs,
|
||||
showChartError,
|
||||
} from './ui.js';
|
||||
|
||||
const instances = new Map();
|
||||
const activeViews = new Map();
|
||||
let manifest;
|
||||
let shell;
|
||||
let state;
|
||||
let renderVersion = 0;
|
||||
let cacheToken = Date.now();
|
||||
|
||||
async function render(spec) {
|
||||
const el = document.getElementById(spec.id);
|
||||
function validateManifest(value) {
|
||||
if (!Array.isArray(value?.companies) || value.companies.length === 0) {
|
||||
throw new Error('manifest 缺少 companies');
|
||||
}
|
||||
|
||||
const chartIds = new Set();
|
||||
value.companies.forEach((company) => {
|
||||
if (!company.id || !Array.isArray(company.tabs)) throw new Error('manifest 中存在无效公司配置');
|
||||
const tabIds = new Set();
|
||||
company.tabs.forEach((tab) => {
|
||||
if (!tab.id || tabIds.has(tab.id)) throw new Error(`公司 ${company.id} 的 tab id 重复或为空`);
|
||||
tabIds.add(tab.id);
|
||||
(tab.charts || []).forEach((chart) => {
|
||||
if (!chart.id || chartIds.has(chart.id)) throw new Error(`图表 id 重复或为空:${chart.id || '(空)'}`);
|
||||
if (!getChartBuilder(chart.key)) throw new Error(`图表 ${chart.id} 引用了未注册 key:${chart.key}`);
|
||||
if (Boolean(chart.csv) === Boolean(chart.views)) throw new Error(`图表 ${chart.id} 必须声明 csv 或 views 之一`);
|
||||
if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) {
|
||||
throw new Error(`图表 ${chart.id} 的 defaultView 无效`);
|
||||
}
|
||||
chartIds.add(chart.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getCompany(companyId) {
|
||||
return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0];
|
||||
}
|
||||
|
||||
function getTab(company, tabId) {
|
||||
return company.tabs.find((tab) => tab.id === tabId) || company.tabs[0];
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
const [path = '', query = ''] = window.location.hash.replace(/^#\/?/, '').split('?');
|
||||
const [companyId, tabId, chartId] = path.split('/').filter(Boolean).map(decodeURIComponent);
|
||||
const params = new URLSearchParams(query);
|
||||
return { companyId, tabId, chartId, view: params.get('view') || undefined };
|
||||
}
|
||||
|
||||
function normalizeState(nextState) {
|
||||
const company = getCompany(nextState.companyId);
|
||||
const tab = getTab(company, nextState.tabId);
|
||||
const chart = tab.charts.find((item) => item.id === nextState.chartId);
|
||||
const view = chart?.views && chart.views[nextState.view]
|
||||
? nextState.view
|
||||
: chart?.defaultView;
|
||||
if (chart?.views && view) activeViews.set(chart.id, view);
|
||||
return { companyId: company.id, tabId: tab.id, chartId: chart?.id, view };
|
||||
}
|
||||
|
||||
function serializeHash(nextState) {
|
||||
const path = [nextState.companyId, nextState.tabId, nextState.chartId]
|
||||
.filter(Boolean)
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
return `#/${path}${nextState.view ? `?view=${encodeURIComponent(nextState.view)}` : ''}`;
|
||||
}
|
||||
|
||||
function disposeAll() {
|
||||
instances.forEach((chart) => chart.dispose());
|
||||
instances.clear();
|
||||
}
|
||||
|
||||
function chartSource(chart) {
|
||||
if (!chart.views) return chart.csv;
|
||||
const view = activeViews.get(chart.id) || chart.defaultView;
|
||||
return chart.views[view];
|
||||
}
|
||||
|
||||
async function renderChart(chart, version) {
|
||||
const el = document.getElementById(`chart-${chart.id}`);
|
||||
if (!el) return;
|
||||
const source = chartSource(chart);
|
||||
|
||||
try {
|
||||
// 加时间戳绕过缓存,改完 CSV 刷新就能看到
|
||||
const rows = await loadCSV(`${spec.csv}?t=${Date.now()}`);
|
||||
const chart = instances.get(spec.id) || echarts.init(el, null, { renderer: 'canvas' });
|
||||
instances.set(spec.id, chart);
|
||||
const rows = await loadCSV(`${source}?t=${cacheToken}`);
|
||||
if (version !== renderVersion || !document.body.contains(el)) return;
|
||||
|
||||
const instance = echarts.init(el, null, { renderer: 'canvas' });
|
||||
instances.set(chart.id, instance);
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
chart.setOption(spec.build(rows, { width, height }), true);
|
||||
} catch (err) {
|
||||
el.innerHTML = `<div class="chart-error">${err.message}</div>`;
|
||||
console.error(spec.id, err);
|
||||
instance.setOption(getChartBuilder(chart.key)(rows, { width, height }), true);
|
||||
} catch (error) {
|
||||
if (version === renderVersion) showChartError(chart.id, error.message);
|
||||
console.error(`渲染图表失败:${chart.id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderAll() {
|
||||
await Promise.all(CHARTS.map(render));
|
||||
function exportPNG(chartId, filename) {
|
||||
const instance = instances.get(chartId);
|
||||
if (!instance) return;
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
|
||||
anchor.download = `${filename}.png`;
|
||||
anchor.click();
|
||||
}
|
||||
|
||||
/** 导出当前图为 PNG,2 倍图,微信公众号里够清晰 */
|
||||
function exportPNG(id, filename) {
|
||||
const chart = instances.get(id);
|
||||
if (!chart) return;
|
||||
const url = chart.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${filename}.png`;
|
||||
a.click();
|
||||
function updateHash(nextState) {
|
||||
const hash = serializeHash(nextState);
|
||||
if (window.location.hash === hash) {
|
||||
state = normalizeState(nextState);
|
||||
renderCurrent();
|
||||
return;
|
||||
}
|
||||
window.location.hash = hash;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderAll();
|
||||
function selectAnchor(chartId) {
|
||||
state = { ...state, chartId, view: activeViews.get(chartId) };
|
||||
history.replaceState(null, '', serializeHash(state));
|
||||
document.getElementById(`card-${chartId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
document.getElementById('btn-reload').addEventListener('click', renderAll);
|
||||
function renderCurrent() {
|
||||
state = normalizeState(state);
|
||||
const company = getCompany(state.companyId);
|
||||
const tab = getTab(company, state.tabId);
|
||||
const version = ++renderVersion;
|
||||
disposeAll();
|
||||
|
||||
document.querySelectorAll('[data-export]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
exportPNG(btn.dataset.export, btn.dataset.filename || btn.dataset.export);
|
||||
renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => {
|
||||
updateHash({ companyId });
|
||||
});
|
||||
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
|
||||
updateHash({ companyId: company.id, tabId });
|
||||
});
|
||||
renderCards(shell.cards, tab.charts || [], activeViews, {
|
||||
onExport: exportPNG,
|
||||
onSelectView: (chartId, view) => {
|
||||
activeViews.set(chartId, view);
|
||||
updateHash({ companyId: company.id, tabId: tab.id, chartId, view });
|
||||
},
|
||||
});
|
||||
renderAnchors(shell.chartNav, tab.charts || [], selectAnchor);
|
||||
Promise.all((tab.charts || []).map((chart) => renderChart(chart, version)));
|
||||
}
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
const response = await fetch('manifest.json');
|
||||
if (!response.ok) throw new Error(`读取 manifest 失败:${response.status}`);
|
||||
manifest = await response.json();
|
||||
validateManifest(manifest);
|
||||
shell = createAppShell(document.getElementById('app'), {
|
||||
onReload: () => {
|
||||
cacheToken = Date.now();
|
||||
renderCurrent();
|
||||
},
|
||||
});
|
||||
});
|
||||
state = normalizeState(parseHash());
|
||||
if (!window.location.hash) history.replaceState(null, '', serializeHash(state));
|
||||
renderCurrent();
|
||||
} catch (error) {
|
||||
document.getElementById('app').textContent = `初始化失败:${error.message}`;
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
let timer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(timer);
|
||||
// resize 后水印数量要跟着画布变,所以整体重绘而不是 chart.resize()
|
||||
timer = setTimeout(renderAll, 200);
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (!manifest) return;
|
||||
state = normalizeState(parseHash());
|
||||
renderCurrent();
|
||||
});
|
||||
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
if (!shell) return;
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(renderCurrent, 200);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
function element(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function button(label, { active = false, onClick, className = '' } = {}) {
|
||||
const el = element('button', className, label);
|
||||
el.type = 'button';
|
||||
el.dataset.active = String(active);
|
||||
if (onClick) el.addEventListener('click', onClick);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function createAppShell(root, { onReload }) {
|
||||
root.replaceChildren();
|
||||
|
||||
const header = element('header', 'app-header');
|
||||
const brand = element('div', 'app-brand');
|
||||
brand.append(element('h1', '', '财报图表'), element('p', '', '选择公司与分析维度,数据更新后可重新加载。'));
|
||||
header.append(brand, button('重新加载数据', { onClick: onReload, className: 'reload-button' }));
|
||||
|
||||
const layout = element('div', 'app-layout');
|
||||
const companyNav = element('aside', 'sidebar company-sidebar');
|
||||
const content = element('main', 'content');
|
||||
const tabs = element('nav', 'tabs');
|
||||
const cards = element('div', 'cards');
|
||||
content.append(tabs, cards);
|
||||
const chartNav = element('aside', 'sidebar chart-sidebar');
|
||||
layout.append(companyNav, content, chartNav);
|
||||
root.append(header, layout);
|
||||
|
||||
return { companyNav, tabs, cards, chartNav };
|
||||
}
|
||||
|
||||
export function renderCompanies(container, companies, activeCompanyId, onSelect) {
|
||||
container.replaceChildren(element('h2', 'sidebar-title', '公司'));
|
||||
const list = element('div', 'company-list');
|
||||
companies.forEach((company) => {
|
||||
list.append(button(company.name, {
|
||||
active: company.id === activeCompanyId,
|
||||
onClick: () => onSelect(company.id),
|
||||
className: 'company-button',
|
||||
}));
|
||||
});
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
export function renderTabs(container, tabs, activeTabId, onSelect) {
|
||||
container.replaceChildren();
|
||||
tabs.forEach((tab) => {
|
||||
container.append(button(tab.name, {
|
||||
active: tab.id === activeTabId,
|
||||
onClick: () => onSelect(tab.id),
|
||||
className: 'tab-button',
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function renderCards(container, charts, activeViews, { onExport, onSelectView }) {
|
||||
container.replaceChildren();
|
||||
if (!charts.length) {
|
||||
container.append(element('p', 'empty-state', '该分类暂时没有图表。'));
|
||||
return;
|
||||
}
|
||||
|
||||
charts.forEach((chart) => {
|
||||
const card = element('section', 'card');
|
||||
card.id = `card-${chart.id}`;
|
||||
const bar = element('div', 'card-bar');
|
||||
const heading = element('div', 'card-heading');
|
||||
heading.append(element('h2', 'card-title', chart.title));
|
||||
|
||||
const viewNames = Object.keys(chart.views || {});
|
||||
const activeView = activeViews.get(chart.id) || chart.defaultView || viewNames[0];
|
||||
const source = chart.views ? chart.views[activeView] : chart.csv;
|
||||
heading.append(element('span', 'src', source));
|
||||
bar.append(heading);
|
||||
|
||||
if (viewNames.length > 1) {
|
||||
const switcher = element('div', 'view-switcher');
|
||||
const labels = { annual: '年度', halfyear: '半年', quarter: '季度' };
|
||||
viewNames.forEach((view) => {
|
||||
switcher.append(button(labels[view] || view, {
|
||||
active: view === activeView,
|
||||
onClick: () => onSelectView(chart.id, view),
|
||||
className: 'view-button',
|
||||
}));
|
||||
});
|
||||
bar.append(switcher);
|
||||
}
|
||||
|
||||
bar.append(button('导出 PNG', {
|
||||
onClick: () => onExport(chart.id, chart.filename || chart.title),
|
||||
className: 'export-button',
|
||||
}));
|
||||
const chartEl = element('div', 'chart');
|
||||
chartEl.id = `chart-${chart.id}`;
|
||||
chartEl.style.height = `${chart.height || 680}px`;
|
||||
card.append(bar, chartEl);
|
||||
container.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderAnchors(container, charts, onSelect) {
|
||||
container.replaceChildren(element('h2', 'sidebar-title', '当前图表'));
|
||||
const list = element('div', 'anchor-list');
|
||||
charts.forEach((chart) => {
|
||||
container.append(button(chart.title, {
|
||||
onClick: () => onSelect(chart.id),
|
||||
className: 'anchor-button',
|
||||
}));
|
||||
});
|
||||
if (!charts.length) container.append(element('p', 'sidebar-empty', '暂无图表'));
|
||||
}
|
||||
|
||||
export function showChartError(chartId, message) {
|
||||
const chartEl = document.getElementById(`chart-${chartId}`);
|
||||
if (!chartEl) return;
|
||||
chartEl.replaceChildren(element('div', 'chart-error', message));
|
||||
}
|
||||
Reference in New Issue
Block a user