fix: 自定义标签
This commit is contained in:
@@ -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}`
|
||||
})
|
||||
]
|
||||
}
|
||||
@@ -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
3531
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
1951
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
55
src/api/ydoyun/customtag/index.ts
Normal file
55
src/api/ydoyun/customtag/index.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
203
src/views/ydoyun/customtag/CustomTagForm.vue
Normal file
203
src/views/ydoyun/customtag/CustomTagForm.vue
Normal 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>
|
||||
232
src/views/ydoyun/customtag/index.vue
Normal file
232
src/views/ydoyun/customtag/index.vue
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"> </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"> </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;
|
||||
|
||||
@@ -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"> </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"> </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;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 卡片网格布局 ====================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(',')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user