feat(reject-history): fix Pareto datasources, multi-select filtering, and UX enhancements
- Fix dimension Pareto datasources: PJ_TYPE/PRODUCTLINENAME from DW_MES_CONTAINER, WORKFLOWNAME from DW_MES_LOTWIPHISTORY via WIPTRACKINGGROUPKEYID, EQUIPMENTNAME from LOTREJECTHISTORY only (no WIP fallback), workcenter dimension uses WORKCENTER_GROUP - Add multi-select Pareto click filtering with chip display and detail list integration - Add TOP 20 display scope selector for TYPE/WORKFLOW/機台 dimensions - Pass Pareto selection (dimension + values) through to list/export endpoints - Enable TRACE_WORKER_ENABLED=true by default in start_server.sh and .env.example - Archive reject-history-pareto-datasource-fix and reject-history-pareto-ux-enhancements - Update reject-history-api and reject-history-page specs with new Pareto behaviors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -173,6 +173,8 @@ export function buildViewParams(queryId, {
|
||||
metricFilter = 'all',
|
||||
trendDates = [],
|
||||
detailReason = '',
|
||||
paretoDimension = '',
|
||||
paretoValues = [],
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
policyFilters = {},
|
||||
@@ -196,6 +198,14 @@ export function buildViewParams(queryId, {
|
||||
if (detailReason) {
|
||||
params.detail_reason = detailReason;
|
||||
}
|
||||
const normalizedParetoDimension = normalizeText(paretoDimension).toLowerCase();
|
||||
const normalizedParetoValues = normalizeArray(paretoValues);
|
||||
if (normalizedParetoDimension) {
|
||||
params.pareto_dimension = normalizedParetoDimension;
|
||||
}
|
||||
if (normalizedParetoValues.length > 0) {
|
||||
params.pareto_values = normalizedParetoValues;
|
||||
}
|
||||
params.page = page || 1;
|
||||
params.per_page = perPage || 50;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { apiGet, apiPost } from '../core/api.js';
|
||||
import {
|
||||
buildViewParams,
|
||||
parseMultiLineInput,
|
||||
toRejectFilterSnapshot,
|
||||
} from '../core/reject-history-filters.js';
|
||||
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
|
||||
@@ -17,6 +16,15 @@ import TrendChart from './components/TrendChart.vue';
|
||||
|
||||
const API_TIMEOUT = 360000;
|
||||
const DEFAULT_PER_PAGE = 50;
|
||||
const PARETO_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']);
|
||||
const PARETO_DIMENSION_LABELS = {
|
||||
reason: '不良原因',
|
||||
package: 'PACKAGE',
|
||||
type: 'TYPE',
|
||||
workflow: 'WORKFLOW',
|
||||
workcenter: '站點',
|
||||
equipment: '機台',
|
||||
};
|
||||
|
||||
// ---- Primary query form state ----
|
||||
const queryMode = ref('date_range');
|
||||
@@ -59,10 +67,11 @@ const supplementaryFilters = reactive({
|
||||
|
||||
// ---- Interactive state ----
|
||||
const page = ref(1);
|
||||
const detailReason = ref('');
|
||||
const selectedTrendDates = ref([]);
|
||||
const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true });
|
||||
const paretoDimension = ref('reason');
|
||||
const selectedParetoValues = ref([]);
|
||||
const paretoDisplayScope = ref('all');
|
||||
const dimensionParetoItems = ref([]);
|
||||
const dimensionParetoLoading = ref(false);
|
||||
|
||||
@@ -198,8 +207,9 @@ async function executePrimaryQuery() {
|
||||
supplementaryFilters.workcenterGroups = [];
|
||||
supplementaryFilters.reason = '';
|
||||
page.value = 1;
|
||||
detailReason.value = '';
|
||||
selectedTrendDates.value = [];
|
||||
selectedParetoValues.value = [];
|
||||
paretoDisplayScope.value = 'all';
|
||||
paretoDimension.value = 'reason';
|
||||
dimensionParetoItems.value = [];
|
||||
|
||||
@@ -239,7 +249,8 @@ async function refreshView() {
|
||||
supplementaryFilters,
|
||||
metricFilter: metricFilterParam(),
|
||||
trendDates: selectedTrendDates.value,
|
||||
detailReason: detailReason.value,
|
||||
paretoDimension: paretoDimension.value,
|
||||
paretoValues: selectedParetoValues.value,
|
||||
page: page.value,
|
||||
perPage: DEFAULT_PER_PAGE,
|
||||
policyFilters: {
|
||||
@@ -330,10 +341,18 @@ function onTrendLegendChange(selected) {
|
||||
refreshDimensionParetoIfActive();
|
||||
}
|
||||
|
||||
function onParetoClick(reason) {
|
||||
if (!reason) return;
|
||||
detailReason.value = detailReason.value === reason ? '' : reason;
|
||||
function onParetoItemToggle(itemValue) {
|
||||
const normalized = String(itemValue || '').trim();
|
||||
if (!normalized) return;
|
||||
if (selectedParetoValues.value.includes(normalized)) {
|
||||
selectedParetoValues.value = selectedParetoValues.value.filter(
|
||||
(item) => item !== normalized,
|
||||
);
|
||||
} else {
|
||||
selectedParetoValues.value = [...selectedParetoValues.value, normalized];
|
||||
}
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void refreshView();
|
||||
}
|
||||
|
||||
@@ -341,6 +360,7 @@ function handleParetoScopeToggle(checked) {
|
||||
draftFilters.paretoTop80 = Boolean(checked);
|
||||
committedPrimary.paretoTop80 = Boolean(checked);
|
||||
updateUrlState();
|
||||
refreshDimensionParetoIfActive();
|
||||
}
|
||||
|
||||
let activeDimRequestId = 0;
|
||||
@@ -391,20 +411,37 @@ function refreshDimensionParetoIfActive() {
|
||||
|
||||
function onDimensionChange(dim) {
|
||||
paretoDimension.value = dim;
|
||||
selectedParetoValues.value = [];
|
||||
paretoDisplayScope.value = 'all';
|
||||
page.value = 1;
|
||||
if (dim === 'reason') {
|
||||
dimensionParetoItems.value = [];
|
||||
void refreshView();
|
||||
} else {
|
||||
void fetchDimensionPareto(dim);
|
||||
void refreshView();
|
||||
}
|
||||
}
|
||||
|
||||
function onParetoDisplayScopeChange(scope) {
|
||||
paretoDisplayScope.value = scope === 'top20' ? 'top20' : 'all';
|
||||
updateUrlState();
|
||||
}
|
||||
|
||||
function clearParetoSelection() {
|
||||
selectedParetoValues.value = [];
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void refreshView();
|
||||
}
|
||||
|
||||
function onSupplementaryChange(filters) {
|
||||
supplementaryFilters.packages = filters.packages || [];
|
||||
supplementaryFilters.workcenterGroups = filters.workcenterGroups || [];
|
||||
supplementaryFilters.reason = filters.reason || '';
|
||||
page.value = 1;
|
||||
detailReason.value = '';
|
||||
selectedTrendDates.value = [];
|
||||
selectedParetoValues.value = [];
|
||||
void refreshView();
|
||||
refreshDimensionParetoIfActive();
|
||||
}
|
||||
@@ -412,9 +449,12 @@ function onSupplementaryChange(filters) {
|
||||
function removeFilterChip(chip) {
|
||||
if (!chip?.removable) return;
|
||||
|
||||
if (chip.type === 'detail-reason') {
|
||||
detailReason.value = '';
|
||||
if (chip.type === 'pareto-value') {
|
||||
selectedParetoValues.value = selectedParetoValues.value.filter(
|
||||
(value) => value !== chip.value,
|
||||
);
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void refreshView();
|
||||
return;
|
||||
}
|
||||
@@ -471,7 +511,8 @@ async function exportCsv() {
|
||||
if (supplementaryFilters.reason) params.set('reason', supplementaryFilters.reason);
|
||||
params.set('metric_filter', metricFilterParam());
|
||||
for (const date of selectedTrendDates.value) params.append('trend_dates', date);
|
||||
if (detailReason.value) params.set('detail_reason', detailReason.value);
|
||||
params.set('pareto_dimension', paretoDimension.value);
|
||||
for (const value of selectedParetoValues.value) params.append('pareto_values', value);
|
||||
|
||||
// Policy filters (applied in-memory on cached data)
|
||||
if (committedPrimary.includeExcludedScrap) params.set('include_excluded_scrap', 'true');
|
||||
@@ -642,10 +683,24 @@ const filteredParetoItems = computed(() => {
|
||||
});
|
||||
|
||||
const activeParetoItems = computed(() => {
|
||||
if (paretoDimension.value !== 'reason') return dimensionParetoItems.value;
|
||||
return filteredParetoItems.value;
|
||||
const baseItems =
|
||||
paretoDimension.value === 'reason'
|
||||
? filteredParetoItems.value
|
||||
: (dimensionParetoItems.value || []);
|
||||
|
||||
if (
|
||||
PARETO_TOP20_DIMENSIONS.has(paretoDimension.value)
|
||||
&& paretoDisplayScope.value === 'top20'
|
||||
) {
|
||||
return baseItems.slice(0, 20);
|
||||
}
|
||||
return baseItems;
|
||||
});
|
||||
|
||||
const selectedParetoDimensionLabel = computed(
|
||||
() => PARETO_DIMENSION_LABELS[paretoDimension.value] || 'Pareto',
|
||||
);
|
||||
|
||||
const activeFilterChips = computed(() => {
|
||||
const chips = [];
|
||||
|
||||
@@ -742,15 +797,15 @@ const activeFilterChips = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (detailReason.value) {
|
||||
selectedParetoValues.value.forEach((value) => {
|
||||
chips.push({
|
||||
key: `detail-reason:${detailReason.value}`,
|
||||
label: `明細原因: ${detailReason.value}`,
|
||||
key: `pareto-value:${paretoDimension.value}:${value}`,
|
||||
label: `${selectedParetoDimensionLabel.value}: ${value}`,
|
||||
removable: true,
|
||||
type: 'detail-reason',
|
||||
value: detailReason.value,
|
||||
type: 'pareto-value',
|
||||
value,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return chips;
|
||||
});
|
||||
@@ -809,9 +864,9 @@ function updateUrlState() {
|
||||
|
||||
// Interactive
|
||||
appendArrayParams(params, 'trend_dates', selectedTrendDates.value);
|
||||
if (detailReason.value) {
|
||||
params.set('detail_reason', detailReason.value);
|
||||
}
|
||||
params.set('pareto_dimension', paretoDimension.value);
|
||||
appendArrayParams(params, 'pareto_values', selectedParetoValues.value);
|
||||
if (paretoDisplayScope.value !== 'all') params.set('pareto_display_scope', paretoDisplayScope.value);
|
||||
if (!committedPrimary.paretoTop80) {
|
||||
params.set('pareto_scope_all', 'true');
|
||||
}
|
||||
@@ -886,15 +941,26 @@ function restoreFromUrl() {
|
||||
|
||||
// Interactive
|
||||
const urlTrendDates = readArrayParam(params, 'trend_dates');
|
||||
const urlDetailReason = String(params.get('detail_reason') || '').trim();
|
||||
const rawParetoDimension = String(params.get('pareto_dimension') || '').trim().toLowerCase();
|
||||
const urlParetoDimension = Object.hasOwn(PARETO_DIMENSION_LABELS, rawParetoDimension)
|
||||
? rawParetoDimension
|
||||
: 'reason';
|
||||
const urlParetoValues = readArrayParam(params, 'pareto_values');
|
||||
const urlParetoDisplayScope = String(params.get('pareto_display_scope') || '').trim().toLowerCase();
|
||||
const parsedPage = Number(params.get('page') || '1');
|
||||
|
||||
paretoDimension.value = urlParetoDimension;
|
||||
selectedParetoValues.value = urlParetoValues;
|
||||
paretoDisplayScope.value = urlParetoDisplayScope === 'top20' ? 'top20' : 'all';
|
||||
|
||||
return {
|
||||
packages: urlPackages,
|
||||
workcenterGroups: urlWcGroups,
|
||||
reason: urlReason,
|
||||
trendDates: urlTrendDates,
|
||||
detailReason: urlDetailReason,
|
||||
paretoDimension: urlParetoDimension,
|
||||
paretoValues: urlParetoValues,
|
||||
paretoDisplayScope: paretoDisplayScope.value,
|
||||
page: Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1,
|
||||
};
|
||||
}
|
||||
@@ -962,23 +1028,26 @@ onMounted(() => {
|
||||
|
||||
<ParetoSection
|
||||
:items="activeParetoItems"
|
||||
:detail-reason="detailReason"
|
||||
:selected-values="selectedParetoValues"
|
||||
:display-scope="paretoDisplayScope"
|
||||
:selected-dates="selectedTrendDates"
|
||||
:metric-label="paretoMetricLabel"
|
||||
:loading="loading.querying || dimensionParetoLoading"
|
||||
:dimension="paretoDimension"
|
||||
:show-dimension-selector="committedPrimary.mode === 'date_range'"
|
||||
@reason-click="onParetoClick"
|
||||
@item-toggle="onParetoItemToggle"
|
||||
@dimension-change="onDimensionChange"
|
||||
@display-scope-change="onParetoDisplayScopeChange"
|
||||
/>
|
||||
|
||||
<DetailTable
|
||||
:items="detail.items"
|
||||
:pagination="pagination"
|
||||
:loading="loading.list"
|
||||
:detail-reason="detailReason"
|
||||
:selected-pareto-values="selectedParetoValues"
|
||||
:selected-pareto-dimension-label="selectedParetoDimensionLabel"
|
||||
@go-to-page="goToPage"
|
||||
@clear-reason="onParetoClick(detailReason)"
|
||||
@clear-pareto-selection="clearParetoSelection"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps({
|
||||
defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }),
|
||||
},
|
||||
loading: { type: Boolean, default: false },
|
||||
detailReason: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineEmits(['go-to-page', 'clear-reason']);
|
||||
},
|
||||
loading: { type: Boolean, default: false },
|
||||
selectedParetoValues: { type: Array, default: () => [] },
|
||||
selectedParetoDimensionLabel: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineEmits(['go-to-page', 'clear-pareto-selection']);
|
||||
|
||||
const showRejectBreakdown = ref(false);
|
||||
|
||||
@@ -22,15 +23,20 @@ function formatNumber(value) {
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
明細列表
|
||||
<span v-if="detailReason" class="detail-reason-badge">
|
||||
原因: {{ detailReason }}
|
||||
<button type="button" class="badge-clear" @click="$emit('clear-reason')">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
明細列表
|
||||
<span v-if="selectedParetoValues.length > 0" class="detail-reason-badge">
|
||||
{{ selectedParetoDimensionLabel || 'Pareto 篩選' }}:
|
||||
{{
|
||||
selectedParetoValues.length === 1
|
||||
? selectedParetoValues[0]
|
||||
: `${selectedParetoValues.length} 項`
|
||||
}}
|
||||
<button type="button" class="badge-clear" @click="$emit('clear-pareto-selection')">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body detail-table-wrap" :class="{ 'is-loading': loading }">
|
||||
<div v-if="loading" class="table-loading-overlay"><span class="table-spinner"></span></div>
|
||||
<table class="detail-table">
|
||||
|
||||
@@ -122,20 +122,12 @@ function emitSupplementary(patch) {
|
||||
<input v-model="filters.excludeMaterialScrap" type="checkbox" />
|
||||
排除原物料報廢
|
||||
</label>
|
||||
<label class="checkbox-pill">
|
||||
<input v-model="filters.excludePbDiode" type="checkbox" />
|
||||
排除 PB_* 系列
|
||||
</label>
|
||||
<label class="checkbox-pill">
|
||||
<input
|
||||
:checked="filters.paretoTop80"
|
||||
type="checkbox"
|
||||
@change="$emit('pareto-scope-toggle', $event.target.checked)"
|
||||
/>
|
||||
Pareto 僅顯示累計前 80%
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<label class="checkbox-pill">
|
||||
<input v-model="filters.excludePbDiode" type="checkbox" />
|
||||
排除 PB_* 系列
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="loading.querying"
|
||||
@@ -187,11 +179,21 @@ function emitSupplementary(patch) {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Supplementary filters (only after primary query) -->
|
||||
<div v-if="queryId" class="supplementary-panel">
|
||||
<div class="supplementary-header">補充篩選 (快取內篩選)</div>
|
||||
<div class="supplementary-row">
|
||||
<div class="filter-group">
|
||||
<!-- Supplementary filters (only after primary query) -->
|
||||
<div v-if="queryId" class="supplementary-panel">
|
||||
<div class="supplementary-header">補充篩選 (快取內篩選)</div>
|
||||
<div class="supplementary-toolbar">
|
||||
<label class="checkbox-pill">
|
||||
<input
|
||||
:checked="filters.paretoTop80"
|
||||
type="checkbox"
|
||||
@change="$emit('pareto-scope-toggle', $event.target.checked)"
|
||||
/>
|
||||
Pareto 僅顯示累計前 80%
|
||||
</label>
|
||||
</div>
|
||||
<div class="supplementary-row">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">WORKCENTER GROUP</label>
|
||||
<MultiSelect
|
||||
:model-value="supplementaryFilters.workcenterGroups"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
|
||||
@@ -9,33 +9,43 @@ import VChart from 'vue-echarts';
|
||||
|
||||
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
const DIMENSION_OPTIONS = [
|
||||
{ value: 'reason', label: '不良原因' },
|
||||
{ value: 'package', label: 'PACKAGE' },
|
||||
{ value: 'type', label: 'TYPE' },
|
||||
{ value: 'workflow', label: 'WORKFLOW' },
|
||||
{ value: 'workcenter', label: '站點' },
|
||||
{ value: 'equipment', label: '機台' },
|
||||
];
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
detailReason: { type: String, default: '' },
|
||||
selectedDates: { type: Array, default: () => [] },
|
||||
metricLabel: { type: String, default: '報廢量' },
|
||||
loading: { type: Boolean, default: false },
|
||||
dimension: { type: String, default: 'reason' },
|
||||
showDimensionSelector: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reason-click', 'dimension-change']);
|
||||
|
||||
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
|
||||
|
||||
const dimensionLabel = computed(() => {
|
||||
const opt = DIMENSION_OPTIONS.find((o) => o.value === props.dimension);
|
||||
return opt ? opt.label : '報廢原因';
|
||||
});
|
||||
const DIMENSION_OPTIONS = [
|
||||
{ value: 'reason', label: '不良原因' },
|
||||
{ value: 'package', label: 'PACKAGE' },
|
||||
{ value: 'type', label: 'TYPE' },
|
||||
{ value: 'workflow', label: 'WORKFLOW' },
|
||||
{ value: 'workcenter', label: '站點' },
|
||||
{ value: 'equipment', label: '機台' },
|
||||
];
|
||||
const DISPLAY_SCOPE_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']);
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
selectedValues: { type: Array, default: () => [] },
|
||||
selectedDates: { type: Array, default: () => [] },
|
||||
metricLabel: { type: String, default: '報廢量' },
|
||||
loading: { type: Boolean, default: false },
|
||||
dimension: { type: String, default: 'reason' },
|
||||
showDimensionSelector: { type: Boolean, default: false },
|
||||
displayScope: { type: String, default: 'all' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['item-toggle', 'dimension-change', 'display-scope-change']);
|
||||
|
||||
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
|
||||
const selectedValueSet = computed(() => new Set((props.selectedValues || []).map((item) => String(item || '').trim())));
|
||||
const showDisplayScopeSelector = computed(
|
||||
() => DISPLAY_SCOPE_TOP20_DIMENSIONS.has(props.dimension),
|
||||
);
|
||||
|
||||
const dimensionLabel = computed(() => {
|
||||
const opt = DIMENSION_OPTIONS.find((o) => o.value === props.dimension);
|
||||
return opt ? opt.label : '報廢原因';
|
||||
});
|
||||
|
||||
function isSelected(value) {
|
||||
return selectedValueSet.value.has(String(value || '').trim());
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString('zh-TW');
|
||||
@@ -100,16 +110,16 @@ const chartOption = computed(() => {
|
||||
{
|
||||
name: props.metricLabel,
|
||||
type: 'bar',
|
||||
data: items.map((item) => Number(item.metric_value || 0)),
|
||||
barMaxWidth: 34,
|
||||
itemStyle: {
|
||||
color(params) {
|
||||
const reason = items[params.dataIndex]?.reason || '';
|
||||
return reason === props.detailReason ? '#b91c1c' : '#2563eb';
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
},
|
||||
data: items.map((item) => Number(item.metric_value || 0)),
|
||||
barMaxWidth: 34,
|
||||
itemStyle: {
|
||||
color(params) {
|
||||
const reason = items[params.dataIndex]?.reason || '';
|
||||
return isSelected(reason) ? '#b91c1c' : '#2563eb';
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '累積%',
|
||||
type: 'line',
|
||||
@@ -123,36 +133,47 @@ const chartOption = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
function handleChartClick(params) {
|
||||
if (params?.seriesType !== 'bar' || props.dimension !== 'reason') {
|
||||
return;
|
||||
}
|
||||
const reason = props.items?.[params.dataIndex]?.reason;
|
||||
if (reason) {
|
||||
emit('reason-click', reason);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
function handleChartClick(params) {
|
||||
if (params?.seriesType !== 'bar') {
|
||||
return;
|
||||
}
|
||||
const itemValue = props.items?.[params.dataIndex]?.reason;
|
||||
if (itemValue) {
|
||||
emit('item-toggle', itemValue);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<div class="card-header pareto-header">
|
||||
<div class="card-title">
|
||||
{{ metricLabel }} vs {{ dimensionLabel }}(Pareto)
|
||||
<span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span>
|
||||
</div>
|
||||
<select
|
||||
v-if="showDimensionSelector"
|
||||
class="dimension-select"
|
||||
:value="dimension"
|
||||
@change="emit('dimension-change', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in DIMENSION_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-body pareto-layout">
|
||||
<div class="pareto-chart-wrap">
|
||||
<VChart :option="chartOption" autoresize @click="handleChartClick" />
|
||||
<div class="pareto-controls">
|
||||
<select
|
||||
v-if="showDimensionSelector"
|
||||
class="dimension-select"
|
||||
:value="dimension"
|
||||
@change="emit('dimension-change', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in DIMENSION_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="showDisplayScopeSelector"
|
||||
class="dimension-select pareto-scope-select"
|
||||
:value="displayScope"
|
||||
@change="emit('display-scope-change', $event.target.value)"
|
||||
>
|
||||
<option value="all">全部顯示</option>
|
||||
<option value="top20">只顯示 TOP 20</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pareto-layout">
|
||||
<div class="pareto-chart-wrap">
|
||||
<VChart :option="chartOption" autoresize @click="handleChartClick" />
|
||||
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
|
||||
</div>
|
||||
<div class="pareto-table-wrap">
|
||||
@@ -166,19 +187,18 @@ function handleChartClick(params) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.reason"
|
||||
:class="{ active: detailReason === item.reason }"
|
||||
>
|
||||
<td>
|
||||
<button v-if="dimension === 'reason'" class="reason-link" type="button" @click="$emit('reason-click', item.reason)">
|
||||
{{ item.reason }}
|
||||
</button>
|
||||
<span v-else>{{ item.reason }}</span>
|
||||
</td>
|
||||
<td>{{ formatNumber(item.metric_value) }}</td>
|
||||
<td>{{ formatPct(item.pct) }}</td>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.reason"
|
||||
:class="{ active: isSelected(item.reason) }"
|
||||
>
|
||||
<td>
|
||||
<button class="reason-link" type="button" @click="$emit('item-toggle', item.reason)">
|
||||
{{ item.reason }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ formatNumber(item.metric_value) }}</td>
|
||||
<td>{{ formatPct(item.pct) }}</td>
|
||||
<td>{{ formatPct(item.cumPct) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!items || items.length === 0">
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.supplementary-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.supplementary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -312,6 +316,13 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pareto-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dimension-select {
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
@@ -320,7 +331,10 @@
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-primary, #374151);
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pareto-scope-select {
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.pareto-date-badge {
|
||||
|
||||
Reference in New Issue
Block a user