Files
yudao-ui-admin-vue3/src/views/ydoyun/report/lijun/reportpage6/detail.vue

1738 lines
55 KiB
Vue
Raw Normal View History

2026-02-27 09:47:06 +08:00
<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>
<!-- 展示形式切换放在最上面 -->
<div class="view-toolbar">
<div class="view-switch">
<span class="toolbar-label">展示形式</span>
<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>
<!-- 内容卡片标签 + 卡片/列表内容标签放在卡片内不突兀 -->
<el-card class="content-card" shadow="never">
<!-- 标签分类筛选放在卡片内部 -->
<div v-if="labelOpts.length > 0" class="filter-bar">
<div
v-for="opt in labelOpts"
:key="opt.value"
class="pill"
:class="{ 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]
}
// 下拉选项(门店通过 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);
}
}
.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>