feat: local Bootstrap Vue ECharts dashboards and universal genealogy write scope

This commit is contained in:
Codex
2026-10-08 22:41:19 +08:00
parent d8826152c6
commit fb87ae4de9
33 changed files with 980 additions and 110 deletions
+50
View File
@@ -0,0 +1,50 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
const props = defineProps({ option: { type: Object, required: true }, label: String, active: { type: Boolean, default: true }, height: { type: Number, default: 260 } });
const emit = defineEmits(['select']);
const host = ref(null), failed = ref(false);
let chart, resizeObserver, themeObserver, frame, alive = true, loading = false, last = '', size = '';
function dispose() { chart?.dispose(); chart = null; last = ''; size = ''; }
function queue() { if (!frame && alive) frame = requestAnimationFrame(() => { frame = null; render(); }); }
async function render() {
if (!alive || !host.value || !props.active) { dispose(); return; }
const width = Math.floor(host.value.clientWidth), height = Math.floor(host.value.clientHeight);
if (!width || !height) return;
if (!chart) {
if (loading) return;
loading = true;
try {
const runtime = await import('../composables/charts-runtime.js');
if (!alive || !props.active || !host.value) return;
chart = runtime.init(host.value, null, { renderer: 'canvas', devicePixelRatio: Math.min(window.devicePixelRatio || 1, 2) });
chart.on('click', value => { if (value.data?.targetId) emit('select', value.data.targetId); });
failed.value = false;
} catch (error) { failed.value = true; console.error('Local chart unavailable', error); return; }
finally { loading = false; }
}
const css = getComputedStyle(document.documentElement), ink = css.getPropertyValue('--ink').trim() || '#253b46', muted = css.getPropertyValue('--ui-muted').trim() || '#627485', line = css.getPropertyValue('--ui-line').trim() || '#e3e9ef';
const fingerprint = JSON.stringify(props.option) + ink + muted + line;
// Periodic status refreshes must not restart chart layout/animation.
if (fingerprint !== last) {
const option = { ...props.option, animation: false, backgroundColor: 'transparent', textStyle: { color: ink, fontFamily: 'system-ui, Microsoft YaHei, sans-serif' }, aria: { enabled: true, description: props.label }, tooltip: { trigger: 'item', renderMode: 'richText', confine: true, ...props.option.tooltip } };
for (const axis of ['xAxis', 'yAxis']) if (option[axis]) option[axis] = { ...option[axis], axisLabel: { color: muted, ...option[axis].axisLabel }, splitLine: { lineStyle: { color: line } } };
option.series = (option.series || []).map(series => ({ ...series, label: { color: ink, ...series.label } }));
chart.setOption(option, { notMerge: true, lazyUpdate: false }); last = fingerprint;
}
const nextSize = width + ':' + height;
if (nextSize !== size) { chart.resize({ width, height }); size = nextSize; }
}
watch(() => [props.option, props.active], queue, { deep: true, flush: 'post' });
onMounted(() => {
resizeObserver = new ResizeObserver(queue); resizeObserver.observe(host.value);
themeObserver = new MutationObserver(queue); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-density'] });
queue();
});
onBeforeUnmount(() => { alive = false; cancelAnimationFrame(frame); resizeObserver?.disconnect(); themeObserver?.disconnect(); dispose(); });
</script>
<template>
<div class="local-chart-wrap">
<div ref="host" class="local-chart" role="img" :aria-label="label" :style="{ height: height + 'px' }"></div>
<p v-if="failed" class="small text-secondary px-3" role="status">图表暂未加载,可查看下方数据。</p>
</div>
</template>