优化
This commit is contained in:
@@ -15,7 +15,8 @@ import { analyticsApi } from '../api/analytics'
|
||||
import { RequestFence, beijingTime, contextParams } from '../state'
|
||||
import type { DailyResponse, Overview } from '../types'
|
||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
|
||||
import { turnoverRoute, validateTurnover, yesterdayBeijing, turnoverSorts } from '../turnoverNavigation'
|
||||
import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
|
||||
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
|
||||
import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
|
||||
import AnalyticsChart from './AnalyticsChart.vue'
|
||||
import ChartDetailsDialog from './ChartDetailsDialog.vue'
|
||||
@@ -37,7 +38,6 @@ let linkedSnapshot: string | undefined
|
||||
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse } | null = null
|
||||
let initialized = false, active = true
|
||||
const warehouses = computed(() => [...new Map((runtime.value?.warehouses ?? []).map(w => [w.warehouseCode, { value: w.warehouseCode, label: w.warehouseName || w.warehouseCode }])).values()])
|
||||
const sortLabels: Record<TurnoverSort, string> = { PHYSICAL_DESC: '实物件数最多', AVAILABLE_DESC: '可售件数最多', OUTBOUND_7_DESC: '完整7日出库最多', OUTBOUND_15_DESC: '完整15日出库最多', OUTBOUND_30_DESC: '完整30日出库最多', COVERAGE_ASC: '可售天数从少到多', COVERAGE_DESC: '可售天数从多到少', TURNOVER_DESC: '期间件数周转次数最多', NO_OUTBOUND_PHYSICAL_DESC: '已确认无出库实物最多' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseCode', label: '仓库', type: 'select', width: 170, options: warehouses.value, props: { clearable: false } },
|
||||
{ key: 'endDate', label: '截止日', type: 'date', width: 150, props: { valueFormat: 'YYYY-MM-DD', clearable: false, disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } },
|
||||
@@ -47,7 +47,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_30D', label: '近30日无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
|
||||
{ key: 'coverageDaysThreshold', label: '快慢阈值(天)', type: 'input', width: 145, placeholder: '正整数,可选' },
|
||||
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
|
||||
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSorts.filter(s => filters.value.groupBy === 'OWNER' ? !['COVERAGE_ASC', 'COVERAGE_DESC'].includes(s) : s !== 'NO_OUTBOUND_PHYSICAL_DESC').map(value => ({ value, label: sortLabels[value] })) },
|
||||
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
|
||||
])
|
||||
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
@@ -60,7 +60,19 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
...(ownerMode.value ? [{ prop: 'noOutbound30d', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]),
|
||||
{ prop: 'attentionReasons', label: '状态与样本', slotName: 'status', minWidth: 180 },
|
||||
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slotName: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slotName: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slotName: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slotName: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slotName: 'lastDate', minWidth: 180 }] : []),
|
||||
])
|
||||
].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined })))
|
||||
function columnSortDirection(prop: string) {
|
||||
const metric = turnoverSortColumns[prop]
|
||||
if (!metric) return undefined
|
||||
return applied.value?.sortBy === `${metric}_ASC` ? 'ASC' : applied.value?.sortBy === `${metric}_DESC` ? 'DESC' : undefined
|
||||
}
|
||||
async function sortColumn(prop: string) {
|
||||
const metric = turnoverSortColumns[prop]
|
||||
if (!metric || loading.value) return
|
||||
const sortBy: TurnoverSort = `${metric}_${columnSortDirection(prop) === 'DESC' ? 'ASC' : 'DESC'}`
|
||||
filters.value = { ...filters.value, sortBy }
|
||||
await search()
|
||||
}
|
||||
const cards = computed(() => data.value?.summary ? [
|
||||
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
|
||||
{ name: '近30日无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
|
||||
@@ -84,7 +96,9 @@ function restoreThreshold(warehouse: string) {
|
||||
return ''
|
||||
}
|
||||
function quickFocus(focus: TurnoverQuery['focus'], sortBy: TurnoverSort) {
|
||||
filters.value = { ...filters.value, focus, sortBy }; void search()
|
||||
const needsThreshold = ['COVERAGE_GT', 'COVERAGE_LE'].includes(focus)
|
||||
const threshold = filters.value.coverageDaysThreshold
|
||||
filters.value = { ...filters.value, focus, sortBy, coverageDaysThreshold: needsThreshold && (threshold == null || String(threshold).trim() === '') ? 30 : threshold }; void search()
|
||||
}
|
||||
function query(): TurnoverQuery {
|
||||
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: String(filters.value.endDate || ''), baseWindowDays: Number(filters.value.baseWindowDays) as TurnoverQuery['baseWindowDays'], groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
|
||||
@@ -198,6 +212,12 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<LoadErrorState v-if="error" title="库存周转读取失败" :message="error.message" compact @retry="runtime ? load() : initialize()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
|
||||
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
|
||||
<template #sortHeader="{ column }">
|
||||
<button type="button" class="turnover-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${columnSortDirection(column.property) === 'ASC' ? '当前升序,' : columnSortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${columnSortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortColumn(column.property)">
|
||||
{{ column.label }}
|
||||
<span class="turnover-sort-arrows" aria-hidden="true"><span :class="{ active: columnSortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: columnSortDirection(column.property) === 'DESC' }">▼</span></span>
|
||||
</button>
|
||||
</template>
|
||||
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
|
||||
<template #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></div></el-popover></template>
|
||||
<template #filters>
|
||||
@@ -216,12 +236,26 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<template v-if="data?.charts">
|
||||
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
|
||||
<div v-if="chartsOpen" class="turnover-charts">
|
||||
<section><h3>货主实物件数 Top10</h3><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p><ChartDetailsDialog title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></section>
|
||||
<section><h3>7/15/30 日日均出库</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'">完整 {{ window.completeDayCount }} / {{ window.days }} 日</small></div></div><ChartDetailsDialog title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></section>
|
||||
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
|
||||
<section><header class="turnover-chart-header"><h3>7/15/30 日日均出库</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'">完整 {{ window.completeDayCount }} / {{ window.days }} 日</small></div></div></section>
|
||||
</div>
|
||||
</template>
|
||||
<nav class="turnover-context turnover-focus" aria-label="库存关注快捷筛选"><el-button :type="applied?.focus === 'ALL' ? 'primary' : 'default'" :plain="applied?.focus !== 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部/实物件数最多</el-button><el-button :type="applied?.focus === 'NO_OUTBOUND_30D' ? 'primary' : 'default'" :plain="applied?.focus !== 'NO_OUTBOUND_30D'" @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</el-button><el-button :type="applied?.focus === 'COVERAGE_GT' ? 'primary' : 'default'" :plain="applied?.focus !== 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</el-button><el-button :type="applied?.focus === 'COVERAGE_LE' ? 'primary' : 'default'" :plain="applied?.focus !== 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</el-button><el-button :type="applied?.focus === 'UNASSESSABLE' ? 'primary' : 'default'" :plain="applied?.focus !== 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</el-button></nav>
|
||||
<p class="turnover-context">筛选结果:{{ data?.total ?? 0 }} {{ ownerMode ? '个货主' : '条SKU' }}</p>
|
||||
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
|
||||
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_30D'" @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button>
|
||||
</nav>
|
||||
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
|
||||
<label>快慢阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="快慢阈值天数" @keydown.enter.prevent="search()" /> 天</label>
|
||||
<el-button @click="search()">应用</el-button>
|
||||
<span>预计可售天数大于阈值为较慢,不超过阈值为较快;首次使用默认30天,可修改。数据不足的商品请查看“待评估”。</span>
|
||||
</div>
|
||||
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
|
||||
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
|
||||
<p v-else-if="error" class="turnover-filter-feedback" role="alert">筛选未更新,当前保留上次结果。{{ error.message }}</p>
|
||||
|
||||
</template>
|
||||
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
|
||||
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
|
||||
@@ -237,15 +271,23 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
|
||||
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天成功的计划库存,要在截止日包含今天时才进入窗口,历史缺日不能补零。</p></template></AppDialog>
|
||||
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog>
|
||||
</template>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.turnover-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
||||
.turnover-sort:disabled { cursor: wait; }
|
||||
.turnover-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
|
||||
.turnover-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
|
||||
.turnover-sort-arrows .active { color: var(--el-color-primary); }
|
||||
.turnover-page :deep(.data-table--integrated > .data-table__summary) { padding: 12px 20px 0; }
|
||||
.turnover-page :deep(.data-table--integrated > .el-table) { width: calc(100% - 40px); margin-inline: 20px; }
|
||||
|
||||
.turnover-title { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
|
||||
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 16px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 16px 12px; }
|
||||
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 0 12px; }
|
||||
.turnover-cards article { position: relative; overflow: hidden; padding: 16px 20px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
|
||||
.turnover-cards article:nth-child(2) { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
|
||||
.turnover-cards article:nth-child(3) { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
|
||||
@@ -253,19 +295,30 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
|
||||
.turnover-cards article > span { color: var(--el-text-color-regular); }
|
||||
.turnover-cards strong { display: block; font-size: 28px; margin: 8px 0 4px; font-variant-numeric: tabular-nums; }
|
||||
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 16px 12px; }
|
||||
.turnover-charts section { background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
|
||||
.turnover-chart, .turnover-chart-empty { height: 220px; }
|
||||
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
|
||||
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
|
||||
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
|
||||
.turnover-chart-empty { flex: 1 1 0; min-height: 0; padding: 16px; text-align: center; }
|
||||
.turnover-chart-header { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.turnover-chart-header h3 { margin: 0; }
|
||||
.turnover-chart-header :deep(.chart-disclosure) { flex-shrink: 0; }
|
||||
.turnover-chart-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 8px; margin-top: 12px; }
|
||||
.turnover-chart-empty strong { color: var(--el-text-color-regular); }
|
||||
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-window-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 8px 0 12px; }
|
||||
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; }
|
||||
.turnover-window-summary { flex-shrink: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 12px 0 0; }
|
||||
.turnover-window-summary > div { background: var(--el-fill-color-lighter); padding: 8px 12px; border-radius: 6px; }
|
||||
.turnover-window-summary span, .turnover-window-summary strong { display: block; }
|
||||
.turnover-window-summary span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin-bottom: 4px; }
|
||||
.turnover-window-summary strong { font-variant-numeric: tabular-nums; }
|
||||
.turnover-focus { border-top: 1px solid var(--el-border-color-lighter); padding-top: 16px; gap: 8px; }
|
||||
.turnover-focus :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 4px 0 0; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
|
||||
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
|
||||
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.turnover-threshold { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 0; font-size: var(--admin-font-caption); }
|
||||
.turnover-threshold label { display: flex; align-items: center; gap: 8px; }
|
||||
.turnover-threshold input { width: 76px; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); font: inherit; color: var(--el-text-color-primary); }
|
||||
.turnover-threshold > span { color: var(--el-text-color-secondary); }
|
||||
.turnover-filter-feedback { margin: 8px 0; color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
|
||||
h3 { margin: 0 0 12px; font-size: var(--admin-font-body); border-left: 3px solid var(--el-color-primary); padding-left: 10px; }
|
||||
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-rules { font-size: var(--admin-font-caption); line-height: 1.7; }
|
||||
|
||||
Reference in New Issue
Block a user