1795 lines
56 KiB
Vue
1795 lines
56 KiB
Vue
<template>
|
||
<div class="product-detail-page">
|
||
<!-- 查询条件区域:与主页一致 -->
|
||
<el-card class="query-card" shadow="never">
|
||
<div class="query-form">
|
||
<div class="query-item">
|
||
<span class="query-label">时间区间</span>
|
||
<el-date-picker
|
||
v-model="dateRange"
|
||
type="daterange"
|
||
range-separator="至"
|
||
start-placeholder="开始日期"
|
||
end-placeholder="结束日期"
|
||
value-format="YYYY-MM-DD"
|
||
style="width: 260px"
|
||
:disabled-date="disabledDate"
|
||
@change="handleDateRangeChange"
|
||
/>
|
||
</div>
|
||
|
||
<div class="query-item">
|
||
<span class="query-label">快捷</span>
|
||
<el-button-group>
|
||
<el-button
|
||
:type="activeTimeRange === 'week7' ? 'primary' : 'default'"
|
||
@click="handleTimeRangeClick('week7')"
|
||
>
|
||
近一周
|
||
</el-button>
|
||
<el-button
|
||
:type="activeTimeRange === 'day15' ? 'primary' : 'default'"
|
||
@click="handleTimeRangeClick('day15')"
|
||
>
|
||
近15天
|
||
</el-button>
|
||
<el-button
|
||
:type="activeTimeRange === 'day30' ? 'primary' : 'default'"
|
||
@click="handleTimeRangeClick('day30')"
|
||
>
|
||
近30天
|
||
</el-button>
|
||
</el-button-group>
|
||
</div>
|
||
|
||
<div class="query-form-right">
|
||
<div class="query-item">
|
||
<el-button type="primary" @click="handleQuery" :loading="loading">查询</el-button>
|
||
<el-button @click="handleReset">重置</el-button>
|
||
</div>
|
||
<div class="query-item query-more-toggle">
|
||
<span class="query-toggle-link" @click="queryMoreExpanded = !queryMoreExpanded">
|
||
{{ queryMoreExpanded ? '收起 ▴' : '更多条件 ▾' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-if="queryMoreExpanded">
|
||
<div class="query-form-more">
|
||
<div class="query-item">
|
||
<span class="query-label">年份</span>
|
||
<el-button-group>
|
||
<el-button
|
||
v-for="y in yearOptions"
|
||
:key="y"
|
||
:type="activeTimeRange === `year${y}` ? 'primary' : 'default'"
|
||
@click="handleTimeRangeClick(`year${y}`)"
|
||
>
|
||
{{ y }}
|
||
</el-button>
|
||
</el-button-group>
|
||
</div>
|
||
|
||
<div class="query-item">
|
||
<span class="query-label">季节</span>
|
||
<el-select
|
||
v-model="queryParams.season"
|
||
placeholder="请选择季节(可多选)"
|
||
filterable
|
||
clearable
|
||
multiple
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
style="width: 180px"
|
||
>
|
||
<el-option
|
||
v-for="opt in seasonOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="query-item">
|
||
<span class="query-label">正特价</span>
|
||
<el-select
|
||
v-model="queryParams.zgj"
|
||
placeholder="请选择(可多选)"
|
||
filterable
|
||
clearable
|
||
multiple
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
style="width: 160px"
|
||
>
|
||
<el-option label="正价" value="1" />
|
||
<el-option label="特价" value="0" />
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="query-item">
|
||
<span class="query-label">大类</span>
|
||
<el-select
|
||
v-model="queryParams.category"
|
||
placeholder="请选择大类(可多选)"
|
||
filterable
|
||
clearable
|
||
multiple
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
style="width: 180px"
|
||
>
|
||
<el-option
|
||
v-for="opt in categoryOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="query-item">
|
||
<span class="query-label">品牌</span>
|
||
<el-select
|
||
v-model="brandModel"
|
||
:placeholder="isBrandMultiSelect ? '请选择品牌(可多选)' : '请选择品牌'"
|
||
filterable
|
||
clearable
|
||
:multiple="isBrandMultiSelect"
|
||
:collapse-tags="isBrandMultiSelect"
|
||
collapse-tags-tooltip
|
||
style="width: 180px"
|
||
>
|
||
<el-option
|
||
v-for="opt in brandOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="query-item">
|
||
<span class="query-label">线路</span>
|
||
<el-select
|
||
v-model="queryParams.line"
|
||
placeholder="请选择线路(可多选)"
|
||
filterable
|
||
clearable
|
||
multiple
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
style="width: 180px"
|
||
>
|
||
<el-option
|
||
v-for="opt in lineOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="query-item">
|
||
<span class="query-label">门店</span>
|
||
<el-select
|
||
v-model="queryParams.ckdm"
|
||
placeholder="请选择门店(可多选)"
|
||
filterable
|
||
clearable
|
||
multiple
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
style="width: 200px"
|
||
>
|
||
<el-option
|
||
v-for="opt in storeOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 展示形式切换(放在最上面) -->
|
||
<!-- 内容卡片:展示形式切换 + 标签 + 卡片/列表内容(标签放在卡片内,不突兀) -->
|
||
<el-card class="content-card" shadow="never">
|
||
<!-- 展示形式切换:移入内容卡片内部,置于标签上方,仅保留切换按钮 -->
|
||
<div class="view-toolbar">
|
||
<div class="view-switch">
|
||
<el-radio-group v-model="displayMode" size="default">
|
||
<el-radio-button label="card">
|
||
<el-icon><Grid /></el-icon>
|
||
卡片
|
||
</el-radio-button>
|
||
<el-radio-button label="table">
|
||
<el-icon><List /></el-icon>
|
||
列表
|
||
</el-radio-button>
|
||
</el-radio-group>
|
||
</div>
|
||
<div v-if="displayMode === 'table'" class="table-search">
|
||
<el-input
|
||
v-model="tableSearchKeyword"
|
||
placeholder="搜索款号/名称"
|
||
style="width: 200px"
|
||
clearable
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 标签分类筛选:放在卡片内部 -->
|
||
<div v-if="labelOpts.length > 0" class="filter-bar">
|
||
<div
|
||
v-for="opt in labelOpts"
|
||
:key="opt.value"
|
||
:class="['pill', { active: labelFilter === opt.value }]"
|
||
@click="labelFilter = opt.value"
|
||
>
|
||
{{ opt.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 卡片形式:商品卡片列表 -->
|
||
<div v-show="displayMode === 'card'" v-loading="loading" class="product-cards-container">
|
||
<div v-if="productList.length === 0 && !loading" class="empty-state">
|
||
<el-empty description="暂无数据" />
|
||
</div>
|
||
<div v-else class="product-cards-grid">
|
||
<div
|
||
v-for="(item, index) in visibleProductList"
|
||
:key="index"
|
||
class="product-card-item"
|
||
>
|
||
<div class="product-detail-card kb22-card">
|
||
<div class="kb22-header-bar">
|
||
<div>款号: <span class="kb22-code">{{ item.code }}</span></div>
|
||
<div class="kb22-color-badge">
|
||
<span class="kb22-color-dot" :style="{ background: getColorCode(item.color) }"></span>
|
||
{{ item.color }}
|
||
</div>
|
||
</div>
|
||
<div class="kb22-card-body">
|
||
<div class="kb22-media-row">
|
||
<div class="kb22-thumb-box">
|
||
<img v-if="item.imageUrl" :src="item.imageUrl" alt="" class="kb22-thumb-img" />
|
||
<div v-else class="kb22-no-img"><el-icon :size="32"><Picture /></el-icon></div>
|
||
</div>
|
||
<div class="kb22-info-col">
|
||
<div class="kb22-prod-title">{{ item.name }}</div>
|
||
<div class="kb22-tags-container">
|
||
<span class="kb22-tag-pill kb22-tag-blue">{{ item.season }}</span>
|
||
<span class="kb22-tag-pill kb22-tag-purple">{{ item.discount }}</span>
|
||
<el-tag v-if="item.type" :type="item.type === '畅销款' ? 'success' : item.type === '滞销款' ? 'danger' : 'info'" size="small" style="margin-left: 4px;">
|
||
{{ item.type }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="kb22-price-section">
|
||
<span class="kb22-current-price">¥{{ item.sellingPrice ?? 0 }}</span>
|
||
<span class="kb22-cost-price">¥{{ item.purchasePrice ?? 0 }}</span>
|
||
<span class="kb22-margin-text">毛利 {{ formatGrossMarginPct(item.grossMargin, item.purchasePrice != null && item.sellingPrice != null ? (1 - item.purchasePrice / item.sellingPrice) * 100 : undefined) }}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="kb22-stats-grid">
|
||
<div class="kb22-stat-card kb22-sales">
|
||
<div class="kb22-stat-label">总销量</div>
|
||
<div class="kb22-stat-value">{{ item.salesCount ?? 0 }}<span class="kb22-stat-sub">件</span></div>
|
||
<div class="kb22-sales-footer">
|
||
<div class="kb22-mini-progress">
|
||
<div class="kb22-mini-fill" :style="{ width: Math.min(100, item.selloutRate ?? 0) + '%' }"></div>
|
||
</div>
|
||
<div class="kb22-rate-text">售罄率 {{ item.selloutRate ?? 0 }}%</div>
|
||
</div>
|
||
</div>
|
||
<div class="kb22-stat-card kb22-stock">
|
||
<div class="kb22-stat-label">当前库存</div>
|
||
<div class="kb22-stat-value kcsl-display">{{ item.kcslRaw || (item.inventoryCount != null ? item.inventoryCount + '件' : '0件') }}</div>
|
||
<div v-if="item.turnoverText" class="kb22-turnover-text" :class="uiTextClass(item.turnoverStatus)">周转: {{ item.turnoverText }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="item.sizes && item.sizes.length" class="kb22-stock-footer">
|
||
<div class="kb22-section-header">
|
||
<span>SKU明细 ({{ item.sizes.length }}码)</span>
|
||
<span v-if="hasOutOfStockSize(item.sizes)" class="kb22-alert-tip">⚠️ 有断货</span>
|
||
</div>
|
||
<div class="kb22-size-grid">
|
||
<div
|
||
v-for="(size, i) in item.sizes"
|
||
:key="i"
|
||
class="kb22-size-cell"
|
||
:class="{ 'kb22-alert': size.status === 'out' || size.stock === 0 }"
|
||
>
|
||
<div v-if="size.status === 'out' || size.stock === 0" class="kb22-alert-badge">补</div>
|
||
<span class="kb22-sz-tag">{{ size.label }}</span>
|
||
<div class="kb22-sz-data-row">
|
||
<span class="kb22-sz-stock">{{ size.stock != null ? size.stock : '—' }}</span>
|
||
<span class="kb22-sz-sales">销 {{ size.sales != null ? size.sales : '—' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="kb22-legend">
|
||
<div class="kb22-legend-item"><span class="kb22-dot" style="background:#0f172a"></span>库存</div>
|
||
<div class="kb22-legend-item"><span class="kb22-dot" style="background:#3b82f6"></span>销量</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 列表形式:商品明细表格(与主页商品明细列表一致,放在同一张内容卡片内) -->
|
||
<div v-show="displayMode === 'table'" class="detail-table-wrap">
|
||
<el-table
|
||
:data="filteredTableList"
|
||
v-loading="loading"
|
||
border
|
||
stripe
|
||
style="width: 100%"
|
||
row-class-name="product-list-row-clickable"
|
||
@row-click="handleDetailRowClick"
|
||
>
|
||
<el-table-column prop="productInfo" label="商品信息" width="280">
|
||
<template #default="{ row }">
|
||
<div class="product-info">
|
||
<div class="product-image">
|
||
<img v-if="row.imageUrl" :src="row.imageUrl" alt="商品图片" class="product-img" />
|
||
<el-icon v-else><Picture /></el-icon>
|
||
</div>
|
||
<div class="product-details">
|
||
<div class="product-code">款号: {{ row.code || '-' }}</div>
|
||
<div class="product-code">条码: {{ row.barcode || '-' }}</div>
|
||
<div class="product-code">颜色: {{ row.color }}</div>
|
||
<div class="product-code">进价: ¥{{ formatNumber(row.purchasePrice || 0) }}</div>
|
||
<div class="product-code">售价: ¥{{ formatNumber(row.sellingPrice || 0) }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="category" label="商品属性" width="200" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<div>{{ row.category }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="ls" label="销售数据" align="right" width="150">
|
||
<template #default="{ row }">
|
||
<div class="font-bold">{{ row.lsRaw || '-' }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="j7sl" label="近7天销售" align="right" width="150">
|
||
<template #default="{ row }">
|
||
<div class="font-bold">{{ row.j7slRaw || '-' }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="kcsl" label="库存数量" align="right" width="150">
|
||
<template #default="{ row }">
|
||
<div class="font-bold kcsl-display">{{ row.kcslRaw || '-' }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="zksql" label="平均折扣/售罄率" align="center" width="150">
|
||
<template #default="{ row }">
|
||
<div>{{ row.zksqlRaw || '-' }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="shdxd" label="上货店/动销店" align="center" width="150">
|
||
<template #default="{ row }">
|
||
<div>{{ row.shdxdRaw || '-' }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="wsdp" label="未上店铺" align="center" width="120">
|
||
<template #default="{ row }">
|
||
<div>{{ row.wsdpRaw || '-' }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="sizeStatus" label="断码监控 (S/M/L/XL)" width="150">
|
||
<template #default="{ row }">
|
||
<div class="size-status">
|
||
<span
|
||
v-for="(size, i) in row.sizes"
|
||
:key="i"
|
||
class="size-badge"
|
||
:class="sizeChipClass(size.status)"
|
||
:title="size.title"
|
||
>
|
||
{{ size.label }}
|
||
</span>
|
||
</div>
|
||
<div :class="uiTextClass(row.sizeStatusStatus)">{{ row.sizeStatusText }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="supplierName" label="供货商" width="140" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<div>{{ row.supplierName || '-' }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="type" label="商品类型" width="120" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.type" :type="row.type === '畅销款' ? 'success' : row.type === '滞销款' ? 'danger' : 'info'" size="small">
|
||
{{ row.type }}
|
||
</el-tag>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" align="center" width="100" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button :type="row.actionType" size="small" @click.stop="handleDetailAction(row)">
|
||
{{ row.actionText }}
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import dayjs from 'dayjs'
|
||
import { Picture, Grid, List, Search } from '@element-plus/icons-vue'
|
||
import { ReportApi } from '@/api/ydoyun/report/reportpage'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
import type { ProductDetailData, ProductSizeStatus, UiStatus } from './index.vue'
|
||
|
||
defineOptions({ name: 'ProductDetailPage' })
|
||
|
||
const REPORT_ID = 6
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const loading = ref(false)
|
||
const productList = ref<ProductDetailData[]>([])
|
||
/** 标签分类筛选 */
|
||
const labelFilter = ref<string>('all')
|
||
/** 展示形式:卡片 | 列表 */
|
||
const displayMode = ref<'card' | 'table'>('card')
|
||
/** 列表模式下的搜索关键字 */
|
||
const tableSearchKeyword = ref('')
|
||
|
||
// 从路由参数获取spdm
|
||
const spdmFromRoute = computed(() => route.query.spdm as string || '')
|
||
|
||
const username = computed(() => {
|
||
return userStore.user?.username ?? ''
|
||
})
|
||
|
||
// 查询参数(与主页一致)
|
||
const queryParams = reactive({
|
||
rq: dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||
rq2: dayjs().format('YYYY-MM-DD'),
|
||
ckdm: [] as string[],
|
||
pp: [] as string[],
|
||
season: [] as string[],
|
||
zgj: [] as string[],
|
||
category: [] as string[],
|
||
line: [] as string[],
|
||
spdm: '' // 商品代码
|
||
})
|
||
|
||
// 初始化spdm
|
||
queryParams.spdm = spdmFromRoute.value
|
||
|
||
// 从路由参数初始化查询条件
|
||
function initQueryParamsFromRoute() {
|
||
const q = route.query
|
||
if (q.rq) queryParams.rq = String(q.rq)
|
||
if (q.rq2) queryParams.rq2 = String(q.rq2)
|
||
if (q.ckdm) queryParams.ckdm = String(q.ckdm).split(',').filter(Boolean)
|
||
if (q.pp) queryParams.pp = String(q.pp).split(',').filter(Boolean)
|
||
if (q.season) queryParams.season = String(q.season).split(',').filter(Boolean)
|
||
if (q.zgj) queryParams.zgj = String(q.zgj).split(',').filter(Boolean)
|
||
if (q.category) queryParams.category = String(q.category).split(',').filter(Boolean)
|
||
if (q.line) queryParams.line = String(q.line).split(',').filter(Boolean)
|
||
if (q.spdm) queryParams.spdm = String(q.spdm)
|
||
dateRange.value = [queryParams.rq, queryParams.rq2]
|
||
|
||
// 根据路由传入的时间区间反推当前快捷按钮
|
||
try {
|
||
const rq = queryParams.rq
|
||
const rq2 = queryParams.rq2
|
||
const today = dayjs().format('YYYY-MM-DD')
|
||
|
||
// 仅在 rq2 为今天时才尝试匹配 week7/day15/day30 或年份
|
||
if (rq && rq2 && rq2 === today) {
|
||
const diff = dayjs(today).diff(dayjs(rq), 'day')
|
||
if (diff === 6) {
|
||
activeTimeRange.value = 'week7'
|
||
return
|
||
}
|
||
if (diff === 14) {
|
||
activeTimeRange.value = 'day15'
|
||
return
|
||
}
|
||
if (diff === 29) {
|
||
activeTimeRange.value = 'day30'
|
||
return
|
||
}
|
||
}
|
||
|
||
// 尝试匹配全年快捷:从某年1月1日到该年末或今天
|
||
if (rq && rq2) {
|
||
const startYear = dayjs(rq).year()
|
||
const yearStart = dayjs(`${startYear}-01-01`).format('YYYY-MM-DD')
|
||
const yearEnd = startYear === dayjs().year() ? today : dayjs(`${startYear}-12-31`).format('YYYY-MM-DD')
|
||
if (rq === yearStart && rq2 === yearEnd) {
|
||
activeTimeRange.value = `year${startYear}`
|
||
return
|
||
}
|
||
}
|
||
|
||
// 其余情况,不高亮任何快捷按钮
|
||
activeTimeRange.value = ''
|
||
} catch {
|
||
activeTimeRange.value = ''
|
||
}
|
||
}
|
||
|
||
// 下拉选项(门店通过 executeTable tableName=kehu 获取)
|
||
const storeOptions = ref<{ label: string; value: string }[]>([])
|
||
const brandOptions = ref<{ label: string; value: string }[]>([])
|
||
const seasonOptions = ref<{ label: string; value: string }[]>([])
|
||
const categoryOptions = ref<{ label: string; value: string }[]>([])
|
||
const lineOptions = ref<{ label: string; value: string }[]>([])
|
||
|
||
// 年份快捷选项
|
||
const yearOptions = computed(() => {
|
||
const current = dayjs().year()
|
||
return Array.from({ length: 4 }, (_, i) => current - i)
|
||
})
|
||
|
||
// 品牌多选权限
|
||
const isBrandMultiSelect = computed(() => {
|
||
const roles = userStore.getRoles ?? []
|
||
return Array.isArray(roles) && (roles.includes('经理') || roles.includes('manager'))
|
||
})
|
||
|
||
// 品牌选择双向绑定
|
||
const brandModel = computed({
|
||
get: () => (isBrandMultiSelect.value ? queryParams.pp : queryParams.pp[0] ?? ''),
|
||
set: (val: string | string[]) => {
|
||
queryParams.pp = Array.isArray(val) ? val : val ? [val] : []
|
||
}
|
||
})
|
||
|
||
// 日期范围
|
||
const dateRange = ref<[string, string] | null>([
|
||
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||
dayjs().format('YYYY-MM-DD')
|
||
])
|
||
|
||
// 更多查询条件折叠
|
||
const queryMoreExpanded = ref(false)
|
||
|
||
// 时间范围快捷按钮
|
||
const activeTimeRange = ref<string>('week7')
|
||
|
||
// 禁用超过当天的日期
|
||
const disabledDate = (time: Date) => time.getTime() > Date.now()
|
||
|
||
// 计算时间区间
|
||
const getTimeRange = (range: string) => {
|
||
const today = dayjs()
|
||
let start: dayjs.Dayjs, end: dayjs.Dayjs = today
|
||
if (range.startsWith('year')) {
|
||
const year = parseInt(range.replace('year', ''), 10)
|
||
start = dayjs(`${year}-01-01`)
|
||
end = year === today.year() ? today : dayjs(`${year}-12-31`)
|
||
return { rq: start.format('YYYY-MM-DD'), rq2: end.format('YYYY-MM-DD') }
|
||
}
|
||
switch (range) {
|
||
case 'week7':
|
||
start = today.subtract(6, 'day')
|
||
break
|
||
case 'day15':
|
||
start = today.subtract(14, 'day')
|
||
break
|
||
case 'day30':
|
||
start = today.subtract(29, 'day')
|
||
break
|
||
default:
|
||
start = today.subtract(6, 'day')
|
||
}
|
||
return { rq: start.format('YYYY-MM-DD'), rq2: end.format('YYYY-MM-DD') }
|
||
}
|
||
|
||
// 日期范围变化
|
||
const handleDateRangeChange = (val: [string, string] | null) => {
|
||
if (val && val.length === 2) {
|
||
queryParams.rq = val[0]
|
||
queryParams.rq2 = val[1]
|
||
activeTimeRange.value = ''
|
||
} else {
|
||
queryParams.rq = ''
|
||
queryParams.rq2 = ''
|
||
dateRange.value = null
|
||
}
|
||
}
|
||
|
||
// 快捷时间按钮点击
|
||
const handleTimeRangeClick = (range: string) => {
|
||
activeTimeRange.value = range
|
||
const timeRange = getTimeRange(range)
|
||
queryParams.rq = timeRange.rq
|
||
queryParams.rq2 = timeRange.rq2
|
||
dateRange.value = [timeRange.rq, timeRange.rq2]
|
||
}
|
||
|
||
// 数组转查询字符串
|
||
const arrToQuery = (arr: string[]) => (Array.isArray(arr) && arr.length > 0 ? arr.join(',') : '')
|
||
|
||
// 解析接口返回数组
|
||
function resolveTableList(res: any): any[] {
|
||
if (res == null) return []
|
||
if (Array.isArray(res)) return res
|
||
const data = (res as any).data
|
||
if (Array.isArray(data)) return data
|
||
if (data && typeof data === 'object') {
|
||
const inner = data.list ?? data.data ?? data.result ?? data.rows
|
||
if (Array.isArray(inner)) return inner
|
||
}
|
||
const list = (res as any).list ?? (res as any).result ?? (res as any).rows
|
||
return Array.isArray(list) ? list : []
|
||
}
|
||
|
||
// 映射函数
|
||
function mapJijieToOptions(list: any): { label: string; value: string }[] {
|
||
if (!Array.isArray(list)) return []
|
||
return list.map((item: any) => ({
|
||
label: item?.JJMC ?? '',
|
||
value: item?.JJDM != null ? String(item.JJDM) : ''
|
||
})).filter((o) => o.label && o.value)
|
||
}
|
||
|
||
function mapPinpaiToOptions(list: any): { label: string; value: string }[] {
|
||
if (!Array.isArray(list)) return []
|
||
return list.map((item: any) => ({
|
||
label: item?.PPMC ?? '',
|
||
value: item?.PPDM != null ? String(item.PPDM) : ''
|
||
})).filter((o) => o.label && o.value)
|
||
}
|
||
|
||
function mapDaleiToOptions(list: any): { label: string; value: string }[] {
|
||
if (!Array.isArray(list)) return []
|
||
return list.map((item: any) => ({
|
||
label: item?.DLMC ?? '',
|
||
value: item?.DLDM != null ? String(item.DLDM) : ''
|
||
})).filter((o) => o.label && o.value)
|
||
}
|
||
|
||
/** kehu 返回:{ khmc, khdm };兼容后端返回大类结构 { DLMC, DLDM } */
|
||
function mapKehuToOptions(list: any): { label: string; value: string }[] {
|
||
if (!Array.isArray(list)) return []
|
||
return list.map((item: any) => {
|
||
const label = item?.khmc ?? item?.CKMC ?? item?.KHMC ?? item?.DLMC ?? item?.label ?? item?.name ?? ''
|
||
const value = item?.khdm != null ? String(item.khdm) : item?.CKDM != null ? String(item.CKDM) : item?.KHDM != null ? String(item.KHDM) : item?.DLDM != null ? String(item.DLDM) : item?.value != null ? String(item.value) : item?.code != null ? String(item.code) : ''
|
||
return { label, value }
|
||
}).filter((o) => o.label && o.value)
|
||
}
|
||
|
||
// 拉取下拉选项
|
||
async function fetchBrandOptions() {
|
||
try {
|
||
const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName: 'pinpai' })
|
||
brandOptions.value = mapPinpaiToOptions(resolveTableList(res))
|
||
} catch (_) {
|
||
brandOptions.value = []
|
||
}
|
||
}
|
||
|
||
async function fetchSeasonOptions() {
|
||
try {
|
||
const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName: 'jijie' })
|
||
seasonOptions.value = mapJijieToOptions(resolveTableList(res))
|
||
} catch (_) {
|
||
seasonOptions.value = []
|
||
}
|
||
}
|
||
|
||
async function fetchCategoryOptions() {
|
||
try {
|
||
const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName: 'dalei' })
|
||
categoryOptions.value = mapDaleiToOptions(resolveTableList(res))
|
||
} catch (_) {
|
||
categoryOptions.value = []
|
||
}
|
||
}
|
||
|
||
async function fetchStoreOptions() {
|
||
try {
|
||
const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName: 'kehu' })
|
||
storeOptions.value = mapKehuToOptions(resolveTableList(res))
|
||
} catch (_) {
|
||
storeOptions.value = []
|
||
}
|
||
}
|
||
|
||
/** 线路选项:暂不查后端,使用空默认值 */
|
||
function fetchLineOptions() {
|
||
lineOptions.value = []
|
||
}
|
||
|
||
// 查询条件默认值:品牌有数据且含 1 则默认 1,门店默认全选
|
||
function applyQueryDefaults() {
|
||
const brands = brandOptions.value
|
||
if (queryParams.pp.length === 0 && Array.isArray(brands) && brands.length > 0 && brands.some((b) => b.value === '1')) {
|
||
queryParams.pp = ['1']
|
||
}
|
||
const stores = storeOptions.value
|
||
if (Array.isArray(stores) && stores.length > 0 && queryParams.ckdm.length === 0) {
|
||
queryParams.ckdm = stores.map((s) => s.value)
|
||
}
|
||
}
|
||
|
||
// 工具函数(从index.vue复制)
|
||
function parseSizeColors(cmStr: string): Array<{ label: string; color: string }> {
|
||
if (!cmStr || typeof cmStr !== 'string') return []
|
||
return cmStr
|
||
.split(';')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
.map((item) => {
|
||
const [label, color] = item.split(',')
|
||
return { label: label?.trim() || '', color: color?.trim() || 'flat' }
|
||
})
|
||
.filter((item) => item.label)
|
||
}
|
||
|
||
function parseSizeDetails(cmjsStr: string): Array<{ label: string; stock: number; color: string }> {
|
||
if (!cmjsStr || typeof cmjsStr !== 'string') return []
|
||
return cmjsStr
|
||
.split(';')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
.map((item) => {
|
||
const parts = item.split(',')
|
||
const label = parts[0]?.trim() || ''
|
||
const stock = Number(parts[1]?.trim()) || 0
|
||
let color = (parts[2]?.trim() || 'flat').toLowerCase()
|
||
if (color === 'yello') color = 'yellow'
|
||
return { label, stock, color }
|
||
})
|
||
.filter((item) => item.label)
|
||
}
|
||
|
||
function parseSalesData(lsStr: string): { amount: number; count: number } {
|
||
if (!lsStr || typeof lsStr !== 'string') {
|
||
return { amount: 0, count: 0 }
|
||
}
|
||
const parts = lsStr.split('/')
|
||
let amount = 0
|
||
let count = 0
|
||
|
||
if (parts.length >= 1) {
|
||
const countMatch = parts[0].match(/(\d+)/)
|
||
if (countMatch) {
|
||
count = Number(countMatch[1]) || 0
|
||
}
|
||
}
|
||
|
||
if (parts.length >= 2) {
|
||
const amountMatch = parts[1].match(/(\d+)/)
|
||
if (amountMatch) {
|
||
amount = Number(amountMatch[1]) || 0
|
||
}
|
||
}
|
||
|
||
return { amount, count }
|
||
}
|
||
|
||
function parseDiscountAndSelloutRate(zksqlStr: string): { discount: string; selloutRate: number } {
|
||
if (!zksqlStr || typeof zksqlStr !== 'string') {
|
||
return { discount: '-', selloutRate: 0 }
|
||
}
|
||
const parts = zksqlStr.split(',').map((s) => s.trim())
|
||
let discount = '-'
|
||
let selloutRate = 0
|
||
|
||
if (parts.length >= 1) {
|
||
const discountVal = parts[0]
|
||
if (discountVal.includes('折')) {
|
||
discount = discountVal
|
||
} else {
|
||
const num = Number(discountVal)
|
||
if (!isNaN(num) && num > 0 && num <= 1) {
|
||
discount = `${Math.round(num * 100) / 10}折`
|
||
} else if (!isNaN(num) && num > 1 && num <= 10) {
|
||
discount = `${num}折`
|
||
}
|
||
}
|
||
}
|
||
|
||
if (parts.length >= 2) {
|
||
const rateVal = parts[1].replace('%', '')
|
||
const rate = Number(rateVal)
|
||
if (!isNaN(rate)) {
|
||
selloutRate = rate > 100 ? rate / 100 : rate
|
||
}
|
||
}
|
||
|
||
return { discount, selloutRate }
|
||
}
|
||
|
||
function mapColorToSizeStatus(color: string, stock: number): 'out' | 'low' | 'warn' | 'ok' {
|
||
if (stock === 0) return 'out'
|
||
const colorLower = color.toLowerCase()
|
||
if (colorLower === 'red') {
|
||
return stock <= 5 ? 'out' : stock <= 20 ? 'low' : 'warn'
|
||
}
|
||
if (colorLower === 'yellow') {
|
||
return 'warn'
|
||
}
|
||
return 'ok'
|
||
}
|
||
|
||
function getSizeTitle(label: string, status: string, stock: number): string {
|
||
if (status === 'out' || stock === 0) return `${label}码 缺货`
|
||
if (status === 'low') return `${label}码 紧张`
|
||
if (status === 'warn') return `${label}码 需关注`
|
||
return `${label}码 充足`
|
||
}
|
||
|
||
function getSelloutRateStatus(rate: number): UiStatus {
|
||
if (rate >= 80) return 'danger'
|
||
if (rate >= 60) return 'success'
|
||
if (rate >= 40) return 'info'
|
||
return 'warning'
|
||
}
|
||
|
||
function getDiscountStatus(discount: string): UiStatus {
|
||
if (!discount || discount === '-') return 'info'
|
||
const match = discount.match(/(\d+(?:\.\d+)?)/)
|
||
if (!match) return 'info'
|
||
const val = Number(match[1])
|
||
if (val >= 9) return 'info'
|
||
if (val >= 7) return 'warning'
|
||
return 'danger'
|
||
}
|
||
|
||
/** 毛利显示为两位小数(支持接口返回比例 0.34 或百分比 34) */
|
||
function formatGrossMarginPct(val: number | null | undefined, fallback?: number): string {
|
||
const n = val != null ? Number(val) : (fallback != null ? fallback : 0)
|
||
if (typeof n !== 'number' || isNaN(n)) return '0.00'
|
||
const pct = n > 0 && n <= 1 ? n * 100 : n
|
||
return Number(pct).toFixed(2)
|
||
}
|
||
|
||
function getColorCode(colorName: string): string {
|
||
const colorMap: Record<string, string> = {
|
||
'黑色': '#1a1a1a',
|
||
'白色': '#f5f5f5',
|
||
'红色': '#e53935',
|
||
'蓝色': '#1e88e5',
|
||
'绿色': '#43a047',
|
||
'黄色': '#fdd835',
|
||
'橙色': '#fb8c00',
|
||
'紫色': '#8e24aa',
|
||
'粉色': '#ec407a',
|
||
'灰色': '#757575',
|
||
'米白': '#f5f5dc',
|
||
'米色': '#f5f5dc',
|
||
'驼色': '#c19a6b',
|
||
'卡其色': '#c3b091',
|
||
'藏青': '#1a237e',
|
||
'深蓝': '#0d47a1',
|
||
'浅蓝': '#64b5f6',
|
||
'天蓝': '#03a9f4',
|
||
'深灰': '#424242',
|
||
'浅灰': '#bdbdbd',
|
||
'咖啡色': '#795548',
|
||
'棕色': '#8d6e63',
|
||
'酒红': '#880e4f',
|
||
'墨绿': '#1b5e20',
|
||
'军绿': '#558b2f',
|
||
'杏色': '#ffcc80',
|
||
'奶白': '#fffaf0',
|
||
'藕粉': '#ffcdd2',
|
||
'均色': '#9e9e9e',
|
||
'淡蓝': '#64b5f6',
|
||
'兰色': '#1e88e5',
|
||
'枣红': '#880e4f'
|
||
}
|
||
return colorMap[colorName] || '#9e9e9e'
|
||
}
|
||
|
||
function mapApiRowToProductDetail(row: any): ProductDetailData {
|
||
const salesData = parseSalesData(row.ls || '')
|
||
const sizeDetails = parseSizeDetails(row.cmjs || '')
|
||
const sizeColors = parseSizeColors(row.cm || '')
|
||
const sizes: ProductSizeStatus[] = sizeDetails.length > 0
|
||
? sizeDetails.map((item) => {
|
||
const status = mapColorToSizeStatus(item.color, item.stock)
|
||
return {
|
||
label: item.label,
|
||
status,
|
||
title: getSizeTitle(item.label, status, item.stock),
|
||
stock: item.stock,
|
||
sales: undefined
|
||
}
|
||
})
|
||
: sizeColors.map((item) => {
|
||
return {
|
||
label: item.label,
|
||
status: item.color === 'red' ? 'low' : item.color === 'yellow' ? 'warn' : 'ok',
|
||
title: `${item.label}码`,
|
||
stock: undefined,
|
||
sales: undefined
|
||
}
|
||
})
|
||
|
||
const { discount, selloutRate } = parseDiscountAndSelloutRate(row.zksql || '')
|
||
let finalSelloutRate = selloutRate
|
||
if (finalSelloutRate === 0 && row.ls != null && row.kcsl != null) {
|
||
const sold = salesData.count || 0
|
||
const kcslStr = String(row.kcsl || '').split('\r\n')[0].trim()
|
||
const total = Number(kcslStr) || 0
|
||
if (total > 0) {
|
||
finalSelloutRate = Math.round((sold / (sold + total)) * 1000) / 10
|
||
}
|
||
}
|
||
|
||
let finalDiscount = discount
|
||
if (finalDiscount === '-' && row.jj != null && row.sj != null) {
|
||
const purchasePrice = Number(row.jj) || 0
|
||
const sellingPrice = Number(row.sj) || 0
|
||
if (sellingPrice > 0) {
|
||
const discountRate = purchasePrice / sellingPrice
|
||
finalDiscount = `${Math.round(discountRate * 100) / 10}折`
|
||
}
|
||
}
|
||
|
||
const purchasePrice = Number(row.jj) || 0
|
||
const sellingPrice = Number(row.sj) || 0
|
||
const grossMargin = sellingPrice > 0 ? (sellingPrice - purchasePrice) / sellingPrice : 0
|
||
const kcslStr = String(row.kcsl || '').split('\r\n')[0].trim()
|
||
const inventoryCount = Number(kcslStr) || 0
|
||
|
||
const turnoverDays = 0
|
||
const turnoverText = ''
|
||
const turnoverStatus: UiStatus = 'info'
|
||
|
||
const outOfStockSizes = sizes.filter((s) => s.status === 'out' || s.stock === 0)
|
||
let sizeStatusText = ''
|
||
let sizeStatusStatus: UiStatus = 'info'
|
||
if (outOfStockSizes.length > 0) {
|
||
sizeStatusText = `缺${outOfStockSizes.map((s) => s.label).join('/')}码`
|
||
sizeStatusStatus = 'danger'
|
||
} else {
|
||
const lowStockSizes = sizes.filter((s) => s.status === 'low' || (s.stock != null && s.stock < 10))
|
||
if (lowStockSizes.length > 0) {
|
||
sizeStatusText = `${lowStockSizes.map((s) => s.label).join('/')}码需补货`
|
||
sizeStatusStatus = 'warning'
|
||
} else {
|
||
sizeStatusText = '库存齐色齐码'
|
||
sizeStatusStatus = 'info'
|
||
}
|
||
}
|
||
|
||
let lifecycle = '正常销售'
|
||
let lifecycleType: 'success' | 'danger' | 'info' | 'warning' = 'info'
|
||
let lifecycleIcon = 'ep:circle-check'
|
||
const salesCountForLifecycle = salesData.count || 0
|
||
if (finalSelloutRate >= 70 && salesCountForLifecycle > 100) {
|
||
lifecycle = '爆发成长期'
|
||
lifecycleType = 'success'
|
||
lifecycleIcon = 'fa-solid:fire-alt'
|
||
} else if (finalSelloutRate < 20) {
|
||
lifecycle = '衰退期'
|
||
lifecycleType = 'danger'
|
||
lifecycleIcon = 'ep:circle-close'
|
||
}
|
||
|
||
let actionText = '分析'
|
||
let actionType: 'primary' | 'warning' | 'default' | 'danger' = 'default'
|
||
if (outOfStockSizes.length > 0) {
|
||
actionText = '补货'
|
||
actionType = 'primary'
|
||
} else if (finalSelloutRate < 20) {
|
||
actionText = '清仓'
|
||
actionType = 'danger'
|
||
} else if (finalDiscount.includes('5') || finalDiscount.includes('6')) {
|
||
actionText = '调价'
|
||
actionType = 'warning'
|
||
}
|
||
|
||
const productCode = String(row.spdm || '') + String(row.spmc || '')
|
||
|
||
return {
|
||
name: row.spsx || row.spdm || '-',
|
||
code: productCode || String(row.spdm || ''),
|
||
spdm: String(row.spdm || ''),
|
||
color: row.ysmc || '-',
|
||
season: '-',
|
||
category: row.spsx || '-',
|
||
daysOnMarket: 0,
|
||
salesAmount: salesData.amount,
|
||
salesCount: salesData.count,
|
||
inventoryCount: inventoryCount,
|
||
turnoverText,
|
||
turnoverStatus,
|
||
selloutRate: finalSelloutRate,
|
||
selloutRateStatus: getSelloutRateStatus(finalSelloutRate),
|
||
discount: finalDiscount,
|
||
discountStatus: getDiscountStatus(finalDiscount),
|
||
sizes,
|
||
sizeStatusText,
|
||
sizeStatusStatus,
|
||
lifecycle,
|
||
lifecycleType,
|
||
lifecycleIcon,
|
||
actionText,
|
||
actionType,
|
||
imageUrl: row.pic || undefined,
|
||
barcode: row.sptm || undefined,
|
||
purchasePrice: purchasePrice > 0 ? purchasePrice : undefined,
|
||
sellingPrice: sellingPrice > 0 ? sellingPrice : undefined,
|
||
grossMargin: grossMargin > 0 ? grossMargin : undefined,
|
||
supplierName: row.ghs || undefined,
|
||
type: row.type || undefined,
|
||
turnoverDays: turnoverDays > 0 ? turnoverDays : undefined,
|
||
lsRaw: row.ls || undefined,
|
||
j7slRaw: row.j7sl || undefined,
|
||
kcslRaw: row.kcsl || undefined,
|
||
zksqlRaw: row.zksql || undefined,
|
||
shdxdRaw: row.shdxd || undefined,
|
||
wsdpRaw: row.wsdp || undefined
|
||
}
|
||
}
|
||
|
||
function uiTextClass(status?: string): string {
|
||
const s = String(status ?? '').toLowerCase()
|
||
if (s === 'success') return 'text-success'
|
||
if (s === 'danger') return 'text-danger'
|
||
if (s === 'warning') return 'text-warning'
|
||
return 'text-info'
|
||
}
|
||
|
||
function hasOutOfStockSize(sizes: ProductSizeStatus[]): boolean {
|
||
return Array.isArray(sizes) && sizes.some((s) => s.status === 'out' || s.stock === 0)
|
||
}
|
||
|
||
/** 从 productList 中收集所有标签(type 和 lifecycle)去重后作为筛选项,首项为「全部」 */
|
||
const labelOpts = computed(() => {
|
||
const labels = new Set<string>()
|
||
productList.value.forEach((item) => {
|
||
if (item.type) labels.add(item.type)
|
||
if (item.lifecycle) labels.add(item.lifecycle)
|
||
})
|
||
const list = Array.from(labels).sort((a, b) => a.localeCompare(b, 'zh-CN'))
|
||
return [{ label: '全部', value: 'all' }, ...list.map((l) => ({ label: l, value: l }))]
|
||
})
|
||
|
||
/** 行是否匹配当前标签筛选 */
|
||
function productMatchesLabelFilter(item: ProductDetailData, filter: string): boolean {
|
||
if (filter === 'all') return true
|
||
return item.type === filter || item.lifecycle === filter
|
||
}
|
||
|
||
/** 按标签筛选后的商品列表 */
|
||
const visibleProductList = computed(() => {
|
||
const filter = labelFilter.value
|
||
if (filter === 'all') return productList.value
|
||
return productList.value.filter((item) => productMatchesLabelFilter(item, filter))
|
||
})
|
||
|
||
/** 列表模式下再按款号/名称搜索后的列表 */
|
||
const filteredTableList = computed(() => {
|
||
const list = visibleProductList.value
|
||
if (!tableSearchKeyword.value) return list
|
||
const kw = tableSearchKeyword.value.toLowerCase()
|
||
return list.filter((row) => (row.name || '').toLowerCase().includes(kw) || (row.code || '').toLowerCase().includes(kw))
|
||
})
|
||
|
||
function formatNumber(n: number): string {
|
||
return n >= 10000 ? (n / 10000).toFixed(1) + 'w' : n.toLocaleString('zh-CN')
|
||
}
|
||
|
||
function sizeChipClass(status?: string): string {
|
||
const s = String(status ?? '').toLowerCase()
|
||
if (s === 'out') return 'size-red'
|
||
if (s === 'low') return 'size-orange'
|
||
if (s === 'warn') return 'size-yellow'
|
||
return 'size-gray'
|
||
}
|
||
|
||
function handleDetailProductCodeClick(row: ProductDetailData) {
|
||
router.push({
|
||
path: '/reports/lijun/reportpage6/detail',
|
||
query: { spdm: row.spdm || row.code || '' }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
function handleDetailAction(row: ProductDetailData) {
|
||
ElMessage.info(`执行操作: ${row.actionText}`)
|
||
}
|
||
|
||
function handleDetailRowClick(_row: ProductDetailData) {
|
||
// 点击行不跳转,与当前详情页一致
|
||
}
|
||
|
||
// 查询
|
||
async function handleQuery() {
|
||
loading.value = true
|
||
productList.value = []
|
||
|
||
try {
|
||
applyQueryDefaults()
|
||
const baseParams = {
|
||
reportId: REPORT_ID,
|
||
rq_s: queryParams.rq,
|
||
rq_e: queryParams.rq2,
|
||
ckdm: arrToQuery(queryParams.ckdm),
|
||
pp: arrToQuery(queryParams.pp),
|
||
dalei: arrToQuery(queryParams.category),
|
||
jj: arrToQuery(queryParams.season),
|
||
p: '123',
|
||
username: username.value
|
||
}
|
||
|
||
const productRes: any = await ReportApi.executeProcedureWithData({
|
||
...baseParams,
|
||
name: 'YDY_AI_GET_SPDP5'
|
||
} as any)
|
||
.then((productRes: any) => {
|
||
// 处理返回数据结构:可能是 { code, msg, data: [...] } 或直接是数组
|
||
let data: any[] | null = null
|
||
if (productRes != null) {
|
||
// 如果返回的是标准格式 { code, msg, data: [...] }
|
||
if (productRes.code != null && productRes.data != null) {
|
||
data = Array.isArray(productRes.data) ? productRes.data : null
|
||
}
|
||
// 如果直接是数组
|
||
else if (Array.isArray(productRes)) {
|
||
data = productRes
|
||
}
|
||
// 如果返回对象中有 data 字段
|
||
else if (productRes.data != null) {
|
||
data = Array.isArray(productRes.data) ? productRes.data : null
|
||
}
|
||
}
|
||
|
||
if (data && data.length > 0) {
|
||
productList.value = data.map((item: any) => mapApiRowToProductDetail(item))
|
||
} else {
|
||
productList.value = []
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error('查询商品明细列表失败:', error)
|
||
productList.value = []
|
||
throw error
|
||
})
|
||
} catch (error) {
|
||
console.error('查询商品明细列表失败:', error)
|
||
ElMessage.error('查询失败,请重试')
|
||
productList.value = []
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 重置
|
||
function handleReset() {
|
||
queryParams.rq = dayjs().subtract(6, 'day').format('YYYY-MM-DD')
|
||
queryParams.rq2 = dayjs().format('YYYY-MM-DD')
|
||
queryParams.ckdm = []
|
||
queryParams.pp = []
|
||
queryParams.season = []
|
||
queryParams.zgj = []
|
||
queryParams.category = []
|
||
queryParams.line = []
|
||
queryParams.spdm = '' // 清除spdm
|
||
dateRange.value = [
|
||
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||
dayjs().format('YYYY-MM-DD')
|
||
]
|
||
activeTimeRange.value = 'week7'
|
||
productList.value = []
|
||
}
|
||
|
||
// 初始化
|
||
onMounted(async () => {
|
||
initQueryParamsFromRoute()
|
||
await Promise.all([
|
||
fetchBrandOptions(),
|
||
fetchSeasonOptions(),
|
||
fetchCategoryOptions(),
|
||
fetchLineOptions(),
|
||
fetchStoreOptions()
|
||
])
|
||
// 如果有spdm参数,自动查询
|
||
if (spdmFromRoute.value) {
|
||
await handleQuery()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.product-detail-page {
|
||
padding: 20px;
|
||
background: var(--el-bg-color-page);
|
||
|
||
.query-card {
|
||
margin-bottom: 20px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--el-border-color);
|
||
|
||
.query-form {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 24px 32px;
|
||
|
||
.query-form-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 24px 32px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.query-form-more {
|
||
flex: 0 0 100%;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 24px 32px;
|
||
margin-top: 8px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--el-border-color-lighter);
|
||
}
|
||
|
||
.query-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
|
||
.query-label {
|
||
font-size: 14px;
|
||
color: var(--el-text-color-regular);
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
&.query-more-toggle {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.query-toggle-link {
|
||
color: var(--el-color-primary);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
user-select: none;
|
||
transition: color 0.2s;
|
||
|
||
&:hover {
|
||
color: var(--el-color-primary-light-3);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.filter-bar {
|
||
display: flex;
|
||
gap: 6px;
|
||
overflow-x: auto;
|
||
margin-top: 16px;
|
||
margin-bottom: 8px;
|
||
padding: 10px 0 2px;
|
||
|
||
&::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.pill {
|
||
white-space: nowrap;
|
||
padding: 4px 10px;
|
||
background: var(--el-fill-color-light);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
|
||
&:hover {
|
||
background: var(--el-fill-color);
|
||
}
|
||
|
||
&.active {
|
||
background: var(--el-color-primary);
|
||
color: #fff;
|
||
border-color: var(--el-color-primary);
|
||
}
|
||
|
||
&.badge-danger {
|
||
background: #fee2e2;
|
||
color: #ef4444;
|
||
border-color: #fecaca;
|
||
}
|
||
|
||
&.badge-warn {
|
||
background: #fef3c7;
|
||
color: #d97706;
|
||
border-color: #fde68a;
|
||
}
|
||
|
||
&.badge-success {
|
||
background: #dcfce7;
|
||
color: #22c55e;
|
||
border-color: #bbf7d0;
|
||
}
|
||
}
|
||
|
||
.view-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
|
||
.view-switch {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
.toolbar-label {
|
||
font-size: 14px;
|
||
color: var(--el-text-color-regular);
|
||
}
|
||
}
|
||
.table-search {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
}
|
||
|
||
.content-card {
|
||
border-radius: 8px;
|
||
margin-top: 0;
|
||
|
||
:deep(.el-card__body) {
|
||
padding: 16px;
|
||
}
|
||
|
||
.filter-bar {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.detail-table-wrap {
|
||
:deep(.product-list-row-clickable) {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.product-info {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
|
||
.product-image {
|
||
width: 48px;
|
||
height: 56px;
|
||
background-color: var(--el-fill-color);
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--el-text-color-placeholder);
|
||
font-size: 20px;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
.product-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
|
||
.product-details {
|
||
flex: 1;
|
||
min-width: 0;
|
||
.product-code {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
.product-code-link {
|
||
color: var(--el-color-primary);
|
||
cursor: pointer;
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.font-bold {
|
||
font-weight: 700;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
.kcsl-display {
|
||
white-space: pre-line;
|
||
text-align: right;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.size-status {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-bottom: 4px;
|
||
flex-wrap: wrap;
|
||
.size-badge {
|
||
min-width: 28px;
|
||
width: 28px;
|
||
height: 20px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 2px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: white;
|
||
box-sizing: border-box;
|
||
flex-shrink: 0;
|
||
&.size-red {
|
||
background-color: var(--el-color-danger);
|
||
}
|
||
&.size-orange {
|
||
background-color: var(--el-color-warning);
|
||
}
|
||
&.size-yellow {
|
||
background-color: var(--el-color-warning);
|
||
}
|
||
&.size-gray {
|
||
background-color: var(--el-fill-color);
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.product-cards-container {
|
||
min-height: 400px;
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 400px;
|
||
}
|
||
|
||
.product-cards-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||
gap: 20px;
|
||
|
||
.product-card-item {
|
||
.kb22-card {
|
||
background: var(--el-bg-color);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||
overflow: hidden;
|
||
|
||
.kb22-header-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: var(--el-fill-color-lighter);
|
||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||
|
||
.kb22-code {
|
||
font-weight: 700;
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
.kb22-color-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--el-text-color-secondary);
|
||
|
||
.kb22-color-dot {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
}
|
||
}
|
||
|
||
.kb22-card-body {
|
||
padding: 16px;
|
||
|
||
.kb22-media-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
|
||
.kb22-thumb-box {
|
||
width: 80px;
|
||
height: 96px;
|
||
background: var(--el-fill-color);
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
|
||
.kb22-thumb-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.kb22-no-img {
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
}
|
||
|
||
.kb22-info-col {
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
.kb22-prod-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
margin-bottom: 8px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.kb22-tags-container {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
|
||
.kb22-tag-pill {
|
||
padding: 2px 8px;
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
|
||
&.kb22-tag-blue {
|
||
background: var(--el-color-primary-light-9);
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
&.kb22-tag-purple {
|
||
background: var(--el-color-primary-light-9);
|
||
color: var(--el-color-primary);
|
||
}
|
||
}
|
||
}
|
||
|
||
.kb22-price-section {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
|
||
.kb22-current-price {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
.kb22-cost-price {
|
||
font-size: 13px;
|
||
color: var(--el-text-color-secondary);
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.kb22-margin-text {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.kb22-stats-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
|
||
.kb22-stat-card {
|
||
padding: 12px;
|
||
background: var(--el-fill-color-light);
|
||
border-radius: 6px;
|
||
|
||
.kb22-stat-label {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.kb22-stat-value {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--el-text-color-primary);
|
||
margin-bottom: 8px;
|
||
|
||
.kb22-stat-sub {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
&.kcsl-display {
|
||
white-space: pre-line;
|
||
line-height: 1.4;
|
||
}
|
||
}
|
||
|
||
&.kb22-sales {
|
||
.kb22-sales-footer {
|
||
.kb22-mini-progress {
|
||
height: 4px;
|
||
background: rgba(0, 0, 0, 0.1);
|
||
border-radius: 2px;
|
||
margin-bottom: 4px;
|
||
overflow: hidden;
|
||
|
||
.kb22-mini-fill {
|
||
height: 100%;
|
||
background: var(--el-color-primary);
|
||
transition: width 0.3s;
|
||
}
|
||
}
|
||
|
||
.kb22-rate-text {
|
||
font-size: 11px;
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
}
|
||
}
|
||
|
||
&.kb22-stock {
|
||
.kb22-turnover-text {
|
||
font-size: 11px;
|
||
margin-top: 4px;
|
||
|
||
&.text-success {
|
||
color: var(--el-color-success);
|
||
}
|
||
|
||
&.text-danger {
|
||
color: var(--el-color-danger);
|
||
}
|
||
|
||
&.text-warning {
|
||
color: var(--el-color-warning);
|
||
}
|
||
|
||
&.text-info {
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.kb22-stock-footer {
|
||
.kb22-section-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
|
||
.kb22-alert-tip {
|
||
color: var(--el-color-danger);
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
.kb22-size-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
|
||
.kb22-size-cell {
|
||
position: relative;
|
||
padding: 8px;
|
||
background: white;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 4px;
|
||
text-align: center;
|
||
|
||
&.kb22-alert {
|
||
border-color: var(--el-color-danger);
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.kb22-alert-badge {
|
||
position: absolute;
|
||
top: -6px;
|
||
right: -6px;
|
||
width: 18px;
|
||
height: 18px;
|
||
background: var(--el-color-danger);
|
||
color: white;
|
||
border-radius: 50%;
|
||
font-size: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.kb22-sz-tag {
|
||
display: block;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.kb22-sz-data-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 10px;
|
||
color: var(--el-text-color-secondary);
|
||
|
||
.kb22-sz-stock {
|
||
color: #0f172a;
|
||
}
|
||
|
||
.kb22-sz-sales {
|
||
color: #3b82f6;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.kb22-legend {
|
||
display: flex;
|
||
gap: 16px;
|
||
font-size: 11px;
|
||
color: var(--el-text-color-placeholder);
|
||
|
||
.kb22-legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
|
||
.kb22-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|