feat(reject-history): fix silent data loss by propagating partial failure metadata to frontend
Chunk failures in BatchQueryEngine were silently discarded — `has_partial_failure` was tracked in Redis but never surfaced to the API response or frontend. Users could see incomplete data without any warning. This commit closes the gap end-to-end: Backend: - Track failed chunk time ranges (`failed_ranges`) in batch engine progress metadata - Add single retry for transient Oracle errors (timeout, connection) in `_execute_single_chunk` - Read `get_batch_progress()` after merge but before `redis_clear_batch()` cleanup - Inject `has_partial_failure`, `failed_chunk_count`, `failed_ranges` into API response meta - Persist partial failure flag to independent Redis key with TTL aligned to data storage layer - Add shared container-resolution policy module with wildcard/expansion guardrails - Refactor reason filter from single-value to multi-select (`reason` → `reasons`) Frontend: - Add client-side date range validation (730-day limit) before API submission - Display amber warning banner on partial failure with specific failed date ranges - Support generic fallback message for container-mode queries without date ranges - Update FilterPanel to support multi-select reason chips Specs & tests: - Create batch-query-resilience spec; update reject-history-api and reject-history-page specs - Add 7 new tests for retry, memory guard, failed ranges, partial failure propagation, TTL - Cross-service regression verified (hold, resource, job, msd — 411 tests pass) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ export function toRejectFilterSnapshot(input = {}) {
|
||||
endDate: normalizeText(input.endDate),
|
||||
workcenterGroups: normalizeArray(input.workcenterGroups),
|
||||
packages: normalizeArray(input.packages),
|
||||
reason: normalizeText(input.reason),
|
||||
reasons: normalizeArray(input.reasons),
|
||||
includeExcludedScrap: normalizeBoolean(input.includeExcludedScrap, false),
|
||||
excludeMaterialScrap: normalizeBoolean(input.excludeMaterialScrap, true),
|
||||
excludePbDiode: normalizeBoolean(input.excludePbDiode, true),
|
||||
@@ -77,7 +77,7 @@ export function pruneRejectFilterSelections(filters = {}, options = {}) {
|
||||
const removed = {
|
||||
workcenterGroups: [],
|
||||
packages: [],
|
||||
reason: '',
|
||||
reasons: [],
|
||||
};
|
||||
|
||||
if (hasWorkcenterOptions) {
|
||||
@@ -100,9 +100,14 @@ export function pruneRejectFilterSelections(filters = {}, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
if (next.reason && hasReasonOptions && !validReasons.has(next.reason)) {
|
||||
removed.reason = next.reason;
|
||||
next.reason = '';
|
||||
if (hasReasonOptions) {
|
||||
next.reasons = next.reasons.filter((value) => {
|
||||
if (validReasons.has(value)) {
|
||||
return true;
|
||||
}
|
||||
removed.reasons.push(value);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -111,7 +116,7 @@ export function pruneRejectFilterSelections(filters = {}, options = {}) {
|
||||
removedCount:
|
||||
removed.workcenterGroups.length +
|
||||
removed.packages.length +
|
||||
(removed.reason ? 1 : 0),
|
||||
removed.reasons.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,13 +131,13 @@ export function buildRejectOptionsRequestParams(filters = {}) {
|
||||
exclude_material_scrap: next.excludeMaterialScrap,
|
||||
exclude_pb_diode: next.excludePbDiode,
|
||||
};
|
||||
if (next.reason) {
|
||||
params.reason = next.reason;
|
||||
if (next.reasons.length > 0) {
|
||||
params.reasons = next.reasons;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildRejectCommonQueryParams(filters = {}, { reason = '' } = {}) {
|
||||
export function buildRejectCommonQueryParams(filters = {}, { reasons: extraReasons = [] } = {}) {
|
||||
const next = toRejectFilterSnapshot(filters);
|
||||
const params = {
|
||||
start_date: next.startDate,
|
||||
@@ -143,9 +148,9 @@ export function buildRejectCommonQueryParams(filters = {}, { reason = '' } = {})
|
||||
exclude_material_scrap: next.excludeMaterialScrap,
|
||||
exclude_pb_diode: next.excludePbDiode,
|
||||
};
|
||||
const effectiveReason = normalizeText(reason) || next.reason;
|
||||
if (effectiveReason) {
|
||||
params.reasons = [effectiveReason];
|
||||
const merged = normalizeArray([...next.reasons, ...normalizeArray(extraReasons)]);
|
||||
if (merged.length > 0) {
|
||||
params.reasons = merged;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
@@ -168,6 +173,30 @@ export function parseMultiLineInput(text) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateDateRange(startDate, endDate) {
|
||||
const MAX_QUERY_DAYS = 730;
|
||||
const start = normalizeText(startDate);
|
||||
const end = normalizeText(endDate);
|
||||
if (!start || !end) {
|
||||
return '請先設定開始與結束日期';
|
||||
}
|
||||
|
||||
const startDt = new Date(`${start}T00:00:00`);
|
||||
const endDt = new Date(`${end}T00:00:00`);
|
||||
if (Number.isNaN(startDt.getTime()) || Number.isNaN(endDt.getTime())) {
|
||||
return '日期格式不正確';
|
||||
}
|
||||
if (endDt < startDt) {
|
||||
return '結束日期必須大於起始日期';
|
||||
}
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const days = Math.floor((endDt - startDt) / dayMs) + 1;
|
||||
if (days > MAX_QUERY_DAYS) {
|
||||
return '查詢範圍不可超過 730 天(約兩年)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildViewParams(queryId, {
|
||||
supplementaryFilters = {},
|
||||
metricFilter = 'all',
|
||||
@@ -185,8 +214,8 @@ export function buildViewParams(queryId, {
|
||||
if (supplementaryFilters.workcenterGroups?.length > 0) {
|
||||
params.workcenter_groups = supplementaryFilters.workcenterGroups;
|
||||
}
|
||||
if (supplementaryFilters.reason) {
|
||||
params.reason = supplementaryFilters.reason;
|
||||
if (supplementaryFilters.reasons?.length > 0) {
|
||||
params.reasons = supplementaryFilters.reasons;
|
||||
}
|
||||
if (metricFilter && metricFilter !== 'all') {
|
||||
params.metric_filter = metricFilter;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiGet, apiPost } from '../core/api.js';
|
||||
import {
|
||||
buildViewParams,
|
||||
parseMultiLineInput,
|
||||
validateDateRange,
|
||||
} from '../core/reject-history-filters.js';
|
||||
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
|
||||
@@ -104,14 +105,14 @@ const availableFilters = ref({ workcenterGroups: [], packages: [], reasons: [] }
|
||||
const supplementaryFilters = reactive({
|
||||
packages: [],
|
||||
workcenterGroups: [],
|
||||
reason: '',
|
||||
reasons: [],
|
||||
});
|
||||
|
||||
// ---- Interactive state ----
|
||||
const page = ref(1);
|
||||
const selectedTrendDates = ref([]);
|
||||
const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true });
|
||||
const paretoDisplayScope = ref('all');
|
||||
const paretoDisplayScope = ref('top20');
|
||||
const paretoSelections = reactive(createEmptyParetoSelections());
|
||||
const paretoData = reactive(createEmptyParetoData());
|
||||
|
||||
@@ -146,6 +147,7 @@ const loading = reactive({
|
||||
exporting: false,
|
||||
});
|
||||
const errorMessage = ref('');
|
||||
const partialFailureWarning = ref('');
|
||||
const lastQueryAt = ref('');
|
||||
|
||||
// ---- Request staleness tracking ----
|
||||
@@ -241,8 +243,8 @@ function buildBatchParetoParams() {
|
||||
if (supplementaryFilters.workcenterGroups.length > 0) {
|
||||
params.workcenter_groups = supplementaryFilters.workcenterGroups;
|
||||
}
|
||||
if (supplementaryFilters.reason) {
|
||||
params.reason = supplementaryFilters.reason;
|
||||
if (supplementaryFilters.reasons.length > 0) {
|
||||
params.reasons = supplementaryFilters.reasons;
|
||||
}
|
||||
if (selectedTrendDates.value.length > 0) {
|
||||
params.trend_dates = selectedTrendDates.value;
|
||||
@@ -301,11 +303,20 @@ async function executePrimaryQuery() {
|
||||
loading.querying = true;
|
||||
loading.list = true;
|
||||
errorMessage.value = '';
|
||||
partialFailureWarning.value = '';
|
||||
|
||||
try {
|
||||
const body = { mode: queryMode.value };
|
||||
|
||||
if (queryMode.value === 'date_range') {
|
||||
const dateValidationError = validateDateRange(
|
||||
draftFilters.startDate,
|
||||
draftFilters.endDate,
|
||||
);
|
||||
if (dateValidationError) {
|
||||
errorMessage.value = dateValidationError;
|
||||
return;
|
||||
}
|
||||
body.start_date = draftFilters.startDate;
|
||||
body.end_date = draftFilters.endDate;
|
||||
} else {
|
||||
@@ -321,6 +332,19 @@ async function executePrimaryQuery() {
|
||||
if (isStaleRequest(requestId)) return;
|
||||
|
||||
const result = unwrapApiResult(resp, '主查詢執行失敗');
|
||||
const meta = result.meta || {};
|
||||
if (meta.has_partial_failure) {
|
||||
const failedChunkCount = Number(meta.failed_chunk_count || 0);
|
||||
const failedRanges = Array.isArray(meta.failed_ranges) ? meta.failed_ranges : [];
|
||||
if (failedRanges.length > 0) {
|
||||
const rangesText = failedRanges
|
||||
.map((item) => `${item.start} ~ ${item.end}`)
|
||||
.join('、');
|
||||
partialFailureWarning.value = `警告:以下日期區間的資料擷取失敗(${failedChunkCount} 個批次):${rangesText}。目前顯示結果可能不完整。`;
|
||||
} else {
|
||||
partialFailureWarning.value = `警告:${failedChunkCount} 個查詢批次的資料擷取失敗。目前顯示結果可能不完整。`;
|
||||
}
|
||||
}
|
||||
|
||||
committedPrimary.mode = queryMode.value;
|
||||
committedPrimary.startDate = draftFilters.startDate;
|
||||
@@ -344,7 +368,7 @@ async function executePrimaryQuery() {
|
||||
|
||||
supplementaryFilters.packages = [];
|
||||
supplementaryFilters.workcenterGroups = [];
|
||||
supplementaryFilters.reason = '';
|
||||
supplementaryFilters.reasons = [];
|
||||
page.value = 1;
|
||||
selectedTrendDates.value = [];
|
||||
resetParetoSelections();
|
||||
@@ -445,7 +469,7 @@ function clearFilters() {
|
||||
draftFilters.excludeMaterialScrap = true;
|
||||
draftFilters.excludePbDiode = true;
|
||||
draftFilters.paretoTop80 = true;
|
||||
paretoDisplayScope.value = 'all';
|
||||
paretoDisplayScope.value = 'top20';
|
||||
resetParetoSelections();
|
||||
void executePrimaryQuery();
|
||||
}
|
||||
@@ -520,7 +544,7 @@ function clearParetoSelection() {
|
||||
function onSupplementaryChange(filters) {
|
||||
supplementaryFilters.packages = filters.packages || [];
|
||||
supplementaryFilters.workcenterGroups = filters.workcenterGroups || [];
|
||||
supplementaryFilters.reason = filters.reason || '';
|
||||
supplementaryFilters.reasons = filters.reasons || [];
|
||||
page.value = 1;
|
||||
selectedTrendDates.value = [];
|
||||
resetParetoSelections();
|
||||
@@ -545,7 +569,7 @@ function removeFilterChip(chip) {
|
||||
}
|
||||
|
||||
if (chip.type === 'reason') {
|
||||
supplementaryFilters.reason = '';
|
||||
supplementaryFilters.reasons = supplementaryFilters.reasons.filter((r) => r !== chip.value);
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void Promise.all([refreshView(), fetchBatchPareto()]);
|
||||
@@ -584,7 +608,7 @@ async function exportCsv() {
|
||||
params.set('query_id', queryId.value);
|
||||
for (const pkg of supplementaryFilters.packages) params.append('packages', pkg);
|
||||
for (const wc of supplementaryFilters.workcenterGroups) params.append('workcenter_groups', wc);
|
||||
if (supplementaryFilters.reason) params.set('reason', supplementaryFilters.reason);
|
||||
for (const r of supplementaryFilters.reasons) params.append('reasons', r);
|
||||
params.set('metric_filter', metricFilterParam());
|
||||
for (const date of selectedTrendDates.value) params.append('trend_dates', date);
|
||||
for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) {
|
||||
@@ -760,13 +784,13 @@ const activeFilterChips = computed(() => {
|
||||
value: '',
|
||||
});
|
||||
|
||||
if (supplementaryFilters.reason) {
|
||||
for (const reason of supplementaryFilters.reasons) {
|
||||
chips.push({
|
||||
key: `reason:${supplementaryFilters.reason}`,
|
||||
label: `原因: ${supplementaryFilters.reason}`,
|
||||
key: `reason:${reason}`,
|
||||
label: `原因: ${reason}`,
|
||||
removable: true,
|
||||
type: 'reason',
|
||||
value: supplementaryFilters.reason,
|
||||
value: reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -866,16 +890,14 @@ function updateUrlState() {
|
||||
|
||||
appendArrayParams(params, 'packages', supplementaryFilters.packages);
|
||||
appendArrayParams(params, 'workcenter_groups', supplementaryFilters.workcenterGroups);
|
||||
if (supplementaryFilters.reason) {
|
||||
params.set('reason', supplementaryFilters.reason);
|
||||
}
|
||||
appendArrayParams(params, 'reasons', supplementaryFilters.reasons);
|
||||
|
||||
appendArrayParams(params, 'trend_dates', selectedTrendDates.value);
|
||||
for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) {
|
||||
appendArrayParams(params, key, paretoSelections[dimension] || []);
|
||||
}
|
||||
|
||||
if (paretoDisplayScope.value !== 'all') {
|
||||
if (paretoDisplayScope.value !== 'top20') {
|
||||
params.set('pareto_display_scope', paretoDisplayScope.value);
|
||||
}
|
||||
if (!committedPrimary.paretoTop80) {
|
||||
@@ -945,7 +967,7 @@ function restoreFromUrl() {
|
||||
|
||||
supplementaryFilters.packages = readArrayParam(params, 'packages');
|
||||
supplementaryFilters.workcenterGroups = readArrayParam(params, 'workcenter_groups');
|
||||
supplementaryFilters.reason = String(params.get('reason') || '').trim();
|
||||
supplementaryFilters.reasons = readArrayParam(params, 'reasons');
|
||||
|
||||
selectedTrendDates.value = readArrayParam(params, 'trend_dates');
|
||||
|
||||
@@ -969,7 +991,7 @@ function restoreFromUrl() {
|
||||
}
|
||||
|
||||
const urlParetoDisplayScope = String(params.get('pareto_display_scope') || '').trim().toLowerCase();
|
||||
paretoDisplayScope.value = urlParetoDisplayScope === 'top20' ? 'top20' : 'all';
|
||||
paretoDisplayScope.value = urlParetoDisplayScope === 'all' ? 'all' : 'top20';
|
||||
|
||||
const parsedPage = Number(params.get('page') || '1');
|
||||
page.value = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
|
||||
@@ -1001,6 +1023,9 @@ onMounted(() => {
|
||||
</header>
|
||||
|
||||
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
|
||||
<div v-if="partialFailureWarning" class="warning-banner">
|
||||
{{ partialFailureWarning }}
|
||||
</div>
|
||||
|
||||
<FilterPanel
|
||||
:filters="draftFilters"
|
||||
|
||||
@@ -8,23 +8,23 @@ const props = defineProps({
|
||||
containerInput: { type: String, default: '' },
|
||||
availableFilters: { type: Object, default: () => ({}) },
|
||||
supplementaryFilters: { type: Object, default: () => ({}) },
|
||||
queryId: { type: String, default: '' },
|
||||
resolutionInfo: { type: Object, default: null },
|
||||
loading: { type: Object, required: true },
|
||||
activeFilterChips: { type: Array, default: () => [] },
|
||||
paretoDisplayScope: { type: String, default: 'all' },
|
||||
});
|
||||
queryId: { type: String, default: '' },
|
||||
resolutionInfo: { type: Object, default: null },
|
||||
loading: { type: Object, required: true },
|
||||
activeFilterChips: { type: Array, default: () => [] },
|
||||
paretoDisplayScope: { type: String, default: 'all' },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'apply',
|
||||
'clear',
|
||||
'export-csv',
|
||||
'remove-chip',
|
||||
'pareto-scope-toggle',
|
||||
'pareto-display-scope-change',
|
||||
'update:queryMode',
|
||||
'update:containerInputType',
|
||||
'update:containerInput',
|
||||
'remove-chip',
|
||||
'pareto-scope-toggle',
|
||||
'pareto-display-scope-change',
|
||||
'update:queryMode',
|
||||
'update:containerInputType',
|
||||
'update:containerInput',
|
||||
'supplementary-change',
|
||||
]);
|
||||
|
||||
@@ -32,7 +32,7 @@ function emitSupplementary(patch) {
|
||||
emit('supplementary-change', {
|
||||
packages: props.supplementaryFilters.packages || [],
|
||||
workcenterGroups: props.supplementaryFilters.workcenterGroups || [],
|
||||
reason: props.supplementaryFilters.reason || '',
|
||||
reasons: props.supplementaryFilters.reasons || [],
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
@@ -86,23 +86,23 @@ function emitSupplementary(patch) {
|
||||
|
||||
<!-- Container mode -->
|
||||
<template v-else>
|
||||
<div class="filter-group">
|
||||
<label class="filter-label" for="container-type">輸入類型</label>
|
||||
<select
|
||||
id="container-type"
|
||||
class="filter-input"
|
||||
:value="containerInputType"
|
||||
@change="$emit('update:containerInputType', $event.target.value)"
|
||||
>
|
||||
<option value="lot">LOT</option>
|
||||
<option value="work_order">工單</option>
|
||||
<option value="wafer_lot">WAFER LOT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group filter-group-wide">
|
||||
<label class="filter-label" for="container-input"
|
||||
>輸入值 (每行一個,支援 * 或 % wildcard)</label
|
||||
>
|
||||
<div class="filter-group filter-group-full container-input-group">
|
||||
<div class="container-label-row">
|
||||
<label class="filter-label" for="container-type">輸入類型</label>
|
||||
<select
|
||||
id="container-type"
|
||||
class="filter-input container-type-select"
|
||||
:value="containerInputType"
|
||||
@change="$emit('update:containerInputType', $event.target.value)"
|
||||
>
|
||||
<option value="lot">LOT</option>
|
||||
<option value="work_order">工單</option>
|
||||
<option value="wafer_lot">WAFER LOT</option>
|
||||
</select>
|
||||
<label class="filter-label" for="container-input"
|
||||
>輸入值 (每行一個,支援 * 或 % wildcard)</label
|
||||
>
|
||||
</div>
|
||||
<textarea
|
||||
id="container-input"
|
||||
class="filter-input filter-textarea"
|
||||
@@ -124,12 +124,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>
|
||||
</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"
|
||||
@@ -181,30 +181,30 @@ 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-toolbar">
|
||||
<label class="checkbox-pill">
|
||||
<input
|
||||
:checked="filters.paretoTop80"
|
||||
type="checkbox"
|
||||
@change="$emit('pareto-scope-toggle', $event.target.checked)"
|
||||
/>
|
||||
Pareto 僅顯示累計前 80%
|
||||
</label>
|
||||
<label class="filter-label">顯示範圍</label>
|
||||
<select
|
||||
class="dimension-select pareto-scope-select"
|
||||
:value="paretoDisplayScope"
|
||||
@change="$emit('pareto-display-scope-change', $event.target.value)"
|
||||
>
|
||||
<option value="all">全部顯示</option>
|
||||
<option value="top20">只顯示 TOP 20</option>
|
||||
</select>
|
||||
</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>
|
||||
<label class="filter-label">顯示範圍</label>
|
||||
<select
|
||||
class="dimension-select pareto-scope-select"
|
||||
:value="paretoDisplayScope"
|
||||
@change="$emit('pareto-display-scope-change', $event.target.value)"
|
||||
>
|
||||
<option value="all">全部顯示</option>
|
||||
<option value="top20">只顯示 TOP 20</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="supplementary-row">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">WORKCENTER GROUP</label>
|
||||
<MultiSelect
|
||||
:model-value="supplementaryFilters.workcenterGroups"
|
||||
@@ -227,22 +227,14 @@ function emitSupplementary(patch) {
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label" for="supp-reason">報廢原因</label>
|
||||
<select
|
||||
id="supp-reason"
|
||||
class="filter-input"
|
||||
:value="supplementaryFilters.reason"
|
||||
@change="emitSupplementary({ reason: $event.target.value })"
|
||||
>
|
||||
<option value="">全部原因</option>
|
||||
<option
|
||||
v-for="r in availableFilters.reasons || []"
|
||||
:key="r"
|
||||
:value="r"
|
||||
>
|
||||
{{ r }}
|
||||
</option>
|
||||
</select>
|
||||
<label class="filter-label">報廢原因</label>
|
||||
<MultiSelect
|
||||
:model-value="supplementaryFilters.reasons"
|
||||
:options="availableFilters.reasons || []"
|
||||
placeholder="全部原因"
|
||||
searchable
|
||||
@update:model-value="emitSupplementary({ reasons: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.container-type-select {
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.supplementary-panel {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 16px 18px;
|
||||
@@ -119,6 +132,15 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user