247 lines
16 KiB
JavaScript
247 lines
16 KiB
JavaScript
function element(tag, className, text) {
|
||
const el = document.createElement(tag);
|
||
if (className) el.className = className;
|
||
if (text !== undefined) el.textContent = text;
|
||
return el;
|
||
}
|
||
|
||
/**
|
||
* 右上角「重新加载数据」按钮的图标。
|
||
* 内联 SVG 而不是外部文件/字体:省一次请求,且 fill 能直接继承按钮的 currentColor。
|
||
* 原始导出里的 t / p-id 等 iconfont 冗余属性已剔除,只保留 viewBox + path。
|
||
*/
|
||
const RELOAD_ICON_PATH =
|
||
'M249.836 210.77c5.558-3.827 10.73-7.527 15.812-10.695 4.967-3.314 9.807-6.121 14.208-8.785 2.18-1.362 4.374-2.515 6.425-3.673l5.873-3.281c0.945-0.551 1.789-0.953 2.625-1.377l2.425-1.226 4.381-2.233 1.954-1.004 2.219-1.093 3.918-1.924 7.184-3.527c2.883-1.44 5.996-3.272 8.694-5.22 26.969-19.458 33.057-57.094 13.599-84.063-19.458-26.968-57.094-33.057-84.063-13.599l-6.49 4.682-3.54 2.554-2.071 1.499-2.62 1.947-5.824 4.351a7267.87 7267.87 0 0 0-3.193 2.399c-1.091 0.824-2.25 1.676-3.282 2.533a33346.62 33346.62 0 0 1-6.735 5.441c-2.343 1.915-4.833 3.859-7.308 6.046-4.991 4.313-10.423 8.905-15.952 14.121-5.645 5.071-11.353 10.792-17.394 16.739-5.886 6.125-12.079 12.515-18.185 19.505-12.303 13.875-24.827 29.53-36.839 46.835a494.61 494.61 0 0 0-33.896 56.497 491.342 491.342 0 0 0-27.329 64.159c-1.02 2.77-1.888 5.605-2.789 8.428l-2.688 8.477c-1.692 5.691-3.266 11.429-4.857 17.152l-2.135 8.669c-0.683 2.896-1.479 5.756-2.051 8.682l-1.84 8.729-0.914 4.358-0.785 4.387a479.254 479.254 0 0 0-7.583 69.967c-0.302 5.759-0.239 11.514-0.279 17.214l-0.017 4.267c-0.001 1.42 0.069 2.841 0.101 4.257l0.25 8.464 0.127 4.204 0.264 4.188 0.531 8.317 0.278 4.451 0.399 3.933 0.792 7.821c0.14 1.313 0.244 2.561 0.412 3.918l0.527 4.098 1.056 8.115c0.185 1.337 0.33 2.695 0.549 4.002l0.648 3.91 1.289 7.744c1.825 10.246 3.911 20.188 6.263 29.766a463.195 463.195 0 0 0 16.342 52.81l2.255 5.904 1.108 2.899 1.186 2.836 4.611 10.976c3.247 7.033 6.19 13.723 9.353 19.831 6.048 12.328 12.043 22.661 17.007 31.096 5.121 8.359 9.212 14.84 12.197 19.141l4.472 6.638-3.293-7.292c-2.209-4.713-5.148-11.776-8.748-20.761-3.443-9.059-7.545-20.055-11.346-32.919-2.041-6.376-3.771-13.301-5.726-20.516l-2.612-11.204-0.667-2.878-0.583-2.935-1.183-5.957c-3.105-16.055-5.568-33.549-6.889-52.052-0.668-9.251-1.027-18.753-1.1-28.437l0.03-7.3 0.014-3.67c0.002-1.227 0.084-2.416 0.122-3.629l0.284-7.263 0.139-3.644c0.053-1.229 0.166-2.577 0.247-3.863a35704.717 35704.717 0 0 1 0.798-11.787l0.017-0.247c0.013 0.588 0.018 0.028 0.027 0.098l0.05-0.472 0.101-0.945 0.2-1.891 0.8-7.581 0.4-3.801 0.535-3.801 1.069-7.62c0.187-1.271 0.335-2.545 0.555-3.815l0.67-3.811c0.909-5.082 1.708-10.185 2.859-15.253a424.734 424.734 0 0 1 17.057-60.03l1.316-3.663 1.437-3.623 2.859-7.225c0.907-2.417 2.03-4.761 3.033-7.135l3.079-7.082c2.208-4.653 4.37-9.295 6.624-13.867l3.508-6.783c1.167-2.252 2.294-4.513 3.565-6.702a414.503 414.503 0 0 1 31.89-49.502 407.116 407.116 0 0 1 35.476-41.25 406.598 406.598 0 0 1 35.793-32.288c5.751-4.687 11.515-8.857 16.918-12.861zM1023.966 510.486l-0.001-0.005-0.875-7.954-1.051-9.554-0.687-6.125-1.153-8.152c-0.828-5.729-1.741-12.089-2.813-18.907l-4.259-20.887c-0.691-3.726-1.738-7.517-2.729-11.425l-3.102-12.024c-0.558-2.048-1.019-4.142-1.677-6.221l-1.958-6.314-4.07-13.021-1.049-3.333-1.211-3.324c-0.814-2.223-1.638-4.467-2.469-6.732-1.673-4.522-3.34-9.131-5.115-13.781l-5.938-13.981-3.046-7.123c-1.037-2.384-2.247-4.72-3.375-7.102l-6.997-14.388c-0.569-1.22-1.222-2.408-1.877-3.598l-1.958-3.583-3.95-7.211-3.995-7.265c-1.354-2.422-2.881-4.773-4.324-7.175l-8.875-14.427c-12.567-18.998-26.533-37.793-42.018-55.643-15.662-17.71-32.638-34.534-50.581-50.045a527.617 527.617 0 0 0-56.726-41.602c-19.622-12.201-39.702-22.647-59.484-31.64l-14.925-6.188c-2.47-0.995-4.881-2.072-7.356-2.979l-7.402-2.651-7.306-2.609-3.615-1.289c-1.198-0.434-2.394-0.863-3.618-1.21l-14.37-4.353c-2.366-0.691-4.68-1.466-7.035-2.069l-7.02-1.754-13.669-3.402c-4.524-0.941-8.985-1.788-13.341-2.655l-6.47-1.271-3.184-0.621-3.186-0.465-12.373-1.797-5.96-0.862c-1.957-0.298-3.928-0.403-5.85-0.61l-11.239-1.057c-3.638-0.329-7.151-0.735-10.598-0.81l-19.18-0.823-16.258 0.11-6.935 0.057c-2.177 0.091-4.219 0.237-6.115 0.34l-9.619 0.566-7.982 0.472 7.959 0.875 9.529 1.05c1.872 0.215 3.884 0.411 6.023 0.683l6.797 1.101 15.801 2.578 18.393 3.951c3.283 0.628 6.614 1.603 10.051 2.514l10.571 2.838c1.801 0.509 3.644 0.92 5.469 1.526l5.542 1.793 11.432 3.697 2.927 0.946 2.914 1.104 5.902 2.242c3.965 1.517 8.007 3.017 12.084 4.613l12.243 5.366 6.241 2.733c2.088 0.929 4.128 2.029 6.212 3.044l12.59 6.277c1.068 0.506 2.106 1.094 3.145 1.685l3.131 1.761 6.302 3.542 6.353 3.568c2.118 1.209 4.165 2.586 6.263 3.878l12.601 7.922c16.565 11.247 32.949 23.681 48.48 37.44a473.639 473.639 0 0 1 43.435 44.87 467.917 467.917 0 0 1 35.892 50.115c10.476 17.304 19.389 34.971 27.062 52.322 1.761 4.404 3.506 8.772 5.234 13.098 0.843 2.166 1.771 4.273 2.532 6.444l2.224 6.49 2.201 6.396 1.093 3.162c0.368 1.047 0.734 2.092 1.019 3.166l3.633 12.572c0.578 2.068 1.242 4.086 1.738 6.146l1.441 6.137 2.829 11.931 2.152 11.645 1.045 5.64 0.514 2.772 0.362 2.779 1.423 10.784 0.697 5.188c0.245 1.704 0.3 3.423 0.458 5.098l0.794 9.786c0.249 3.167 0.584 6.22 0.595 9.223l0.516 16.686-0.346 13.152-0.109 5.579-0.343 6.014-0.567 9.595-0.472 7.988c-0.169 3.219-0.107 6.829 0.256 10.138 3.638 33.056 33.383 56.904 66.438 53.267 33.056-3.637 56.905-33.382 53.268-66.438zM885.176 762.451l-4.868 5.897c-1.711 2.052-3.44 4.238-5.4 6.399-3.858 4.365-7.946 9.128-12.608 13.957-4.517 4.944-9.632 9.919-14.922 15.205-5.467 5.131-11.147 10.547-17.374 15.864-12.349 10.726-26.262 21.624-41.613 32.046a434.435 434.435 0 0 1-50.026 29.303 431.157 431.157 0 0 1-56.59 23.39c-2.436 0.874-4.935 1.6-7.419 2.361l-7.455 2.279c-5.006 1.425-10.053 2.742-15.078 4.091-2.536 0.593-5.075 1.187-7.617 1.78-2.545 0.566-5.051 1.25-7.622 1.714l-7.663 1.523-3.822 0.763-3.851 0.64a419.278 419.278 0 0 1-61.304 5.938c-5.039 0.219-10.081 0.088-15.068 0.072l-3.733-0.022c-1.238-0.012-2.392-0.1-3.587-0.145l-7.067-0.313-3.505-0.146-3.997-0.27-7.961-0.537-1.981-0.133-0.989-0.067-0.494-0.033c0.221-0.008-0.844-0.005 0.433-0.022l-0.226-0.025-3.593-0.396-7.127-0.78a406.62 406.62 0 0 1-53.494-10.013 402.48 402.48 0 0 1-45.808-14.792l-5.111-2.022-2.511-0.99-2.452-1.066-9.494-4.124c-6.074-2.917-11.861-5.522-17.131-8.352-10.648-5.379-19.552-10.718-26.826-15.11l-1.338-0.807a1085.391 1085.391 0 0 0-3.457-2.276l-4.116-2.687c-0.638-0.414-1.252-0.813-1.842-1.194l-2.021-1.354-3.621-2.439-6.637-4.472c-2.685-1.782-5.825-3.567-8.857-4.938-30.307-13.689-65.973-0.218-79.662 30.089-13.689 30.308-0.218 65.974 30.089 79.663l7.293 3.294 3.979 1.797 2.369 1.063 2.994 1.302c2.073 0.896 4.3 1.857 6.676 2.885l3.672 1.574 1.917 0.819 1.9 0.745c10.22 4.014 22.646 8.772 37.189 13.242 7.215 2.375 15.042 4.443 23.216 6.727l12.687 3.1 3.261 0.787 3.325 0.701 6.75 1.415c18.195 3.715 38.044 6.719 59.054 8.439a494.284 494.284 0 0 0 65.863 0.997l8.535-0.519 4.285-0.266 0.268-0.017c1.44-0.02 0.541-0.02 0.927-0.03l0.492-0.051 0.986-0.1 1.977-0.201 7.938-0.807 3.991-0.406 4.502-0.554 9.015-1.133c1.492-0.2 3.034-0.365 4.486-0.598l4.344-0.709c5.795-0.965 11.61-1.832 17.399-3.066a485.178 485.178 0 0 0 68.692-18.701l4.2-1.463 4.157-1.588 8.292-3.176c2.771-1.016 5.468-2.251 8.194-3.37l8.134-3.434c5.351-2.451 10.685-4.869 15.941-7.39l7.805-3.918c2.591-1.307 5.188-2.576 7.712-3.991a474.344 474.344 0 0 0 57.071-35.83 467.174 467.174 0 0 0 47.72-40.068 466.596 466.596 0 0 0 37.51-40.586c5.455-6.542 10.335-13.082 14.998-19.24 4.483-6.313 8.789-12.218 12.512-18 3.868-5.667 7.179-11.177 10.297-16.204 1.589-2.492 2.957-4.989 4.318-7.332 1.349-2.348 2.635-4.586 3.854-6.711 4.686-8.611 8.271-15.384 10.509-20.116l3.527-7.185-4.682 6.489c-2.985 4.266-7.65 10.33-13.635 17.935z';
|
||
|
||
function refreshIcon() {
|
||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('class', 'reload-icon');
|
||
svg.setAttribute('viewBox', '0 0 1024 1024');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.setAttribute('focusable', 'false');
|
||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
path.setAttribute('d', RELOAD_ICON_PATH);
|
||
svg.append(path);
|
||
return svg;
|
||
}
|
||
|
||
function button(label, { active = false, onClick, className = '' } = {}) {
|
||
const el = document.createElement('button');
|
||
if (className) el.className = className;
|
||
if (label instanceof Node) el.append(label);
|
||
else if (label !== undefined) el.textContent = 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 reloadButton = button(refreshIcon(), { onClick: onReload, className: 'reload-button' });
|
||
reloadButton.title = '重新加载数据';
|
||
reloadButton.setAttribute('aria-label', '重新加载数据');
|
||
|
||
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);
|
||
const cartBar = element('div', 'cart-bar');
|
||
cartBar.hidden = true;
|
||
root.append(layout, reloadButton, cartBar);
|
||
|
||
return { companyNav, tabs, cards, chartNav, cartBar };
|
||
}
|
||
|
||
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, isCarted, onToggleCart, 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.instanceId}`;
|
||
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.viewKey) || 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 = { all: '全部', annual: '年度', halfyear: '半年', quarter: '季度' };
|
||
viewNames.forEach((view) => {
|
||
const viewButton = button(labels[view] || view, {
|
||
active: view === activeView,
|
||
onClick: () => onSelectView(chart, view),
|
||
className: 'view-button',
|
||
});
|
||
viewButton.dataset.view = view;
|
||
switcher.append(viewButton);
|
||
});
|
||
bar.append(switcher);
|
||
}
|
||
|
||
const cartButton = button(isCarted?.(chart.instanceId) ? '已加入 ✓' : '➕ 加入合并', {
|
||
onClick: () => onToggleCart?.(chart),
|
||
className: 'cart-button',
|
||
});
|
||
cartButton.dataset.cartId = chart.instanceId;
|
||
cartButton.dataset.inCart = String(Boolean(isCarted?.(chart.instanceId)));
|
||
bar.append(cartButton, button('导出 PNG', {
|
||
onClick: () => onExport(chart.instanceId, chart.filename || chart.title),
|
||
className: 'export-button',
|
||
}));
|
||
const chartEl = element('div', 'chart');
|
||
chartEl.id = `chart-${chart.instanceId}`;
|
||
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));
|
||
}
|
||
|
||
const CART_MODES = [
|
||
['pairs', '两两合并'],
|
||
['single', '合并为一张'],
|
||
];
|
||
|
||
export function renderCartBar(container, { items = [], mode, onModeChange, onMove, onRemove, onClear, onPreview }) {
|
||
container.replaceChildren();
|
||
if (!items.length) {
|
||
container.hidden = true;
|
||
return;
|
||
}
|
||
container.hidden = false;
|
||
|
||
container.append(element('span', 'cart-label', `合并队列 ${items.length}`));
|
||
|
||
const chips = element('div', 'cart-chips');
|
||
items.forEach((item, index) => {
|
||
const chip = element('div', 'cart-chip');
|
||
chip.title = item.title;
|
||
const up = button('↑', { onClick: () => onMove?.(item.instanceId, -1), className: 'cart-chip-button' });
|
||
up.disabled = index === 0;
|
||
const down = button('↓', { onClick: () => onMove?.(item.instanceId, 1), className: 'cart-chip-button' });
|
||
down.disabled = index === items.length - 1;
|
||
chip.append(
|
||
element('span', 'cart-chip-name', item.title),
|
||
up,
|
||
down,
|
||
button('✕', { onClick: () => onRemove?.(item.instanceId), className: 'cart-chip-button' }),
|
||
);
|
||
chips.append(chip);
|
||
});
|
||
container.append(chips);
|
||
|
||
container.append(element('span', 'cart-divider'));
|
||
|
||
const modes = element('div', 'cart-modes');
|
||
CART_MODES.forEach(([value, label]) => {
|
||
modes.append(button(label, {
|
||
active: mode === value,
|
||
onClick: () => onModeChange?.(value),
|
||
className: 'cart-mode-button',
|
||
}));
|
||
});
|
||
container.append(modes);
|
||
|
||
container.append(button('预览导出', { onClick: () => onPreview?.(), className: 'cart-export-button' }));
|
||
container.append(button('清空', { onClick: () => onClear?.(), className: 'cart-clear-button' }));
|
||
}
|
||
|
||
export function showMergePreview(entries, { notice = '', onDownload, onDownloadAll, onClose }) {
|
||
const overlay = element('div', 'modal-overlay');
|
||
const modal = element('div', 'modal');
|
||
|
||
const head = element('div', 'modal-head');
|
||
head.append(element('h2', 'modal-title', `合并预览 · ${entries.length} 张`));
|
||
if (notice) head.append(element('span', 'modal-notice', notice));
|
||
head.append(button('✕', { onClick: onClose, className: 'modal-close' }));
|
||
|
||
const body = element('div', 'modal-body');
|
||
entries.forEach((entry) => {
|
||
const item = element('div', 'preview-item');
|
||
entry.canvas.className = 'preview-canvas';
|
||
item.append(entry.canvas);
|
||
item.append(button(`⬇ 下载:${entry.filename}.png`, {
|
||
onClick: () => onDownload?.(entry),
|
||
className: 'export-button',
|
||
}));
|
||
body.append(item);
|
||
});
|
||
|
||
const foot = element('div', 'modal-foot');
|
||
foot.append(button('全部下载', { onClick: () => onDownloadAll?.(), className: 'primary-button' }));
|
||
foot.append(button('取消', { onClick: onClose, className: 'export-button' }));
|
||
|
||
modal.append(head, body, foot);
|
||
overlay.append(modal);
|
||
overlay.addEventListener('click', (event) => {
|
||
if (event.target === overlay) onClose?.();
|
||
});
|
||
document.addEventListener('keydown', onKeydown);
|
||
document.body.append(overlay);
|
||
|
||
function onKeydown(event) {
|
||
if (event.key === 'Escape') onClose?.();
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('keydown', onKeydown);
|
||
overlay.remove();
|
||
};
|
||
}
|