Files
2026-10-22 15:43:17 +08:00

319 lines
19 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.
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');
// tab 栏与参数行共处一个 sticky 容器:参数行是独立的一行(不是 tab 栏的一部分),
// 但要和 tab 栏一起钉在顶部。若让两行各自 sticky,参数行就得设「tab 栏高度」的 top
// 偏移,而 tab 栏会换行、窄屏还会横向滚动,高度并不固定;绑在一个容器里天然不会错位。
const tabHeader = element('div', 'tab-header');
const tabs = element('nav', 'tabs');
const params = element('div', 'tab-controls');
params.hidden = true;
tabHeader.append(tabs, params);
const cards = element('div', 'cards');
content.append(tabHeader, 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 { layout, companyNav, tabs, params, 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) => {
if (company.kind === 'tools') list.append(element('h2', 'sidebar-title tools-title', '工具'));
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',
}));
});
}
/**
* tab 级数值参数控件(目前只有「现价」)—— 由 manifest 的 tab.controls 声明。
*
* 位置:页面里独立的一行,钉在 tab 栏正下方(见 createAppShell 的 .tab-header)。
* 不塞进 tab 栏:它是改数据口径的输入,混在导航按钮里容易被当成 tab 误点。
*
* 回调约定:onChange(id, rawValue) 返回 false 表示这次输入不合法(越界/非数字),
* 输入框标红示意,图表保持原样 —— 校验规则属于控制层,壳层不重复实现。
*/
export function renderTabControls(container, controls, values, onChange) {
// 容器是常驻的,每次整屏重渲染都要清空;否则 resize(200ms 防抖)会叠出多行控件
container.replaceChildren();
container.hidden = !controls?.length;
if (!controls?.length) return;
controls.forEach((control) => {
const field = element('label', 'param-field');
if (control.hint) field.title = control.hint;
field.append(element('span', 'param-label', control.label));
const input = document.createElement('input');
input.className = 'param-input';
input.type = 'number';
input.inputMode = 'decimal';
input.step = String(control.step ?? 1);
input.min = String(control.min);
input.max = String(control.max);
input.value = String(values?.[control.id] ?? control.value);
input.setAttribute('aria-label', `${control.label}${control.unit ?? ''}`);
field.append(input, element('span', 'param-unit', control.unit ?? ''));
// 合法区间常驻显示:输入越界时它变红,用户不必猜上下限
field.append(element('span', 'param-range', `${control.min}–${control.max}`));
const reset = button('重置', {
className: 'param-reset',
onClick: () => {
input.value = String(control.value);
input.dataset.invalid = 'false';
// 值已回到默认,按钮自己进入禁用态(不等下一次 input 事件,否则点完还亮着)
reset.disabled = true;
onChange?.(control.id, input.value, { immediate: true });
input.focus();
},
});
reset.title = `回到默认值 ${control.value}`;
const apply = (immediate = false) => {
const ok = onChange?.(control.id, input.value, { immediate }) !== false;
input.dataset.invalid = String(!ok);
reset.disabled = input.value === String(control.value);
};
input.addEventListener('input', () => apply());
// 回车立即生效,不等防抖(防抖只针对连续输入)
input.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
apply(true);
});
reset.disabled = input.value === String(control.value);
container.append(field, reset);
});
}
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();
};
}