feat(self-operated): add charts and continuous collection status
This commit is contained in:
@@ -1,43 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Quantity } from '../types'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { ECElementEvent } from 'echarts'
|
||||
import { quantityText } from '../state'
|
||||
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
|
||||
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string; ranked?: boolean }>()
|
||||
import { barChartOption, type BarRow } from '../dashboardChartOptions'
|
||||
import AnalyticsChart from './AnalyticsChart.vue'
|
||||
import DashboardChartHeading from './DashboardChartHeading.vue'
|
||||
export type { BarRow } from '../dashboardChartOptions'
|
||||
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string; ranked?: boolean; detailsWidth?: string }>()
|
||||
const emit = defineEmits<{ select: [key: string] }>()
|
||||
const focused = ref('')
|
||||
const maximum = computed(() => Math.max(1, ...props.rows.map(row => Number(row.value ?? 0))))
|
||||
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
|
||||
function color(index: number) { return props.ranked ? palette[0] : palette[index % palette.length] }
|
||||
const option = computed(() => barChartOption(props.rows, props.unit, props.title, props.ranked))
|
||||
const detailsOpen = ref(false)
|
||||
watch(() => props.rows, () => { detailsOpen.value = false })
|
||||
function select(row: BarRow) { if (!props.disabled && row.value != null) { detailsOpen.value = false; emit('select', row.key) } }
|
||||
function selectChart(event: ECElementEvent) {
|
||||
const row = event.componentType === 'yAxis' ? props.rows.find(row => row.key === String(event.value)) : event.componentType === 'series' ? props.rows[event.dataIndex] : undefined
|
||||
if (row) select(row)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :aria-label="title" class="dashboard-bars">
|
||||
<div v-if="!rows.length" class="bar-empty"><span aria-hidden="true">—</span><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></div>
|
||||
<button v-for="(row, index) in rows" :key="row.key" type="button" class="bar-row" :style="{ '--bar-color': color(index), '--bar-delay': `${index * 45}ms` }" :aria-disabled="disabled || row.value == null" :aria-label="`${row.label},${row.secondary ?? ''},${quantityText(row.value)} ${unit};${row.detail}`" :title="`${row.label} ${row.secondary ?? ''};${row.detail}`" @focus="focused = row.key" @mouseenter="focused = row.key" @click="!disabled && row.value != null && emit('select', row.key)">
|
||||
<span class="bar-heading"><span class="bar-label"><span v-if="ranked" class="bar-rank" :class="{ 'is-leading': index < 3 }">{{ String(index + 1).padStart(2, '0') }}</span>{{ row.label }}</span><strong>{{ quantityText(row.value) }} <small>{{ unit }}</small></strong></span>
|
||||
<span v-if="row.secondary" class="bar-secondary">{{ row.secondary }}</span>
|
||||
<span class="bar-track" aria-hidden="true"><span v-if="row.value != null" class="bar-fill" :style="{ width: `${Number(row.value) / maximum * 100}%` }" /></span>
|
||||
</button>
|
||||
<p v-if="focused" class="bar-detail" role="status">{{ rows.find(row => row.key === focused)?.detail }}</p>
|
||||
<DashboardChartHeading :title="title">
|
||||
<span v-if="rows.length" class="chart-details-action"><el-button link type="primary" title="查看完整明细" @click="detailsOpen = true">明细</el-button></span>
|
||||
<slot name="actions" />
|
||||
</DashboardChartHeading>
|
||||
<slot name="intro" />
|
||||
<slot v-if="!rows.length" name="empty"><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></slot>
|
||||
<template v-else>
|
||||
<AnalyticsChart :option="option" :style="{ height: `${Math.max(240, rows.length * 38 + 65)}px` }" :aria-label="title" @select="selectChart" />
|
||||
<p v-if="rows.some(row => row.value == null)">存在待核验数量,以“—”表示,不作为零值。</p>
|
||||
<AppDialog v-model="detailsOpen" class="admin-density-dialog" :title="`${title} · 完整明细`" :width="detailsWidth || '1040px'" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
|
||||
<div class="admin-density chart-details">
|
||||
<slot name="details" :select="select">
|
||||
<p>当前图表共 {{ rows.length }} 项;点击项目查看对应明细。</p>
|
||||
<table class="density-table"><thead><tr><th v-if="ranked" class="rank-column">排名</th><th class="name-column">项目/完整名称</th><th class="quantity-column">数量({{ unit }})</th><th>说明</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="row.key"><td v-if="ranked">{{ index + 1 }}</td><td><button type="button" :disabled="disabled || row.value == null" @click="select(row)">{{ row.label }}</button><small v-if="row.secondary">{{ row.secondary }}</small></td><td>{{ quantityText(row.value) }}</td><td>{{ row.detail || '—' }}</td></tr></tbody></table>
|
||||
</slot>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dashboard-bars { display: grid; gap: var(--admin-space-2); }
|
||||
.bar-row { appearance: none; border: 0; padding: 7px 8px; border-radius: 6px; background: transparent; text-align: left; color: inherit; cursor: pointer; width: 100%; font: inherit; transition: background .2s; }
|
||||
.bar-row:hover, .bar-row:focus-visible { background: #f5f7fc; }
|
||||
.bar-row:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.bar-row[aria-disabled="true"] { cursor: default; }
|
||||
.bar-heading { display: flex; gap: var(--admin-space-2); justify-content: space-between; align-items: baseline; }
|
||||
.bar-label { overflow-wrap: anywhere; }
|
||||
.bar-rank { display: inline-block; width: 28px; color: #929db3; font-size: var(--admin-font-caption); font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.bar-rank.is-leading { color: var(--bar-color); }
|
||||
.bar-heading strong { white-space: nowrap; }
|
||||
.bar-heading small { font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.bar-secondary, .bar-detail { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
.bar-track { display: block; height: 10px; margin-top: 7px; border-radius: 3px; overflow: hidden; background: #f0f3f9; }
|
||||
.bar-fill { display: block; height: 100%; background: var(--bar-color); border-radius: 3px; transform-origin: left; animation: bar-reveal .7s cubic-bezier(.2,.7,.3,1) both; animation-delay: var(--bar-delay); transition: width .5s; }
|
||||
.bar-empty { min-height: 150px; display: grid; align-content: center; justify-items: center; color: var(--el-text-color-secondary); text-align: center; }
|
||||
.bar-empty > span { font-size: 38px; color: #a9b7d8; }
|
||||
@keyframes bar-reveal { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
@media (prefers-reduced-motion: reduce) { .bar-fill { animation: none; transition: none; } .bar-row { transition: none; } }
|
||||
.dashboard-bars { min-width: 0; }
|
||||
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
td { overflow-wrap: anywhere; vertical-align: top; }
|
||||
.chart-details-action { display: inline-flex; }
|
||||
.chart-details table { width: 100%; table-layout: fixed; }
|
||||
.rank-column { width: 56px; }
|
||||
.name-column { width: 30%; }
|
||||
.quantity-column { width: 120px; }
|
||||
.chart-details button { font: inherit; color: var(--el-color-primary); background: transparent; border: 0; cursor: pointer; padding: var(--admin-space-2); }
|
||||
.chart-details button:disabled { color: var(--el-text-color-secondary); cursor: default; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user