fix: 自定义标签

This commit is contained in:
2026-03-03 15:36:57 +08:00
parent f935832c9e
commit fe4e742551
14 changed files with 7596 additions and 773 deletions

View File

@@ -1,99 +0,0 @@
import { resolve } from 'path'
import Vue from '@vitejs/plugin-vue'
import VueJsx from '@vitejs/plugin-vue-jsx'
import progress from 'vite-plugin-progress'
import EslintPlugin from 'vite-plugin-eslint'
import PurgeIcons from 'vite-plugin-purge-icons'
import { ViteEjsPlugin } from 'vite-plugin-ejs'
// @ts-ignore
import ElementPlus from 'unplugin-element-plus/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import viteCompression from 'vite-plugin-compression'
import topLevelAwait from 'vite-plugin-top-level-await'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons-ng'
import UnoCSS from 'unocss/vite'
export function createVitePlugins() {
const root = process.cwd()
// 路径查找
function pathResolve(dir: string) {
return resolve(root, '.', dir)
}
return [
Vue(),
VueJsx(),
UnoCSS(),
progress(),
PurgeIcons(),
ElementPlus({}),
AutoImport({
include: [
/\.[tj]sx?$/, // .ts, .tsx, .js, .jsx
/\.vue$/,
/\.vue\?vue/, // .vue
/\.md$/ // .md
],
imports: [
'vue',
'vue-router',
// 可额外添加需要 autoImport 的组件
{
'@/hooks/web/useI18n': ['useI18n'],
'@/hooks/web/useMessage': ['useMessage'],
'@/hooks/web/useTable': ['useTable'],
'@/hooks/web/useCrudSchemas': ['useCrudSchemas'],
'@/utils/formRules': ['required'],
'@/utils/dict': ['DICT_TYPE']
}
],
dts: 'src/types/auto-imports.d.ts',
resolvers: [ElementPlusResolver()],
eslintrc: {
enabled: false, // Default `false`
filepath: './.eslintrc-auto-import.json', // Default `./.eslintrc-auto-import.json`
globalsPropValue: true // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable')
}
}),
Components({
// 生成自定义 `auto-components.d.ts` 全局声明
dts: 'src/types/auto-components.d.ts',
// 自定义组件的解析器
resolvers: [ElementPlusResolver()],
globs: ["src/components/**/**.{vue, md}", '!src/components/DiyEditor/components/mobile/**']
}),
EslintPlugin({
cache: false,
include: ['src/**/*.vue', 'src/**/*.ts', 'src/**/*.tsx'] // 检查的文件
}),
VueI18nPlugin({
runtimeOnly: true,
compositionOnly: true,
include: [resolve(__dirname, 'src/locales/**')]
}),
createSvgIconsPlugin({
iconDirs: [pathResolve('src/assets/svgs')],
symbolId: 'icon-[dir]-[name]',
}),
viteCompression({
verbose: true, // 是否在控制台输出压缩结果
disable: false, // 是否禁用
threshold: 10240, // 体积大于 threshold 才会被压缩,单位 b
algorithm: 'gzip', // 压缩算法,可选 [ 'gzip' , 'brotliCompress' ,'deflate' , 'deflateRaw']
ext: '.gz', // 生成的压缩包后缀
deleteOriginFile: false //压缩后是否删除源文件
}),
ViteEjsPlugin(),
topLevelAwait({
// https://juejin.cn/post/7152191742513512485
// The export name of top-level await promise for each chunk module
promiseExportName: '__tla',
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: (i) => `__tla_${i}`
})
]
}

View File

@@ -1,124 +0,0 @@
const include = [
'qs',
'url',
'vue',
'sass',
'mitt',
'axios',
'pinia',
'dayjs',
'qrcode',
'unocss',
'vue-router',
'vue-types',
'vue-i18n',
'crypto-js',
'cropperjs',
'lodash-es',
'nprogress',
'web-storage-cache',
'@iconify/iconify',
'@vueuse/core',
'@zxcvbn-ts/core',
'echarts/core',
'echarts/charts',
'echarts/components',
'echarts/renderers',
'echarts-wordcloud',
'@wangeditor-next/editor',
'@wangeditor-next/editor-for-vue',
'@microsoft/fetch-event-source',
'markdown-it',
'markmap-view',
'markmap-lib',
'markmap-toolbar',
'highlight.js',
'element-plus',
'element-plus/es',
'element-plus/es/locale/lang/zh-cn',
'element-plus/es/locale/lang/en',
'element-plus/es/components/avatar/style/css',
'element-plus/es/components/space/style/css',
'element-plus/es/components/backtop/style/css',
'element-plus/es/components/form/style/css',
'element-plus/es/components/radio-group/style/css',
'element-plus/es/components/radio/style/css',
'element-plus/es/components/checkbox/style/css',
'element-plus/es/components/checkbox-group/style/css',
'element-plus/es/components/switch/style/css',
'element-plus/es/components/time-picker/style/css',
'element-plus/es/components/date-picker/style/css',
'element-plus/es/components/descriptions/style/css',
'element-plus/es/components/descriptions-item/style/css',
'element-plus/es/components/link/style/css',
'element-plus/es/components/tooltip/style/css',
'element-plus/es/components/drawer/style/css',
'element-plus/es/components/dialog/style/css',
'element-plus/es/components/checkbox-button/style/css',
'element-plus/es/components/option-group/style/css',
'element-plus/es/components/radio-button/style/css',
'element-plus/es/components/cascader/style/css',
'element-plus/es/components/color-picker/style/css',
'element-plus/es/components/input-number/style/css',
'element-plus/es/components/rate/style/css',
'element-plus/es/components/select-v2/style/css',
'element-plus/es/components/tree-select/style/css',
'element-plus/es/components/slider/style/css',
'element-plus/es/components/time-select/style/css',
'element-plus/es/components/autocomplete/style/css',
'element-plus/es/components/image-viewer/style/css',
'element-plus/es/components/upload/style/css',
'element-plus/es/components/col/style/css',
'element-plus/es/components/form-item/style/css',
'element-plus/es/components/alert/style/css',
'element-plus/es/components/breadcrumb/style/css',
'element-plus/es/components/select/style/css',
'element-plus/es/components/input/style/css',
'element-plus/es/components/breadcrumb-item/style/css',
'element-plus/es/components/tag/style/css',
'element-plus/es/components/pagination/style/css',
'element-plus/es/components/table/style/css',
'element-plus/es/components/table-v2/style/css',
'element-plus/es/components/table-column/style/css',
'element-plus/es/components/card/style/css',
'element-plus/es/components/row/style/css',
'element-plus/es/components/button/style/css',
'element-plus/es/components/menu/style/css',
'element-plus/es/components/sub-menu/style/css',
'element-plus/es/components/menu-item/style/css',
'element-plus/es/components/option/style/css',
'element-plus/es/components/dropdown/style/css',
'element-plus/es/components/dropdown-menu/style/css',
'element-plus/es/components/dropdown-item/style/css',
'element-plus/es/components/skeleton/style/css',
'element-plus/es/components/skeleton/style/css',
'element-plus/es/components/backtop/style/css',
'element-plus/es/components/menu/style/css',
'element-plus/es/components/sub-menu/style/css',
'element-plus/es/components/menu-item/style/css',
'element-plus/es/components/dropdown/style/css',
'element-plus/es/components/tree/style/css',
'element-plus/es/components/dropdown-menu/style/css',
'element-plus/es/components/dropdown-item/style/css',
'element-plus/es/components/badge/style/css',
'element-plus/es/components/breadcrumb/style/css',
'element-plus/es/components/breadcrumb-item/style/css',
'element-plus/es/components/image/style/css',
'element-plus/es/components/collapse-transition/style/css',
'element-plus/es/components/timeline/style/css',
'element-plus/es/components/timeline-item/style/css',
'element-plus/es/components/collapse/style/css',
'element-plus/es/components/collapse-item/style/css',
'element-plus/es/components/button-group/style/css',
'element-plus/es/components/text/style/css',
'element-plus/es/components/segmented/style/css',
'@element-plus/icons-vue',
'element-plus/es/components/footer/style/css',
'element-plus/es/components/empty/style/css',
'element-plus/es/components/mention/style/css',
'element-plus/es/components/progress/style/css'
]
const exclude = ['@iconify/json']
export { include, exclude }

3531
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -137,6 +137,7 @@
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-progress": "^0.0.7",
"vite-plugin-purge-icons": "^0.10.0",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-svg-icons-ng": "^1.3.1",
"vite-plugin-top-level-await": "^1.4.4",
"vue-eslint-parser": "^9.3.2",

1951
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
import request from '@/config/axios'
import type { Dayjs } from 'dayjs';
/** 自定义标签类型product-产品, store-店铺, supplier-供货商 */
export type CustomTagType = 'product' | 'store' | 'supplier'
/** 自定义标签信息 */
export interface CustomTag {
id?: number
type?: CustomTagType // 类型,用于区分产品/店铺/供货商
name?: string
expression?: string
color?: string
sqlScript?: string
useParams?: boolean | number
params?: string
}
// 自定义标签 API
export const CustomTagApi = {
// 查询自定义标签分页
getCustomTagPage: async (params: any) => {
return await request.get({ url: `/ydoyun/custom-tag/page`, params })
},
// 查询自定义标签详情
getCustomTag: async (id: number) => {
return await request.get({ url: `/ydoyun/custom-tag/get?id=` + id })
},
// 新增自定义标签
createCustomTag: async (data: CustomTag) => {
return await request.post({ url: `/ydoyun/custom-tag/create`, data })
},
// 修改自定义标签
updateCustomTag: async (data: CustomTag) => {
return await request.put({ url: `/ydoyun/custom-tag/update`, data })
},
// 删除自定义标签
deleteCustomTag: async (id: number) => {
return await request.delete({ url: `/ydoyun/custom-tag/delete?id=` + id })
},
/** 批量删除自定义标签 */
deleteCustomTagList: async (ids: number[]) => {
return await request.delete({ url: `/ydoyun/custom-tag/delete-list?ids=${ids.join(',')}` })
},
// 导出自定义标签 Excel
exportCustomTag: async (params) => {
return await request.download({ url: `/ydoyun/custom-tag/export-excel`, params })
}
}

View File

@@ -822,6 +822,46 @@ const remainingRouter: AppRouteRecordRaw[] = [
component: () => import('@/views/ydoyun/report/lijun/reportpage6/detail.vue')
}
]
},
{
path: '/ydoyuntag',
component: Layout,
name: 'YdoyunTag',
meta: {
hidden: true
},
children: [
{
path: 'product-custom-tag',
name: 'ProductCustomTag',
meta: {
title: '产品标签',
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "product-custom-tag" */ '@/views/ydoyun/productcustomtag/index.vue')
},
{
path: 'store-custom-tag',
name: 'StoreCustomTag',
meta: {
title: '店铺标签',
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "store-custom-tag" */ '@/views/ydoyun/storecustomtag/index.vue')
},
{
path: 'supplier-custom-tag',
name: 'SupplierCustomTag',
meta: {
title: '供货商标签',
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "supplier-custom-tag" */ '@/views/ydoyun/suppliercustomtag/index.vue')
}
]
}
]

View File

@@ -0,0 +1,203 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="560px">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="110px"
v-loading="formLoading"
>
<el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称,如:销售区域" maxlength="50" show-word-limit />
<div class="form-tip">用于报表中展示的标签名称</div>
</el-form-item>
<el-form-item label="公式描述" prop="expression">
<el-input
v-model="formData.expression"
type="textarea"
:rows="2"
placeholder="请输入公式描述,如:${region_name}"
/>
<div class="form-tip">报表中引用的表达式支持 ${字段名} 格式</div>
</el-form-item>
<el-form-item label="标签颜色" prop="color">
<div class="color-field">
<div class="color-presets">
<span
v-for="c in presetColors"
:key="c"
class="color-chip"
:class="{ active: formData.color === c }"
:style="{ backgroundColor: c }"
@click="formData.color = c"
></span>
</div>
<div class="color-picker-wrap">
<el-color-picker v-model="formData.color" :predefine="presetColors" />
<el-input
v-model="formData.color"
placeholder="或输入颜色值"
class="color-input"
maxlength="20"
/>
</div>
</div>
<div class="form-tip">用于报表展示时的颜色标识可点击预设色或使用取色盘</div>
</el-form-item>
<el-form-item label="SQL 脚本" prop="sqlScript">
<el-input
v-model="formData.sqlScript"
type="textarea"
:rows="4"
placeholder="请输入 SQL 脚本,用于获取标签数据"
/>
<div class="form-tip">执行 SQL 获取标签选项支持多列第一列作为显示值</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag'
defineOptions({ name: 'CustomTagForm' })
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const formData = ref({
id: undefined,
name: undefined,
expression: undefined,
color: undefined,
sqlScript: undefined
})
const formRules = reactive({
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }]
})
const formRef = ref()
/** 预设颜色选项 */
const presetColors = [
'#1890ff',
'#52c41a',
'#faad14',
'#f5222d',
'#722ed1',
'#eb2f96',
'#13c2c2',
'#fa8c16'
]
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
if (id) {
formLoading.value = true
try {
const data = await CustomTagApi.getCustomTag(id)
formData.value = { ...data }
} finally {
formLoading.value = false
}
}
}
defineExpose({ open })
const emit = defineEmits(['success'])
const submitForm = async () => {
await formRef.value.validate()
formLoading.value = true
try {
const data = formData.value as unknown as CustomTag
if (formType.value === 'create') {
await CustomTagApi.createCustomTag(data)
message.success(t('common.createSuccess'))
} else {
await CustomTagApi.updateCustomTag(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
expression: undefined,
color: undefined,
sqlScript: undefined
}
formRef.value?.resetFields()
}
</script>
<style scoped>
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
margin-top: 4px;
}
.color-field {
width: 100%;
}
.color-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.color-chip {
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.color-chip:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.color-chip.active {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
}
.color-picker-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.color-picker-wrap .color-input {
flex: 1;
min-width: 120px;
}
</style>

View File

@@ -0,0 +1,232 @@
<template>
<ContentWrap>
<!-- 搜索 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="90px"
>
<el-form-item label="标签名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入标签名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="公式描述" prop="expression">
<el-input
v-model="queryParams.expression"
placeholder="请输入公式描述"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-220px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['ydoyun:custom-tag:create']"
>
<Icon icon="ep:plus" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['ydoyun:custom-tag:export']"
>
<Icon icon="ep:download" /> 导出
</el-button>
<el-button
type="danger"
plain
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-hasPermi="['ydoyun:custom-tag:delete']"
>
<Icon icon="ep:delete" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">
{{ scope.row.color }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180"
/>
<el-table-column label="操作" align="center" width="140" fixed="right">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['ydoyun:custom-tag:update']"
>
<Icon icon="ep:edit" /> 编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['ydoyun:custom-tag:delete']"
>
<Icon icon="ep:delete" /> 删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<CustomTagForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag'
import CustomTagForm from './CustomTagForm.vue'
defineOptions({ name: 'CustomTag' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const list = ref<CustomTag[]>([])
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
expression: undefined,
createTime: [] as string[]
})
const queryFormRef = ref()
const exportLoading = ref(false)
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await CustomTagApi.getCustomTagPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置 */
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
}
/** 添加/修改 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除 */
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await CustomTagApi.deleteCustomTag(id)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
/** 批量删除 */
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: CustomTag[]) => {
checkedIds.value = records.map((item) => item.id!).filter(Boolean)
}
const handleDeleteBatch = async () => {
try {
await message.delConfirm()
await CustomTagApi.deleteCustomTagList(checkedIds.value)
checkedIds.value = []
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
/** 导出 */
const handleExport = async () => {
try {
await message.exportConfirm()
exportLoading.value = true
const data = await CustomTagApi.exportCustomTag(queryParams)
download.excel(data, '自定义标签.xls')
} catch {
} finally {
exportLoading.value = false
}
}
onMounted(() => {
getList()
})
</script>

View File

@@ -2,12 +2,6 @@
<div class="product-cards-page">
<!-- 查询条件区域与主页/详情页一致首行时间+快捷+查询/重置/更多条件其余折叠 -->
<el-card class="query-card" shadow="never">
<div class="query-header">
<h1 class="page-title">查看更多产品卡片</h1>
<span v-if="currentProductCode" class="current-product-tip">
当前查看商品<strong>{{ currentProductName || currentProductCode }}</strong>
</span>
</div>
<div class="query-form">
<div class="query-item">
<span class="query-label">时间区间</span>
@@ -193,44 +187,42 @@
</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="searchKeyword"
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 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="searchKeyword"
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"
:class="{ active: labelFilter === opt.value }"
:class="['pill', productTagClass(opt.label), { active: labelFilter === opt.value }]"
@click="labelFilter = opt.value"
>
{{ opt.label }}
@@ -261,39 +253,83 @@
<!-- 媒体区缩略图 + 名称/标签/价格 -->
<div class="kb22-media-row">
<div class="kb22-thumb-box">
<img v-if="item.imageUrl" :src="item.imageUrl" :alt="item.name" class="kb22-thumb-img" />
<img
v-if="item.imageUrl || (item as any).pic"
:src="item.imageUrl || (item as any).pic"
:alt="item.name"
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>
<span
v-if="item.type"
class="kb22-tag-pill"
:class="productTagClass(item.type)"
>
{{ item.type }}
</span>
<span
v-if="item.lifecycle"
class="kb22-tag-pill"
:class="productTagClass(item.lifecycle)"
>
{{ item.lifecycle }}
</span>
</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>
<span class="kb22-current-price">
<template v-if="item.sellingPrice != null">¥{{ formatNumber(item.sellingPrice) }}</template>
<template v-else>-</template>
</span>
<span class="kb22-cost-price">
<template v-if="item.purchasePrice != null">¥{{ formatNumber(item.purchasePrice) }}</template>
<template v-else>-</template>
</span>
<span class="kb22-margin-text">
<template v-if="item.grossMargin != null">
毛利 {{ formatGrossMarginPct(item.grossMargin, item.purchasePrice != null && item.sellingPrice != null ? (1 - item.purchasePrice / item.sellingPrice) * 100 : undefined) }}%
</template>
<template v-else>-</template>
</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 class="kb22-stat-card kb22-sales">
<div class="kb22-stat-label">总销量</div>
<div class="kb22-stat-value">
<template v-if="item.salesCount != null">
{{ item.salesCount }}<span class="kb22-stat-sub"></span>
</template>
<template v-else>-</template>
</div>
<div class="kb22-sales-footer">
<div class="kb22-mini-progress" v-if="item.selloutRate != null">
<div class="kb22-mini-fill" :style="{ width: Math.min(100, item.selloutRate) + '%' }"></div>
</div>
<div class="kb22-rate-text">
<template v-if="item.selloutRate != null">售罄率 {{ item.selloutRate }}%</template>
<template v-else>-</template>
</div>
</div>
</div>
<div class="kb22-stat-card kb22-stock">
<div class="kb22-stat-label">当前库存</div>
<div class="kb22-stat-value">
<template v-if="item.inventoryCount != null">
{{ item.inventoryCount }}<span class="kb22-stat-sub"></span>
</template>
<template v-else>-</template>
</div>
<div class="kb22-turnover-text" :class="uiTextClass(item.turnoverStatus)">
<template v-if="item.turnoverText">{{ '周转: ' + item.turnoverText }}</template>
<template v-else>-</template>
</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">{{ item.inventoryCount ?? 0 }}<span class="kb22-stat-sub"></span></div>
<div class="kb22-turnover-text" :class="uiTextClass(item.turnoverStatus)">周转: {{ item.turnoverText ?? '-' }}</div>
</div>
</div>
<!-- SKU 明细尺码 + 库存 + 销量 -->
<div v-if="item.sizes && item.sizes.length" class="kb22-stock-footer">
@@ -329,36 +365,88 @@
<!-- 列表形式 ProductDashboard 商品明细列表一致的表格放在同一张内容卡片内 -->
<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="handleProductRowClick"
>
<el-table-column prop="productInfo" label="商品信息" width="280">
<template #default="{ row }">
<div class="product-info">
:data="filteredTableList"
v-loading="loading"
border
stripe
style="width: 100%"
row-class-name="product-list-row-clickable"
@row-click="handleProductRowClick"
>
<el-table-column prop="imageUrl" label="商品图片" width="110" align="center">
<template #default="{ row }">
<div class="product-image">
<img v-if="row.imageUrl" :src="row.imageUrl" alt="商品图片" class="product-img" />
<img
v-if="row.imageUrl || row.pic"
:src="row.imageUrl || row.pic"
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>
</template>
</el-table-column>
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.name || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="code" label="款号" width="140">
<template #default="{ row }">
<span class="product-code-link" @click.stop="copyCellText(row.code)">
{{ row.code || '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="barcode" label="条码" width="160" show-overflow-tooltip>
<template #default="{ row }">
<span class="product-code-link" @click.stop="copyCellText(row.barcode)">
{{ row.barcode || '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="color" label="颜色" width="100">
<template #default="{ row }">
<span>{{ row.color || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="purchasePrice" label="进价" align="right" width="110">
<template #default="{ row }">
<span v-if="row.purchasePrice != null">¥{{ formatNumber(row.purchasePrice) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="sellingPrice" label="售价" align="right" width="110">
<template #default="{ row }">
<span v-if="row.sellingPrice != null">¥{{ formatNumber(row.sellingPrice) }}</span>
<span v-else>-</span>
</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="tags" label="标签" width="200" show-overflow-tooltip>
<template #default="{ row }">
<div class="product-tags">
<span
v-if="row.type"
class="kb22-tag-pill"
:class="productTagClass(row.type)"
>
{{ row.type }}
</span>
<span
v-if="row.lifecycle"
class="kb22-tag-pill"
:class="productTagClass(row.lifecycle)"
>
{{ row.lifecycle }}
</span>
</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>
@@ -438,6 +526,7 @@ import dayjs from 'dayjs'
import { Picture, Grid, List, Search } from '@element-plus/icons-vue'
import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
const REPORT_ID = 6
@@ -485,11 +574,20 @@ interface ProductCardItem {
grossMargin?: number
}
function productTagClass(tag: string): string {
if (!tag) return ''
if (tag.includes('慢') || tag.includes('低') || tag.includes('预警')) return 'kb22-tag-warn'
if (tag.includes('滞后') || tag.includes('高')) return 'kb22-tag-danger'
return 'kb22-tag-success'
}
defineOptions({ name: 'ProductCardsPage' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const userStore = useUserStore()
const username = computed(() => userStore.user?.username || '')
/** 展示形式:卡片 | 列表(与 ProductDashboard 商品明细表格一致) */
const displayMode = ref<'card' | 'table'>('card')
@@ -589,233 +687,7 @@ function hasOutOfStockSize(sizes: ProductSizeStatus[]): boolean {
return Array.isArray(sizes) && sizes.some((s) => s.status === 'out' || s.stock === 0)
}
// Mock与列表页一致的全量数据7 条)
const productList = ref<ProductCardItem[]>([
{
name: 'GZ6596Z 法式收腰连衣裙',
code: '54000008',
color: '黑色',
season: '25春 一波段',
category: '女装 / 连衣裙',
daysOnMarket: 35,
salesAmount: 85200,
salesCount: 320,
inventoryCount: 120,
turnoverText: '周转: 15天 (极快)',
turnoverStatus: 'success',
selloutRate: 78,
selloutRateStatus: 'danger',
discount: '9.5折',
discountStatus: 'info',
sizes: [
{ label: 'S', status: 'out', title: 'S码 缺货', stock: 0 },
{ label: 'M', status: 'out', title: 'M码 缺货', stock: 0 },
{ label: 'L', status: 'low', title: 'L码 紧张', stock: 8 },
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 45 }
],
sizeStatusText: '缺核心码',
sizeStatusStatus: 'danger',
lifecycle: '爆发成长期',
lifecycleType: 'success',
barcode: '6901234567890',
purchasePrice: 168,
sellingPrice: 399,
imageUrl: 'https://img.alicdn.com/imgextra/i1/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
supplierName: '杭州女装供应链有限公司'
},
{
name: '7537X 双面呢大衣',
code: '54000321',
color: '驼色',
season: '24冬 三波段',
category: '女装 / 外套',
daysOnMarket: 120,
salesAmount: 22000,
salesCount: 18,
inventoryCount: 850,
turnoverText: '周转: 280天 (滞销)',
turnoverStatus: 'info',
selloutRate: 15,
selloutRateStatus: 'warning',
discount: '6.0折',
discountStatus: 'danger',
sizes: [
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 210 },
{ label: 'M', status: 'ok', title: 'M码 充足', stock: 220 },
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 215 },
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 205 }
],
sizeStatusText: '库存齐色齐码',
sizeStatusStatus: 'info',
lifecycle: '严重滞销',
lifecycleType: 'danger',
barcode: '6901234567891',
purchasePrice: 580,
sellingPrice: 1299,
imageUrl: 'https://img.alicdn.com/imgextra/i2/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
supplierName: '上海大衣制造厂'
},
{
name: 'C01J 圆领针织衫',
code: '54000159',
color: '米白',
season: '25春 二波段',
category: '女装 / 毛衫',
daysOnMarket: 25,
salesAmount: 45000,
salesCount: 120,
inventoryCount: 450,
turnoverText: '周转: 45天 (健康)',
turnoverStatus: 'success',
selloutRate: 35,
selloutRateStatus: 'info',
discount: '9.0折',
discountStatus: 'info',
sizes: [
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 120 },
{ label: 'M', status: 'warn', title: 'M码 紧张', stock: 25 },
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 150 },
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 155 }
],
sizeStatusText: '',
sizeStatusStatus: 'info',
lifecycle: '正常销售',
lifecycleType: 'info',
barcode: '6901234567892',
purchasePrice: 128,
sellingPrice: 299,
imageUrl: 'https://img.alicdn.com/imgextra/i3/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
supplierName: '广州针织品有限公司'
},
{
name: 'LK2025 春季休闲裤',
code: '54000245',
color: '卡其色',
season: '25春 一波段',
category: '女装 / 休闲裤',
daysOnMarket: 28,
salesAmount: 125000,
salesCount: 450,
inventoryCount: 280,
turnoverText: '周转: 38天 (健康)',
turnoverStatus: 'success',
selloutRate: 62,
selloutRateStatus: 'success',
discount: '8.5折',
discountStatus: 'info',
sizes: [
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 45 },
{ label: 'M', status: 'warn', title: 'M码 紧张', stock: 8 },
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 52 },
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 38 }
],
sizeStatusText: 'M码需补货',
sizeStatusStatus: 'warning',
lifecycle: '稳定销售期',
lifecycleType: 'info',
barcode: '6901234567893',
purchasePrice: 158,
sellingPrice: 369,
imageUrl: 'https://img.alicdn.com/imgextra/i4/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
supplierName: '深圳休闲服饰有限公司'
},
{
name: 'FW2024 冬季羽绒服',
code: '54000188',
color: '黑色',
season: '24冬 二波段',
category: '女装 / 外套',
daysOnMarket: 95,
salesAmount: 185000,
salesCount: 95,
inventoryCount: 520,
turnoverText: '周转: 210天 (滞销)',
turnoverStatus: 'danger',
selloutRate: 18,
selloutRateStatus: 'danger',
discount: '5.5折',
discountStatus: 'danger',
sizes: [
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 120 },
{ label: 'M', status: 'ok', title: 'M码 充足', stock: 135 },
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 145 },
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 120 }
],
sizeStatusText: '库存积压严重',
sizeStatusStatus: 'danger',
lifecycle: '衰退期',
lifecycleType: 'danger',
barcode: '6901234567894',
purchasePrice: 680,
sellingPrice: 1599,
imageUrl: 'https://img.alicdn.com/imgextra/i1/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
supplierName: '江苏羽绒制品厂'
},
{
name: 'TS2025 春季新品T恤',
code: '54000356',
color: '白色',
season: '25春 一波段',
category: '女装 / T恤',
daysOnMarket: 15,
salesAmount: 98000,
salesCount: 680,
inventoryCount: 320,
turnoverText: '周转: 12天 (极快)',
turnoverStatus: 'success',
selloutRate: 68,
selloutRateStatus: 'success',
discount: '9.8折',
discountStatus: 'info',
sizes: [
{ label: 'S', status: 'out', title: 'S码 缺货', stock: 0 },
{ label: 'M', status: 'out', title: 'M码 缺货', stock: 2 },
{ label: 'L', status: 'low', title: 'L码 紧张', stock: 15 },
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 48 }
],
sizeStatusText: '核心码缺货,需紧急补货',
sizeStatusStatus: 'danger',
lifecycle: '爆发成长期',
lifecycleType: 'success',
barcode: '6901234567895',
purchasePrice: 68,
sellingPrice: 159,
imageUrl: 'https://img.alicdn.com/imgextra/i2/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
supplierName: '浙江T恤代工厂'
},
{
name: 'CS2025 春季衬衫',
code: '54000378',
color: '蓝色',
season: '25春 二波段',
category: '女装 / 衬衫',
daysOnMarket: 20,
salesAmount: 156000,
salesCount: 520,
inventoryCount: 480,
turnoverText: '周转: 25天 (快速)',
turnoverStatus: 'success',
selloutRate: 52,
selloutRateStatus: 'success',
discount: '9.2折',
discountStatus: 'info',
sizes: [
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 95 },
{ label: 'M', status: 'warn', title: 'M码 紧张', stock: 12 },
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 88 },
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 76 }
],
sizeStatusText: 'M码需补货',
sizeStatusStatus: 'warning',
lifecycle: '成长期',
lifecycleType: 'success',
barcode: '6901234567896',
purchasePrice: 138,
sellingPrice: 329,
imageUrl: 'https://img.alicdn.com/imgextra/i3/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
supplierName: '杭州衬衫供应链'
}
])
const productList = ref<ProductCardItem[]>([])
/** 表格行:在 ProductCardItem 基础上补充列表列所需字段(与 index 商品明细一致) */
type TableRow = ProductCardItem & {
@@ -853,16 +725,16 @@ const visibleProductList = computed(() => {
return productList.value.filter((item) => productMatchesLabelFilter(item, filter))
})
/** 将 visibleProductList 转为表格行(缺的 Raw 用派生值或 '-' */
/** 将 visibleProductList 转为表格行Raw 字段直接用于展示,缺失时用 '-' */
const tableList = computed<TableRow[]>(() =>
visibleProductList.value.map((item) => ({
...item,
lsRaw: item.salesCount != null || item.salesAmount != null ? `${item.salesCount ?? 0}件/${item.salesAmount ?? 0}` : '-',
j7slRaw: '-',
kcslRaw: item.inventoryCount != null ? `${item.inventoryCount}` : '-',
zksqlRaw: item.discount != null && item.selloutRate != null ? `${item.discount},${item.selloutRate}%` : '-',
shdxdRaw: '-',
wsdpRaw: '-',
lsRaw: item.salesAmount != null || item.salesCount != null ? `${item.salesCount ?? ''}${item.salesCount != null ? '件/' : ''}${item.salesAmount ?? ''}${item.salesAmount != null ? '元' : ''}` : undefined,
j7slRaw: undefined,
kcslRaw: item.inventoryCount != null ? `${item.inventoryCount}` : undefined,
zksqlRaw: item.discount != null || item.selloutRate != null ? `${item.discount ?? ''}${item.discount && item.selloutRate != null ? ',' : ''}${item.selloutRate != null ? item.selloutRate + '%' : ''}` : undefined,
shdxdRaw: undefined,
wsdpRaw: undefined,
actionText: '分析',
actionType: 'default' as const
}))
@@ -880,18 +752,47 @@ function handleProductRowClick(row: TableRow) {
}
function handleProductCodeClick(row: TableRow) {
router.push({
path: '/reports/lijun/reportpage6/detail',
query: { spdm: row.code || '' }
}).catch(() => {
ElMessage.warning('路由未配置或详情页不存在')
})
const spdm = row.code || ''
router
.push({
path: '/reports/lijun/reportpage6/detail',
query: {
// 关键商品编码
spdm,
// 透传当前大盘的所有查询条件,便于明细页还原筛选环境
rq: queryParams.rq,
rq2: queryParams.rq2,
ckdm: queryParams.ckdm.join(','),
pp: queryParams.pp.join(','),
season: queryParams.season.join(','),
zgj: queryParams.zgj.join(','),
category: queryParams.category.join(','),
line: queryParams.line.join(',')
}
})
.catch(() => {
ElMessage.warning('路由未配置或详情页不存在')
})
}
function handleTableAction(row: TableRow) {
ElMessage.info(`执行操作: ${row.actionText}`)
}
async function copyCellText(val?: string) {
const text = (val ?? '').toString().trim()
if (!text) {
ElMessage.warning('无可复制内容')
return
}
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动选择复制')
}
}
function arrToArray(val: unknown): string[] {
if (Array.isArray(val)) return val.filter((v) => typeof v === 'string')
if (val === undefined || val === null) return []
@@ -913,6 +814,46 @@ function applyQueryFromRoute() {
currentProductCode.value = (q.productCode && typeof q.productCode === 'string') ? q.productCode : ''
currentProductName.value = (q.productName && typeof q.productName === 'string') ? q.productName : ''
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 = ''
}
}
function disabledDate(time: Date) {
@@ -975,12 +916,158 @@ function handleReset() {
handleQuery()
}
function handleQuery() {
function arrToQuery(arr: string[]): string {
return Array.isArray(arr) && arr.length > 0 ? arr.join(',') : ''
}
async function handleQuery() {
loading.value = true
setTimeout(() => {
loading.value = false
try {
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 res: any = await ReportApi.executeProcedureWithData({
...baseParams,
name: 'YDY_AI_GET_SPXQ'
} as any)
// 兼容多种返回结构:数组 / { data: [] } / { code, data: [] }
let data: any[] | null = null
if (Array.isArray(res)) {
data = res
} else if (res && Array.isArray(res.data)) {
data = res.data
} else if (res && res.code != null && Array.isArray(res.data)) {
data = res.data
}
if (data && data.length > 0) {
productList.value = data.map((row: any) => {
// 映射存储过程字段 → 页面字段
// jj: 季节代码,例如 "20"
// jijie: 季节名称,例如 "春"
const seasonName = row.jijie ?? ''
const seasonCode = row.jj ?? ''
const season = seasonName || seasonCode ? `${seasonName || ''}${seasonCode ? `(${seasonCode})` : ''}` : '-'
// kcsl: 库存数量(字符串),直接转数值
const inventoryCount = Number(row.kcsl ?? 0) || 0
// lssl: 零售数量,如 "2726件/" → 2726
const salesCount = (() => {
const raw = String(row.lssl ?? '').split('件')[0].trim()
const n = Number(raw)
return isNaN(n) ? 0 : n
})()
// lsje: 零售金额,如 "79051元" → 79051
const salesAmount = (() => {
const raw = String(row.lsje ?? '').replace('元', '').trim()
const n = Number(raw)
return isNaN(n) ? 0 : n
})()
// zksql: 售罄率,如 "60.96%" → 60.96
const selloutRate = (() => {
const raw = String(row.zksql ?? '').replace('%', '').trim()
const n = Number(raw)
return isNaN(n) ? 0 : n
})()
// pjz: 平均折,例如 "1.00" → "1.00折"
const discount = row.pjz != null ? `${row.pjz}` : row.discount ?? '-'
// 进售价sj=售价jj=进价
const purchasePrice = Number(row.jj ?? row.purchasePrice ?? 0) || 0
const sellingPrice = Number(row.sj ?? row.sellingPrice ?? 0) || 0
const grossMargin =
row.grossMargin != null
? Number(row.grossMargin)
: sellingPrice > 0
? (sellingPrice - purchasePrice) / sellingPrice
: 0
return {
// 商品名称spmc
name: row.spmc ?? row.name ?? row.spdm ?? '-',
// 款号spdm
code: String(row.spdm ?? row.code ?? ''),
// 颜色ysmc
color: row.ysmc ?? row.color ?? '-',
// 季节jijie + jj
season,
// 类目:大类/中类/小类 dl / zl / xl
category: row.dl || row.zl || row.xl || '-',
// 上市天数:根据上市日期 ssrq 粗略计算
daysOnMarket: row.ssrq ? dayjs().diff(dayjs(row.ssrq), 'day') : Number(row.daysOnMarket ?? 0),
salesAmount,
salesCount,
inventoryCount,
turnoverText: row.turnoverText ?? '',
turnoverStatus: (row.turnoverStatus as UiStatus) ?? 'info',
// 售罄率zksql
selloutRate,
selloutRateStatus: (row.selloutRateStatus as UiStatus) ?? 'info',
// 平均折pjz
discount,
discountStatus: (row.discountStatus as UiStatus) ?? 'info',
// 尺码明细cmjs "M,488,red;L,718,red;XL,540,yellow"
sizes: Array.isArray(row.sizes)
? (row.sizes as ProductSizeStatus[])
: String(row.cmjs ?? '')
.split(';')
.map((seg: string) => seg.trim())
.filter(Boolean)
.map((seg: string) => {
const [label, stockStr, color] = seg.split(',').map((s) => s.trim())
const stock = Number(stockStr ?? 0) || 0
let status: ProductSizeStatus['status'] = 'ok'
if (color === 'red') status = 'low'
else if (color === 'yellow') status = 'warn'
return {
label: label || '',
status,
title: `${label || ''}`,
stock,
sales: undefined
} as ProductSizeStatus
}),
sizeStatusText: row.sizeStatusText ?? '',
sizeStatusStatus: (row.sizeStatusStatus as UiStatus) ?? 'info',
// 类型/生命周期标签:优先 type其次 cz
lifecycle: row.lifecycle ?? '',
lifecycleType: (row.lifecycleType as any) ?? 'info',
imageUrl: row.imageUrl ?? row.pic ?? undefined,
type: row.type ?? row.cz ?? row.lx ?? undefined,
supplierName: row.supplierName ?? row.ghs ?? undefined,
productId: row.productId ?? undefined,
barcode: row.barcode ?? row.sptm ?? undefined,
purchasePrice,
sellingPrice,
grossMargin
}
})
} else {
productList.value = []
}
ElMessage.success('查询成功')
}, 300)
} catch (e) {
console.error(e)
ElMessage.error('查询失败,请稍后重试')
productList.value = []
} finally {
loading.value = false
}
}
async function fetchBrandOptions() {
@@ -1045,6 +1132,8 @@ onMounted(async () => {
if (queryParams.ckdm.length === 0 && storeOptions.value.length > 0) {
queryParams.ckdm = storeOptions.value.map((o) => o.value)
}
// 进入页面后直接按当前查询条件拉取商品数据
await handleQuery()
})
watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
@@ -1052,7 +1141,6 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
<style lang="scss" scoped>
.product-cards-page {
padding: 16px;
background: var(--el-bg-color-page);
min-height: 100vh;
}
@@ -1175,18 +1263,17 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
margin-bottom: 16px;
.pill {
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
border: 1px solid var(--el-border-color);
background: var(--el-bg-color);
color: var(--el-text-color-regular);
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
background: var(--el-fill-color);
}
&.active {
@@ -1194,6 +1281,24 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
color: #fff;
border-color: var(--el-color-primary);
}
&.kb22-tag-danger {
background: #fee2e2;
color: #ef4444;
border-color: #fecaca;
}
&.kb22-tag-warn {
background: #fef3c7;
color: #d97706;
border-color: #fde68a;
}
&.kb22-tag-success {
background: #dcfce7;
color: #16a34a;
border-color: #bbf7d0;
}
}
}
@@ -1213,14 +1318,9 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
cursor: pointer;
}
.product-info {
display: flex;
gap: 12px;
align-items: flex-start;
.product-image {
width: 48px;
height: 56px;
height: 64px;
background-color: var(--el-fill-color);
border-radius: 4px;
display: flex;
@@ -1230,6 +1330,7 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
font-size: 20px;
flex-shrink: 0;
overflow: hidden;
.product-img {
width: 100%;
height: 100%;
@@ -1237,6 +1338,12 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
}
}
.product-info {
display: flex;
gap: 12px;
align-items: flex-start;
}
.product-details {
flex: 1;
min-width: 0;
@@ -1298,12 +1405,11 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
}
}
}
}
}
.product-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
@@ -1381,8 +1487,9 @@ $kb22-border: #e2e8f0;
}
.kb22-tags-container { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.kb22-tag-pill { font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600; }
.kb22-tag-blue { background: #eff6ff; color: $kb22-accent; border: 1px solid #dbeafe; }
.kb22-tag-purple { background: #f5f3ff; color: #7c3aed; border: 1px solid #ede9fe; }
.kb22-tag-danger { background: #fee2e2; color: #ef4444; border: 1px solid #fecaca; }
.kb22-tag-warn { background: #fef3c7; color: #d97706; border: 1px solid #fde68a; }
.kb22-tag-success { background: #dcfce7; color: #16a34a; border: 1px solid #bbf7d0; }
.kb22-price-section { margin-top: auto; }
.kb22-current-price { font-size: 22px; font-weight: 800; color: $kb22-primary; letter-spacing: -0.5px; }
.kb22-cost-price { font-size: 12px; color: #94a3b8; text-decoration: line-through; margin-left: 6px; }
@@ -1487,7 +1594,7 @@ $kb22-border: #e2e8f0;
margin-top: 8px;
justify-content: flex-end;
}
.kb22-legend-item { display: flex; align-items: center; gap: 4px; font-size: 9px; color: #94a3b8; }
.kb22-legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #94a3b8; }
.kb22-dot { width: 6px; height: 6px; border-radius: 50%; }
.kb22-dot-stock { background: #0f172a; }
.kb22-dot-sales { background: #3b82f6; }

View File

@@ -196,92 +196,170 @@
</div>
</el-card>
<!-- 标签分类筛选与主页供应商表现一致 -->
<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 }}
<!-- 内容卡片展示形式切换 + 标签筛选 + 卡片/列表标签放在卡片内部更统一 -->
<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>
</div>
<div class="cards-wrap" v-loading="loading" element-loading-text="加载中...">
<div v-if="visibleRows.length === 0 && !loading" class="empty">暂无数据</div>
<div v-else class="cards-grid">
<!-- 标签分类筛选与主页供应商表现一致 -->
<div v-if="labelOpts.length > 0" class="filter-bar">
<div
v-for="(row, idx) in visibleRows"
:key="rowKey(row, idx)"
class="supplier-card"
:class="getCardBorderClass(row)"
v-for="opt in labelOpts"
:key="opt.value"
:class="['pill', getTagClass(opt.label), { active: labelFilter === opt.value }]"
@click="labelFilter = opt.value"
>
<!-- 卡片头部标题+标签在左上右上显示 titleName / titleValue -->
<div class="card-header">
<div class="header-left">
<h2 class="card-title-main">
{{ String((row as any)?.title ?? (row as any)?.name ?? '-') }}
</h2>
<div v-if="getRowTags(row).length > 0" class="card-tags">
<span
v-for="(tag, ti) in getRowTags(row)"
:key="ti"
class="badge"
:class="getTagClass(tag)"
{{ opt.label }}
</div>
</div>
<!-- 卡片形式 -->
<div v-show="displayMode === 'card'" class="cards-wrap" v-loading="loading" element-loading-text="加载中...">
<div v-if="visibleRows.length === 0 && !loading" class="empty">暂无数据</div>
<div v-else class="cards-grid">
<div
v-for="(row, idx) in visibleRows"
:key="rowKey(row, idx)"
class="supplier-card"
:class="getCardBorderClass(row)"
>
<!-- 卡片头部标题+标签在左上右上显示 titleName / titleValue -->
<div class="card-header">
<div class="header-left">
<h2 class="card-title-main">
{{ String((row as any)?.title ?? (row as any)?.name ?? '-') }}
</h2>
<div v-if="getRowTags(row).length > 0" class="card-tags">
<span
v-for="(tag, ti) in getRowTags(row)"
:key="ti"
class="badge"
:class="getTagClass(tag)"
>
{{ tag }}
</span>
</div>
</div>
<div v-if="getTitleName(row) || getTitleValue(row)" class="header-right">
<span class="rate-label">{{ getTitleName(row) }}</span>
<span class="rate-value">{{ getTitleValue(row) }}</span>
</div>
</div>
<!-- 指标表格三列网格布局 -->
<div v-if="getColumnsWithDate(row).length > 0" class="metric-section">
<div class="grid-layout metric-header">
<span>检测指标</span>
<span>实际结果</span>
<span>基准参考</span>
</div>
<div class="metric-rows">
<div
v-for="(item, mi) in getTableData(row)"
:key="mi"
class="grid-layout metric-row"
>
{{ tag }}
</span>
<span class="metric-label">{{ item.metricName }}</span>
<span class="metric-actual" :class="item.valueColorClass">
{{ item.actualValue }}
<span v-if="item.arrow" class="trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
<template v-if="item.labelPairs && item.labelPairs.length > 0">
<span
v-for="(pair, pi) in item.labelPairs"
:key="pi"
class="badge-small"
:class="getTrendClassFromColor(pair.color)"
>
{{ pair.label }}
</span>
</template>
</span>
<span class="metric-ref">{{ item.paramValue }}</span>
</div>
</div>
</div>
<div v-if="getTitleName(row) || getTitleValue(row)" class="header-right">
<span class="rate-label">{{ getTitleName(row) }}</span>
<span class="rate-value">{{ getTitleValue(row) }}</span>
</div>
</div>
<!-- 指标表格三列网格布局 -->
<div v-if="getColumnsWithDate(row).length > 0" class="metric-section">
<div class="grid-layout metric-header">
<span>检测指标</span>
<span>实际结果</span>
<span>基准参考</span>
<!-- 底部 -->
<div class="card-footer">
<span class="footer-label">&nbsp;</span>
<span class="footer-link">详情 </span>
</div>
<div class="metric-rows">
<div
v-for="(item, mi) in getTableData(row)"
:key="mi"
class="grid-layout metric-row"
>
<span class="metric-label">{{ item.metricName }}</span>
<span class="metric-actual" :class="item.valueColorClass">
{{ item.actualValue }}
<span v-if="item.arrow" class="trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
<template v-if="item.labelPairs && item.labelPairs.length > 0">
<span
v-for="(pair, pi) in item.labelPairs"
:key="pi"
class="badge-small"
:class="getTrendClassFromColor(pair.color)"
>
{{ pair.label }}
</span>
</template>
</span>
<span class="metric-ref">{{ item.paramValue }}</span>
</div>
</div>
</div>
<!-- 底部 -->
<div class="card-footer">
<span class="footer-label">&nbsp;</span>
<span class="footer-link">详情 </span>
</div>
</div>
</div>
</div>
<!-- 列表形式不需要悬浮卡片 -->
<div v-show="displayMode === 'table'" class="table-wrap">
<el-table :data="visibleRows" v-loading="loading" border stripe style="width: 100%">
<el-table-column label="供应商" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<div class="table-title">
<div class="table-title-main">{{ String((row as any)?.title ?? (row as any)?.name ?? '-') }}</div>
<div v-if="getRowTags(row).length > 0" class="table-tags">
<span
v-for="(tag, ti) in getRowTags(row)"
:key="ti"
class="badge"
:class="getTagClass(tag)"
>
{{ tag }}
</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="核心指标" width="160" align="center">
<template #default="{ row }">
<div class="table-core">
<div class="core-name">{{ getTitleName(row) || '-' }}</div>
<div class="core-value">{{ getTitleValue(row) || '-' }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="检测指标明细" min-width="420">
<template #default="{ row }">
<div class="metric-cell">
<div
v-for="(item, mi) in getTableData(row)"
:key="mi"
class="metric-cell-row"
>
<div class="metric-cell-name">{{ item.metricName }}</div>
<div class="metric-cell-actual" :class="item.valueColorClass">
{{ item.actualValue }}
<span v-if="item.arrow" class="trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
<template v-if="item.labelPairs && item.labelPairs.length > 0">
<span
v-for="(pair, pi) in item.labelPairs"
:key="pi"
class="badge-small"
:class="getTrendClassFromColor(pair.color)"
>
{{ pair.label }}
</span>
</template>
</div>
<div class="metric-cell-ref">{{ item.paramValue }}</div>
</div>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
@@ -290,6 +368,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
import { Grid, List } from '@element-plus/icons-vue'
import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { useUserStore } from '@/store/modules/user'
@@ -305,6 +384,9 @@ const REPORT_ID = 6
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
/** 展示形式:卡片 | 列表 */
const displayMode = ref<'card' | 'table'>('card')
const columns = ref<ColumnCfg[]>([])
const rows = ref<any[]>([])
/** 标签分类筛选(与主页供应商表现一致) */
@@ -341,6 +423,46 @@ function initQueryParamsFromRoute() {
if (q.line) queryParams.line = String(q.line).split(',').filter(Boolean)
if (q.code) supplierCode.value = String(q.code)
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 获取)
@@ -822,6 +944,36 @@ onMounted(async () => {
margin-bottom: 20px;
}
.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);
font-weight: 500;
}
}
}
.content-card {
border-radius: 12px;
border: 1px solid var(--el-border-color-lighter);
:deep(.el-card__body) {
padding: 16px;
}
}
.query-form {
display: flex;
flex-wrap: wrap;
@@ -921,12 +1073,176 @@ onMounted(async () => {
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;
}
}
.cards-wrap {
margin-top: 16px;
}
.table-wrap {
margin-top: 8px;
}
.table-title {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
.table-title-main {
font-weight: 700;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.badge {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
display: inline-block;
}
.badge-danger {
background: #fee2e2;
color: #ef4444;
border: 1px solid #fecaca;
}
.badge-warn {
background: #fef3c7;
color: #d97706;
border: 1px solid #fde68a;
}
.badge-success {
background: #dcfce7;
color: #22c55e;
border: 1px solid #bbf7d0;
}
}
}
.table-core {
display: flex;
flex-direction: column;
gap: 4px;
.core-name {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.core-value {
font-weight: 700;
color: var(--el-text-color-primary);
}
}
.metric-cell {
display: flex;
flex-direction: column;
gap: 10px;
padding: 2px 0;
}
.metric-cell-row {
display: grid;
grid-template-columns: 160px 1fr 120px;
gap: 12px;
align-items: start;
}
.metric-cell-name {
font-size: 12px;
color: var(--el-text-color-regular);
}
.metric-cell-actual {
font-size: 12px;
color: var(--el-text-color-primary);
line-height: 1.4;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
.trend-arrow {
font-weight: 700;
margin-left: 2px;
&.arrow-up {
color: #ef4444;
}
&.arrow-down {
color: #22c55e;
}
}
.badge-small {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
display: inline-block;
&.badge-danger,
&.label-trend-up {
background: #fee2e2;
color: #ef4444;
border: 1px solid #fecaca;
}
&.badge-warn,
&.label-trend-flat {
background: #fef3c7;
color: #d97706;
border: 1px solid #fde68a;
}
&.badge-success,
&.label-trend-down {
background: #dcfce7;
color: #22c55e;
border: 1px solid #bbf7d0;
}
}
}
.metric-cell-ref {
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: right;
line-height: 1.4;
white-space: pre-line;
}
.empty {
padding: 40px 0;
text-align: center;

View File

@@ -206,90 +206,167 @@
</div>
</el-card>
<!-- 标签分类筛选 -->
<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 }}
<!-- 内容卡片展示形式切换 + 标签筛选 + 卡片/列表 -->
<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>
</div>
<!-- 品类卡片列表 -->
<div class="category-cards-grid" v-loading="loading">
<div v-if="!loading && visibleRows.length === 0" class="empty-tip">暂无数据</div>
<transition-group v-else name="card-list" tag="div" class="cards-container">
<!-- 标签分类筛选 -->
<div v-if="labelOpts.length > 0" class="filter-bar">
<div
v-for="(row, index) in visibleRows"
:key="rowKey(row, index)"
class="category-card slide-in-up"
:class="getCardBorderClass(row)"
:style="{ animationDelay: `${index * 0.1}s` }"
v-for="opt in labelOpts"
:key="opt.value"
:class="['pill', getTagClass(opt.label), { active: labelFilter === opt.value }]"
@click="labelFilter = opt.value"
>
<!-- 卡片头部与供应商详情页一致标题+标签在左上右上 titleName/titleValue -->
<div class="card-header">
<div class="header-left">
<h2 class="category-name">{{ String(row?.title ?? row?.name ?? '-') }}</h2>
<div class="category-tags">
<span
v-for="(tag, ti) in getRowTags(row)"
:key="ti"
class="badge"
:class="getTagClass(tag)"
>
{{ tag }}
</span>
{{ opt.label }}
</div>
</div>
<!-- 卡片形式品类卡片列表 -->
<div v-show="displayMode === 'card'" class="category-cards-grid" v-loading="loading">
<div v-if="!loading && visibleRows.length === 0" class="empty-tip">暂无数据</div>
<transition-group v-else name="card-list" tag="div" class="cards-container">
<div
v-for="(row, index) in visibleRows"
:key="rowKey(row, index)"
class="category-card slide-in-up"
:class="getCardBorderClass(row)"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<!-- 卡片头部与供应商详情页一致标题+标签在左上右上 titleName/titleValue -->
<div class="card-header">
<div class="header-left">
<h2 class="category-name">{{ String(row?.title ?? row?.name ?? '-') }}</h2>
<div class="category-tags">
<span
v-for="(tag, ti) in getRowTags(row)"
:key="ti"
class="badge"
:class="getTagClass(tag)"
>
{{ tag }}
</span>
</div>
</div>
<div v-if="getTitleName(row) || getTitleValue(row)" class="header-right">
<span class="rate-label">{{ getTitleName(row) }}</span>
<span class="rate-value">{{ getTitleValue(row) }}</span>
</div>
</div>
<div v-if="getTitleName(row) || getTitleValue(row)" class="header-right">
<span class="rate-label">{{ getTitleName(row) }}</span>
<span class="rate-value">{{ getTitleValue(row) }}</span>
<!-- 指标表格与供应商详情页一致由接口列配置 + 行数据动态生成 -->
<div v-if="getColumnsWithDate(row).length > 0" class="metric-section">
<div class="metric-header">
<span>检测指标</span>
<span>实际结果</span>
<span>基准参考</span>
</div>
<div
v-for="(item, mi) in getTableData(row)"
:key="mi"
class="grid-layout metric-row"
>
<span class="metric-label">{{ item.metricName }}</span>
<span class="metric-actual" :class="item.valueColorClass">
{{ item.actualValue }}
<span v-if="item.arrow" class="trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
<template v-if="item.labelPairs && item.labelPairs.length > 0">
<span
v-for="(pair, pi) in item.labelPairs"
:key="pi"
class="badge-small"
:class="getTrendClassFromColor(pair.color)"
>
{{ pair.label }}
</span>
</template>
</span>
<span class="metric-ref">{{ item.paramValue }}</span>
</div>
</div>
<!-- 卡片底部 -->
<div class="card-footer">
<span class="footer-label">&nbsp;</span>
<span class="footer-link" @click="handleViewDetail(row)">详情 </span>
</div>
</div>
</transition-group>
</div>
<!-- 指标表格与供应商详情页一致由接口列配置 + 行数据动态生成 -->
<div v-if="getColumnsWithDate(row).length > 0" class="metric-section">
<div class="metric-header">
<span>检测指标</span>
<span>实际结果</span>
<span>基准参考</span>
</div>
<div
v-for="(item, mi) in getTableData(row)"
:key="mi"
class="grid-layout metric-row"
>
<span class="metric-label">{{ item.metricName }}</span>
<span class="metric-actual" :class="item.valueColorClass">
{{ item.actualValue }}
<span v-if="item.arrow" class="trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
<template v-if="item.labelPairs && item.labelPairs.length > 0">
<!-- 列表形式不需要悬浮卡片 -->
<div v-show="displayMode === 'table'" class="table-wrap">
<el-table :data="visibleRows" v-loading="loading" border stripe style="width: 100%">
<el-table-column label="中类" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<div class="table-title">
<div class="table-title-main">{{ String(row?.title ?? row?.name ?? '-') }}</div>
<div v-if="getRowTags(row).length > 0" class="table-tags">
<span
v-for="(pair, pi) in item.labelPairs"
:key="pi"
class="badge-small"
:class="getTrendClassFromColor(pair.color)"
v-for="(tag, ti) in getRowTags(row)"
:key="ti"
class="badge"
:class="getTagClass(tag)"
>
{{ pair.label }}
{{ tag }}
</span>
</template>
</span>
<span class="metric-ref">{{ item.paramValue }}</span>
</div>
</div>
<!-- 卡片底部 -->
<div class="card-footer">
<span class="footer-label">&nbsp;</span>
<span class="footer-link" @click="handleViewDetail(row)">详情 </span>
</div>
</div>
</transition-group>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="核心指标" width="160" align="center">
<template #default="{ row }">
<div class="table-core">
<div class="core-name">{{ getTitleName(row) || '-' }}</div>
<div class="core-value">{{ getTitleValue(row) || '-' }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="检测指标明细" min-width="420">
<template #default="{ row }">
<div class="metric-cell">
<div
v-for="(item, mi) in getTableData(row)"
:key="mi"
class="metric-cell-row"
>
<div class="metric-cell-name">{{ item.metricName }}</div>
<div class="metric-cell-actual" :class="item.valueColorClass">
{{ item.actualValue }}
<span v-if="item.arrow" class="metric-trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
<template v-if="item.labelPairs && item.labelPairs.length > 0">
<span
v-for="(pair, pi) in item.labelPairs"
:key="pi"
class="metric-badge-small"
:class="getTrendClassFromColor(pair.color)"
>
{{ pair.label }}
</span>
</template>
</div>
<div class="metric-cell-ref">{{ item.paramValue }}</div>
</div>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
@@ -297,7 +374,7 @@
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import dayjs from 'dayjs'
import { Search } from '@element-plus/icons-vue'
import { Search, Grid, List } from '@element-plus/icons-vue'
import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
@@ -396,6 +473,8 @@ const getTimeRange = (range: string) => {
}
const loading = ref(false)
/** 展示形式:卡片 | 列表 */
const displayMode = ref<'card' | 'table'>('card')
const columns = ref<{ title: string; key: string; order?: number; labelKey?: string; colorKey?: string }[]>([])
const categoryList = ref<any[]>([])
/** 标签分类筛选 */
@@ -414,11 +493,44 @@ function initQueryParamsFromRoute() {
if (q.line) queryParams.line = String(q.line).split(',').filter(Boolean)
if (q.zhonglei !== undefined) queryParams.zhonglei = String(q.zhonglei)
dateRange.value = [queryParams.rq, queryParams.rq2]
// 如果日期范围匹配默认的"近一周",设置 activeTimeRange
const defaultWeek7 = getTimeRange('week7')
if (queryParams.rq === defaultWeek7.rq && queryParams.rq2 === defaultWeek7.rq2) {
activeTimeRange.value = 'week7'
} else {
// 根据路由传入的时间区间反推当前快捷按钮
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 = ''
}
}
@@ -843,6 +955,179 @@ onMounted(async () => {
margin-bottom: 20px;
}
.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);
font-weight: 500;
}
}
}
.content-card {
border-radius: 12px;
border: 1px solid var(--el-border-color-lighter);
:deep(.el-card__body) {
padding: 16px;
}
}
.table-wrap {
margin-top: 8px;
}
.table-title {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
.table-title-main {
font-weight: 700;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.badge {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
display: inline-block;
}
.badge-danger {
background: #fee2e2;
color: #ef4444;
border: 1px solid #fecaca;
}
.badge-warn {
background: #fef3c7;
color: #d97706;
border: 1px solid #fde68a;
}
.badge-success {
background: #dcfce7;
color: #22c55e;
border: 1px solid #bbf7d0;
}
}
}
.table-core {
display: flex;
flex-direction: column;
gap: 4px;
.core-name {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.core-value {
font-weight: 700;
color: var(--el-text-color-primary);
}
}
.metric-cell {
display: flex;
flex-direction: column;
gap: 10px;
padding: 2px 0;
}
.metric-cell-row {
display: grid;
grid-template-columns: 160px 1fr 120px;
gap: 12px;
align-items: start;
}
.metric-cell-name {
font-size: 12px;
color: var(--el-text-color-regular);
}
.metric-cell-actual {
font-size: 12px;
color: var(--el-text-color-primary);
line-height: 1.4;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
.metric-trend-arrow {
font-weight: 700;
margin-left: 2px;
&.arrow-up {
color: #ef4444;
}
&.arrow-down {
color: #22c55e;
}
}
.metric-badge-small {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
display: inline-block;
&.badge-danger {
background: #fee2e2;
color: #ef4444;
border: 1px solid #fecaca;
}
&.badge-warn {
background: #fef3c7;
color: #d97706;
border: 1px solid #fde68a;
}
&.badge-success {
background: #dcfce7;
color: #22c55e;
border: 1px solid #bbf7d0;
}
}
}
.metric-cell-ref {
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: right;
line-height: 1.4;
white-space: pre-line;
}
.query-form {
display: flex;
flex-wrap: wrap;
@@ -918,6 +1203,149 @@ onMounted(async () => {
}
}
.table-wrap {
margin-top: 8px;
}
.table-title {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
.table-title-main {
font-weight: 700;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.badge {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
display: inline-block;
}
.badge-danger {
background: #fee2e2;
color: #ef4444;
border: 1px solid #fecaca;
}
.badge-warn {
background: #fef3c7;
color: #d97706;
border: 1px solid #fde68a;
}
.badge-success {
background: #dcfce7;
color: #22c55e;
border: 1px solid #bbf7d0;
}
}
}
.table-core {
display: flex;
flex-direction: column;
gap: 4px;
.core-name {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.core-value {
font-weight: 700;
color: var(--el-text-color-primary);
}
}
.metric-cell {
display: flex;
flex-direction: column;
gap: 10px;
padding: 2px 0;
}
.metric-cell-row {
display: grid;
grid-template-columns: 160px 1fr 120px;
gap: 12px;
align-items: start;
}
.metric-cell-name {
font-size: 12px;
color: var(--el-text-color-regular);
}
.metric-cell-actual {
font-size: 12px;
color: var(--el-text-color-primary);
line-height: 1.4;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
.metric-trend-arrow {
font-weight: 700;
margin-left: 2px;
&.arrow-up {
color: #ef4444;
}
&.arrow-down {
color: #22c55e;
}
}
.metric-badge-small {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
display: inline-block;
&.badge-danger {
background: #fee2e2;
color: #ef4444;
border: 1px solid #fecaca;
}
&.badge-warn {
background: #fef3c7;
color: #d97706;
border: 1px solid #fde68a;
}
&.badge-success {
background: #dcfce7;
color: #22c55e;
border: 1px solid #bbf7d0;
}
}
}
.metric-cell-ref {
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: right;
line-height: 1.4;
white-space: pre-line;
}
.pill {
white-space: nowrap;
padding: 4px 10px;
@@ -938,6 +1366,24 @@ onMounted(async () => {
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 File

@@ -197,43 +197,42 @@
</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 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"
:class="{ active: labelFilter === opt.value }"
:class="['pill', { active: labelFilter === opt.value }]"
@click="labelFilter = opt.value"
>
{{ opt.label }}
@@ -495,6 +494,46 @@ function initQueryParamsFromRoute() {
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 获取)
@@ -1275,6 +1314,24 @@ onMounted(async () => {
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 {

View File

@@ -546,10 +546,11 @@
<template #default="{ row }">
<el-popover
placement="right"
:width="340"
:width="360"
trigger="hover"
popper-class="product-detail-popover"
:popper-options="{ modifiers: [{ name: 'offset', options: { offset: [0, 12] } }] }"
@show="handleProductHoverShow(row)"
>
<template #reference>
<div class="product-info">
@@ -574,59 +575,116 @@
</div>
</div>
</template>
<!-- 悬浮详情卡片KB22 样式 -->
<!-- 悬浮详情卡片KB22 样式 product-cards 保持一致仅展示当前单个商品 -->
<div class="product-detail-card kb22-card">
<div class="kb22-header-bar">
<div>款号: <span class="kb22-code">{{ row.code }}</span></div>
<div>款号: <span class="kb22-code">{{ hoverDetail?.code || row.code }}</span></div>
<div class="kb22-color-badge">
<span class="kb22-color-dot" :style="{ background: getColorCode(row.color) }"></span>
{{ row.color }}
<span class="kb22-color-dot" :style="{ background: getColorCode(hoverDetail?.color || row.color) }"></span>
{{ hoverDetail?.color || row.color }}
</div>
</div>
<div class="kb22-card-body">
<div class="kb22-media-row">
<div class="kb22-thumb-box">
<img v-if="row.imageUrl" :src="row.imageUrl" alt="" class="kb22-thumb-img" />
<img v-if="hoverDetail?.imageUrl || row.imageUrl" :src="hoverDetail?.imageUrl || row.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">{{ row.name }}</div>
<div class="kb22-prod-title">{{ hoverDetail?.name || row.name }}</div>
<div class="kb22-tags-container">
<span class="kb22-tag-pill kb22-tag-blue">{{ row.season }}</span>
<span class="kb22-tag-pill kb22-tag-purple">{{ row.discount }}</span>
<span
v-if="hoverDetail?.type"
class="kb22-tag-pill"
:class="productTagClass(hoverDetail.type)"
>
{{ hoverDetail.type }}
</span>
<span
v-if="hoverDetail?.lifecycle"
class="kb22-tag-pill"
:class="productTagClass(hoverDetail.lifecycle)"
>
{{ hoverDetail.lifecycle }}
</span>
</div>
<div class="kb22-price-section">
<span class="kb22-current-price">¥{{ row.sellingPrice ?? 0 }}</span>
<span class="kb22-cost-price">¥{{ row.purchasePrice ?? 0 }}</span>
<span class="kb22-margin-text">毛利 {{ formatGrossMarginPct(row.grossMargin, row.purchasePrice != null && row.sellingPrice != null ? (1 - row.purchasePrice / row.sellingPrice) * 100 : undefined) }}%</span>
<span class="kb22-current-price">
<template v-if="(hoverDetail?.sellingPrice ?? row.sellingPrice) != null">
¥{{ formatNumber(hoverDetail?.sellingPrice ?? row.sellingPrice) }}
</template>
<template v-else>-</template>
</span>
<span class="kb22-cost-price">
<template v-if="(hoverDetail?.purchasePrice ?? row.purchasePrice) != null">
¥{{ formatNumber(hoverDetail?.purchasePrice ?? row.purchasePrice) }}
</template>
<template v-else>-</template>
</span>
<span class="kb22-margin-text">
<template v-if="(hoverDetail?.grossMargin ?? row.grossMargin) != null">
毛利
{{
formatGrossMarginPct(
hoverDetail?.grossMargin ?? row.grossMargin,
(hoverDetail?.purchasePrice ?? row.purchasePrice) != null &&
(hoverDetail?.sellingPrice ?? row.sellingPrice) != null
? (1 - (hoverDetail?.purchasePrice ?? row.purchasePrice) / (hoverDetail?.sellingPrice ?? row.sellingPrice)) * 100
: undefined
)
}}%
</template>
<template v-else>-</template>
</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">{{ row.salesCount ?? 0 }}<span class="kb22-stat-sub"></span></div>
<div class="kb22-stat-value">
<template v-if="(hoverDetail?.salesCount ?? row.salesCount) != null">
{{ hoverDetail?.salesCount ?? row.salesCount }}<span class="kb22-stat-sub"></span>
</template>
<template v-else>-</template>
</div>
<div class="kb22-sales-footer">
<div class="kb22-mini-progress">
<div class="kb22-mini-fill" :style="{ width: Math.min(100, row.selloutRate ?? 0) + '%' }"></div>
<div class="kb22-mini-progress" v-if="(hoverDetail?.selloutRate ?? row.selloutRate) != null">
<div class="kb22-mini-fill" :style="{ width: Math.min(100, hoverDetail?.selloutRate ?? row.selloutRate) + '%' }"></div>
</div>
<div class="kb22-rate-text">
<template v-if="(hoverDetail?.selloutRate ?? row.selloutRate) != null">
售罄率 {{ hoverDetail?.selloutRate ?? row.selloutRate }}%
</template>
<template v-else>-</template>
</div>
<div class="kb22-rate-text">售罄率 {{ row.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">{{ row.kcslRaw || (row.inventoryCount != null ? row.inventoryCount + '件' : '0件') }}</div>
<div v-if="row.turnoverText" class="kb22-turnover-text" :class="uiTextClass(row.turnoverStatus)">周转: {{ row.turnoverText }}</div>
<div class="kb22-stat-value kcsl-display">
<template v-if="(hoverDetail?.inventoryCount ?? row.inventoryCount) != null">
{{ hoverDetail?.inventoryCount ?? row.inventoryCount }}
</template>
<template v-else>-</template>
</div>
<div
v-if="hoverDetail?.turnoverText || row.turnoverText"
class="kb22-turnover-text"
:class="uiTextClass((hoverDetail?.turnoverStatus as any) || row.turnoverStatus)"
>
周转: {{ hoverDetail?.turnoverText || row.turnoverText }}
</div>
</div>
</div>
<div v-if="row.sizes && row.sizes.length" class="kb22-stock-footer">
<div v-if="(hoverDetail?.sizes && hoverDetail.sizes.length) || (row.sizes && row.sizes.length)" class="kb22-stock-footer">
<div class="kb22-section-header">
<span>SKU明细 ({{ row.sizes.length }})</span>
<span v-if="hasOutOfStockSize(row.sizes)" class="kb22-alert-tip"> 有断货</span>
<span>SKU明细 ({{ (hoverDetail?.sizes || row.sizes).length }})</span>
<span v-if="hasOutOfStockSize(hoverDetail?.sizes || row.sizes)" class="kb22-alert-tip"> 有断货</span>
</div>
<div class="kb22-size-grid">
<div
v-for="(size, i) in row.sizes"
v-for="(size, i) in (hoverDetail?.sizes || row.sizes)"
:key="i"
class="kb22-size-cell"
:class="{ 'kb22-alert': size.status === 'out' || size.stock === 0 }"
@@ -1176,6 +1234,7 @@ const loadingPie = ref(false)
/** 商品明细列表加载状态 */
const loadingProductList = ref(false)
const searchKeyword = ref('')
const hoverDetail = ref<ProductDetailData | null>(null)
// 计算时间区间近一周、近15天、近30天、年份
const getTimeRange = (range: string) => {
@@ -1211,6 +1270,44 @@ const getTimeRange = (range: string) => {
return { rq: start.format('YYYY-MM-DD'), rq2: end.format('YYYY-MM-DD') }
}
// 商品明细 hover 时,按当前查询条件 + spdm 取最新单品详情(使用与 product-cards 相同的存储过程)
const handleProductHoverShow = async (row: ProductDetailData) => {
try {
const params = {
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,
spdm: String(row.spdm || '')
}
const res: any = await ReportApi.executeProcedureWithData({
...params,
name: 'YDY_AI_GET_SPXQ'
} as any)
let data: any[] | null = null
if (Array.isArray(res)) data = res
else if (res && Array.isArray(res.data)) data = res.data
else if (res && res.code != null && Array.isArray(res.data)) data = res.data
if (data && data.length > 0) {
// 复用现有映射逻辑,将第一条记录映射为 ProductDetailData
hoverDetail.value = mapApiRowToProductDetail(data[0])
} else {
hoverDetail.value = null
}
} catch (e) {
console.error('加载商品 hover 详情失败:', e)
hoverDetail.value = null
}
}
// 处理快捷时间按钮点击
const handleTimeRangeClick = (range: string) => {
activeTimeRange.value = range
@@ -1920,13 +2017,23 @@ const getProductCode = (row: ProductDetailData): string => {
return row.code || '-'
}
// 点击款号跳转到详情页面
// 点击款号跳转到详情页面(携带当前大盘的全部查询条件)
const handleProductCodeClick = (row: ProductDetailData) => {
const spdm = row.spdm || row.code || ''
router.push({
path: '/reports/lijun/reportpage6/detail',
query: {
spdm: spdm
// 关键商品编码
spdm,
// 透传当前大盘查询条件,明细页可直接还原
rq: queryParams.rq,
rq2: queryParams.rq2,
ckdm: queryParams.ckdm.join(','),
pp: queryParams.pp.join(','),
season: queryParams.season.join(','),
zgj: queryParams.zgj.join(','),
category: queryParams.category.join(','),
line: queryParams.line.join(',')
}
})
}