UI коструктора отчетов

This commit is contained in:
brusnitsyn
2026-06-22 17:02:36 +09:00
parent bdb16dac54
commit 13dfcc3e05
11 changed files with 1664 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
<script setup>
import { computed } from 'vue'
import { NSelect, NEmpty, useThemeVars } from 'naive-ui'
import VueApexCharts from 'vue3-apexcharts'
const props = defineProps({
columns: { type: Array, default: () => [] },
rows: { type: Array, default: () => [] },
chart: { type: Object, default: () => ({ metric: null, type: 'bar' }) },
mode: { type: String, default: 'period' },
metricOptions: { type: Array, default: () => [] }, // [{ label, value }]
})
const emit = defineEmits(['update:chart'])
const themeVars = useThemeVars()
// --- Стили графиков, перенесённые со страницы «Статистика» (Headquarters) ---
const FONT = "'v-sans', ui-sans-serif, system-ui, sans-serif"
const cAxis = computed(() => themeVars.value.textColor3)
const cGrid = computed(() => themeVars.value.dividerColor)
// soften(color) = цвет темы, разбавленный фоном карточки (ApexCharts не понимает color-mix)
const SOFT_AMOUNT = 0.35
function parseColor(c) {
if (!c) return { r: 0, g: 0, b: 0 }
c = c.trim()
if (c[0] === '#') {
let h = c.slice(1)
if (h.length === 3) h = h.split('').map((x) => x + x).join('')
const n = parseInt(h, 16)
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
}
const m = c.match(/rgba?\(([^)]+)\)/i)
if (m) {
const [r, g, b] = m[1].split(',').map((s) => parseFloat(s))
return { r, g, b }
}
return { r: 0, g: 0, b: 0 }
}
function toHex({ r, g, b }) {
const h = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')
return `#${h(r)}${h(g)}${h(b)}`
}
function soften(color, amount = SOFT_AMOUNT) {
const a = parseColor(color)
const b = parseColor(themeVars.value.cardColor)
return toHex({
r: a.r * (1 - amount) + b.r * amount,
g: a.g * (1 - amount) + b.g * amount,
b: a.b * (1 - amount) + b.b * amount,
})
}
// Семантические цвета + различимая палитра (без двух зелёных, как было раньше).
// Приглушённая палитра (для заливок: столбцы/донат) и яркая (для тонких линий).
const buildColors = (vivid) => {
const f = vivid ? (c) => c : (c) => soften(c)
return {
green: f(themeVars.value.successColor),
red: f(themeVars.value.errorColor),
gray: f(themeVars.value.textColor3),
blue: f(themeVars.value.infoColor),
orange: f(themeVars.value.warningColor),
purple: f('#8a63d2'),
teal: f('#13c2c2'),
pink: f('#eb6ea5'),
}
}
const SOFT = computed(() => buildColors(false))
const VIVID = computed(() => buildColors(true))
const fallbackPalette = (c) => [c.green, c.blue, c.orange, c.purple, c.teal, c.pink, c.gray, c.red]
// Цвет категории: экстренное/смерть → красный, плановое → серый, иначе — различимый из палитры.
function colorFor(label, index, vivid = false) {
const c = vivid ? VIVID.value : SOFT.value
const t = String(label ?? '').toLowerCase()
if (/экстрен|неотлож|emergency/.test(t)) return c.red
if (/умер|смерт|летал|decease|death/.test(t)) return c.red
if (/планов|plan/.test(t)) return c.gray
const p = fallbackPalette(c)
return p[index % p.length]
}
const baseChart = (type) => ({
type,
background: 'transparent',
toolbar: { show: false },
fontFamily: FONT,
animations: { enabled: true, speed: 600, dynamicAnimation: { enabled: true, speed: 400 } },
})
const baseTooltip = computed(() => ({ theme: 'dark', style: { fontSize: '12px', fontFamily: FONT } }))
const baseGrid = computed(() => ({ borderColor: cGrid.value, strokeDashArray: 4, xaxis: { lines: { show: false } } }))
const typeOptions = [
{ label: 'Столбцы', value: 'bar' },
{ label: 'Линия', value: 'line' },
{ label: 'Круговая', value: 'donut' },
]
const dimensionCols = computed(() => props.columns.filter((c) => c.kind === 'dimension'))
const periodCols = computed(() => props.columns.filter((c) => c.kind === 'period'))
const metric = computed(() => props.chart.metric ?? props.metricOptions[0]?.value ?? null)
const metricLabel = computed(() => props.metricOptions.find((m) => m.value === metric.value)?.label ?? 'Показатель')
// Режим «сравнение показателей»: слайсы/столбцы = сами показатели (Поступило/Выписано/…),
// а не категории измерения. Доступен, когда выбрано ≥2 показателей и это не динамика.
const ALL_METRICS = '__all'
const isAllMetrics = computed(() => metric.value === ALL_METRICS && props.mode !== 'dynamics')
const metricSelectOptions = computed(() => {
const opts = props.metricOptions.map((m) => ({ label: m.label, value: m.value }))
if (opts.length > 1) opts.unshift({ label: 'Все показатели (сравнение)', value: ALL_METRICS })
return opts
})
const setMetric = (value) => emit('update:chart', { ...props.chart, metric: value })
const setType = (value) => emit('update:chart', { ...props.chart, type: value })
const num = (v) => {
const n = Number(v)
return Number.isNaN(n) ? 0 : n
}
const apexType = computed(() => {
if (props.chart.type === 'donut') return props.mode === 'dynamics' ? 'line' : 'donut'
return props.chart.type === 'line' ? 'line' : 'bar'
})
const categories = computed(() => {
if (props.mode === 'dynamics') return periodCols.value.map((c) => c.label)
if (isAllMetrics.value) return props.metricOptions.map((m) => m.label)
const dim = dimensionCols.value[0]
return dim ? props.rows.map((r) => r[dim.key]) : props.rows.map((_, i) => `#${i + 1}`)
})
const series = computed(() => {
if (props.mode === 'dynamics') {
const labelKey = dimensionCols.value[0]?.key ?? '__metric'
return props.rows.map((r) => ({
name: r[labelKey] ?? metricLabel.value,
data: periodCols.value.map((c) => num(r[c.key])),
}))
}
// Сравнение показателей: каждое значение = сумма показателя по всем строкам.
if (isAllMetrics.value) {
const sums = props.metricOptions.map((m) => props.rows.reduce((s, r) => s + num(r[m.value]), 0))
return apexType.value === 'donut' ? sums : [{ name: 'Показатели', data: sums }]
}
const data = props.rows.map((r) => num(r[metric.value]))
return apexType.value === 'donut' ? data : [{ name: metricLabel.value, data }]
})
// Раскрашивать каждый столбец отдельно, когда это одна серия по нескольким категориям
// (напр. срочность/исход/показатели) и категорий немного.
const distributedBar = computed(() =>
props.mode !== 'dynamics' && apexType.value === 'bar' && (isAllMetrics.value || categories.value.length <= 12),
)
const isLine = computed(() => apexType.value === 'line')
const chartColors = computed(() => {
// Линии берут яркие цвета, заливки (столбцы/донат) — приглушённые.
if (props.mode === 'dynamics') {
return series.value.map((s, i) => colorFor(s.name, i, isLine.value))
}
// Круговая и «распределённый» столбчатый: цвет на категорию.
if (apexType.value === 'donut' || distributedBar.value) {
return categories.value.map((c, i) => colorFor(c, i, false))
}
// Одна серия линией/столбцами без распределения — один цвет.
return [colorFor(metricLabel.value, 0, isLine.value)]
})
const options = computed(() => {
const base = {
chart: baseChart(apexType.value),
colors: chartColors.value,
dataLabels: { enabled: false },
tooltip: baseTooltip.value,
legend: { position: 'bottom', labels: { colors: cAxis.value }, fontSize: '12px', fontFamily: FONT, markers: { size: 6, shape: 'circle' } },
noData: { text: 'Нет данных', style: { fontFamily: FONT } },
}
if (apexType.value === 'donut') {
return {
...base,
labels: categories.value,
stroke: { width: 0 },
legend: { ...base.legend, position: 'right' },
plotOptions: { pie: { donut: { size: '62%' } } },
}
}
return {
...base,
grid: baseGrid.value,
plotOptions: { bar: { borderRadius: 5, borderRadiusApplication: 'end', columnWidth: '60%', distributed: distributedBar.value } },
legend: { ...base.legend, show: props.mode === 'dynamics' },
stroke: isLine.value
? { width: 4, curve: 'smooth', lineCap: 'round' }
: { width: 0 },
markers: isLine.value
? { size: 5, strokeWidth: 0, hover: { sizeOffset: 2 } }
: { size: 0 },
fill: { type: 'solid', opacity: 1 },
xaxis: {
categories: categories.value,
labels: {
style: { colors: cAxis.value, fontSize: '11px', fontFamily: FONT },
rotate: -40,
rotateAlways: categories.value.length > 6,
hideOverlappingLabels: true,
trim: true,
},
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT } }, tickAmount: 4 },
}
})
const hasData = computed(() => props.rows.length > 0 && series.value.length > 0)
</script>
<template>
<div class="chart-wrap">
<div class="chart-toolbar">
<NSelect
v-if="metricOptions.length"
:value="metric"
:options="metricSelectOptions"
size="small"
style="width: 240px;"
:disabled="mode === 'dynamics' && metricOptions.length <= 1"
@update:value="setMetric"
/>
<NSelect
:value="chart.type"
:options="typeOptions"
size="small"
style="width: 140px;"
@update:value="setType"
/>
</div>
<NEmpty v-if="!hasData" description="Диаграмма появится после формирования отчёта" style="padding: 32px;" />
<VueApexCharts
v-else
:key="apexType + '|' + mode"
:type="apexType"
height="280"
:options="options"
:series="series"
/>
</div>
</template>
<style scoped>
.chart-wrap { position: relative; }
.chart-toolbar {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
</style>