UI коструктора отчетов
This commit is contained in:
270
resources/js/Pages/Analytics/Components/ReportChart.vue
Normal file
270
resources/js/Pages/Analytics/Components/ReportChart.vue
Normal 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>
|
||||
Reference in New Issue
Block a user