小如重构

This commit is contained in:
zjf committed 2026-02-09 18:26:12 +08:00
1 parent a984ca9574
commit 54bee11cd4
20 files changed
+1835 -409

No files matched your search

+18 -6
View File
@@ -2,12 +2,14 @@ import request from '@/config/axios'
// 货道 VO
export interface ChannelVO {
id: number // 表编号,自增
name: string // 名称
deviceId: string // 设备IMEI
cabinetChannel: string // 当前使用的货道或柜门编号
maxNum: number // 最大货物数
channelLine: number // 货道行号
id?: number // 表编号,自增
name?: string // 名称
deviceId?: number | string // 设备ID(number)或设备IMEI(string,兼容字段)
deviceImei?: string // 设备IMEI(兼容字段)
cabinetChannel?: string // 当前使用的货道或柜门编号
maxNum?: number // 最大货物数
channelLine?: number // 货道行号
createTime?: string // 创建时间
}
// 货道 API
@@ -40,5 +42,15 @@ export const ChannelApi = {
// 导出货道 Excel
exportChannel: async (params) => {
return await request.download({ url: `/product/channel/export-excel`, params })
},
// 测试货道
testChannel: async (id: number) => {
return await request.post({ url: `/product/channel/channel-test?id=` + id })
},
// 批量新增货道
batchCreateChannel: async (data: { channels: ChannelVO[] }) => {
return await request.post({ url: `/product/channel/batch-create`, data })
}
}
+33
View File
@@ -0,0 +1,33 @@
import request from '@/config/axios'
import type { Sku, Spu } from './spu'
// 售货机商品 API(与商城商品分离)
export const DeviceSpuApi = {
// 分页查询售货机商品
getDeviceSpuPage: async (params: PageParam) => {
return await request.get({ url: '/product/device-spu/page', params })
},
// 获取售货机商品详情
getDeviceSpu: async (id: number) => {
return await request.get({ url: `/product/device-spu/get-detail?id=${id}` })
},
// 创建售货机商品
createDeviceSpu: async (data: Spu) => {
return await request.post({ url: '/product/device-spu/create', data })
},
// 更新售货机商品
updateDeviceSpu: async (data: Spu) => {
return await request.put({ url: '/product/device-spu/update', data })
},
// 删除售货机商品
deleteDeviceSpu: async (id: number) => {
return await request.delete({ url: `/product/device-spu/delete?id=${id}` })
}
}
// 导出类型,方便其他地方使用
export type { Spu, Sku }
+6 -1
View File
@@ -50,8 +50,13 @@ export const DeviceApi = {
return await request.download({ url: `/product/device/export-excel`, params })
},
// 获取设备下拉列表(精简,自动根据当前登录用户过滤)
// 获取设备下拉列表(精简,全部,不按用户过滤)
getDeviceListAllSimple: async () => {
return await request.get({ url: `/product/device/list-all-simple` })
},
// 获取设备下拉列表(精简,按用户绑定过滤)
getDeviceListSimpleByUser: async () => {
return await request.get({ url: `/product/device/list-simple-by-user` })
},
}
+31 -31
View File
@@ -20,35 +20,35 @@
</el-col>
<el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24">
<div class="h-70px flex items-center justify-end lt-sm:mt-10px">
<div class="px-8px text-right">
<div class="mb-16px text-14px text-gray-400">{{ t('workplace.project') }}</div>
<CountTo
class="text-20px"
:start-val="0"
:end-val="totalSate.project"
:duration="2600"
/>
</div>
<el-divider direction="vertical" />
<div class="px-8px text-right">
<div class="mb-16px text-14px text-gray-400">{{ t('workplace.toDo') }}</div>
<CountTo
class="text-20px"
:start-val="0"
:end-val="totalSate.todo"
:duration="2600"
/>
</div>
<el-divider direction="vertical" border-style="dashed" />
<div class="px-8px text-right">
<div class="mb-16px text-14px text-gray-400">{{ t('workplace.access') }}</div>
<CountTo
class="text-20px"
:start-val="0"
:end-val="totalSate.access"
:duration="2600"
/>
</div>
<!-- <div class="px-8px text-right">-->
<!-- <div class="mb-16px text-14px text-gray-400">{{ t('workplace.project') }}</div>-->
<!-- <CountTo-->
<!-- class="text-20px"-->
<!-- :start-val="0"-->
<!-- :end-val="totalSate.project"-->
<!-- :duration="2600"-->
<!-- />-->
<!-- </div>-->
<!-- <el-divider direction="vertical" />-->
<!-- <div class="px-8px text-right">-->
<!-- <div class="mb-16px text-14px text-gray-400">{{ t('workplace.toDo') }}</div>-->
<!-- <CountTo-->
<!-- class="text-20px"-->
<!-- :start-val="0"-->
<!-- :end-val="totalSate.todo"-->
<!-- :duration="2600"-->
<!-- />-->
<!-- </div>-->
<!-- <el-divider direction="vertical" border-style="dashed" />-->
<!-- <div class="px-8px text-right">-->
<!-- <div class="mb-16px text-14px text-gray-400">{{ t('workplace.access') }}</div>-->
<!-- <CountTo-->
<!-- class="text-20px"-->
<!-- :start-val="0"-->
<!-- :end-val="totalSate.access"-->
<!-- :duration="2600"-->
<!-- />-->
<!-- </div>-->
</div>
</el-col>
</el-row>
@@ -56,7 +56,7 @@
</el-card>
</div>
<el-row class="mt-8px" :gutter="8" justify="space-between">
<!-- <el-row class="mt-8px" :gutter="8" justify="space-between">
<el-col :xl="16" :lg="16" :md="24" :sm="24" :xs="24" class="mb-8px">
<el-card shadow="never">
<template #header>
@@ -169,7 +169,7 @@
</el-skeleton>
</el-card>
</el-col>
</el-row>
</el-row>-->
</template>
<script lang="ts" setup>
import { set } from 'lodash-es'
+17 -1
View File
@@ -50,11 +50,18 @@
<el-table-column label="访问商品地址" align="center" prop="productUrl" />
<el-table-column label="文件地址" align="center" prop="fileUrl" />
<el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180px" />
<el-table-column label="操作" align="center" min-width="120px">
<el-table-column label="操作" align="center" min-width="200px">
<template #default="{ row }">
<el-button link type="primary" @click="openForm('update', row.id)" v-hasPermi="['product:device:update']">
编辑
</el-button>
<el-button
link
type="success"
@click="handleChannelManage(row)"
>
<Icon icon="ep:grid" class="mr-5px" />货道管理
</el-button>
<el-button link type="danger" @click="handleDelete(row.id)" v-hasPermi="['product:device:delete']">
删除
</el-button>
@@ -68,6 +75,8 @@
<!-- 表单弹窗:添加/修改 -->
<DeviceForm ref="formRef" @success="getList" />
<!-- 货道管理对话框 -->
<ChannelManageDialog ref="channelManageRef" />
</template>
<script setup lang="ts">
@@ -75,6 +84,7 @@ import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { DeviceApi, DeviceVO } from '@/api/product/device'
import DeviceForm from './DeviceForm.vue'
import ChannelManageDialog from '@/views/mall/product/device/ChannelManageDialog.vue'
/** 贩卖机信息 列表 */
defineOptions({ name: 'Device' })
@@ -156,6 +166,12 @@ const handleExport = async () => {
}
}
/** 货道管理 */
const channelManageRef = ref()
const handleChannelManage = (row: DeviceVO) => {
channelManageRef.value.open(row)
}
/** 初始化 **/
onMounted(() => {
getList()
@@ -2,33 +2,50 @@
<!-- 情况一:添加/修改 -->
<el-table
v-if="!isDetail && !isActivityComponent"
:data="isBatch ? skuList : formData!.skus!"
:data="formData?.skus || []"
border
class="tabNumWidth"
max-height="500"
size="small"
:row-key="getRowKey"
@selection-change="handleSelectionChange"
>
<el-table-column align="center" label="图片" min-width="65">
<template #default="{ row }">
<UploadImg v-model="row.picUrl" height="50px" width="50px" />
<el-table-column width="55" align="center">
<template #header>
<el-checkbox
v-model="isCheckAll"
:indeterminate="isIndeterminate"
@change="handleCheckAll"
/>
</template>
<template #default="{ row, $index }">
<el-checkbox
:model-value="checkedStatus[getRowKey(row, $index)] || false"
@update:model-value="(val: boolean) => { checkedStatus[getRowKey(row, $index)] = val; handleCheckOne() }"
/>
</template>
</el-table-column>
<el-table-column align="center" label="货道号" min-width="120">
<template #default="{ row }">
<el-select
v-model="row.cabinetChannel"
class="w-100%"
placeholder="请选择货道"
clearable
filterable
:disabled="!props.deviceId"
@change="handleChannelChange(row)"
>
<el-option
v-for="channel in availableChannels"
:key="channel.cabinetChannel"
:label="channel.maxNum != null ? `${channel.cabinetChannel} (最大${channel.maxNum})` : (channel.cabinetChannel ?? '')"
:value="channel.cabinetChannel"
:disabled="isChannelUsed(channel.cabinetChannel, row)"
/>
</el-select>
</template>
</el-table-column>
<template v-if="formData!.specType && !isBatch">
<!-- 根据商品属性动态添加 -->
<el-table-column
v-for="(item, index) in tableHeaders"
:key="index"
:label="item.label"
align="center"
min-width="120"
>
<template #default="{ row }">
<span style="font-weight: bold; color: #40aaff">
{{ row.properties?.[index]?.valueName }}
</span>
</template>
</el-table-column>
</template>
<el-table-column align="center" label="商品条码" min-width="168">
<template #default="{ row }">
<el-input v-model="row.barCode" class="w-100%" />
@@ -72,69 +89,75 @@
</el-table-column>
<el-table-column align="center" label="库存" min-width="168">
<template #default="{ row }">
<el-input-number v-model="row.stock" :min="0" class="w-100%" controls-position="right" />
</template>
</el-table-column>
<el-table-column align="center" label="重量(kg)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.weight"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
<el-input-number
v-model="row.stock"
:min="0"
:max="getMaxStock(row)"
class="w-100%"
controls-position="right"
@change="handleStockChange(row)"
/>
</template>
</el-table-column>
<el-table-column align="center" label="体积(m^3)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.volume"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<template v-if="formData!.subCommissionType">
<el-table-column align="center" label="一级返佣(元)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.firstBrokeragePrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column align="center" label="二级返佣(元)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.secondBrokeragePrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
</template>
<el-table-column v-if="formData?.specType" align="center" fixed="right" label="操作" width="80">
<template #default="{ row }">
<el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd">
批量添加
</el-button>
<el-button v-else link size="small" type="primary" @click="deleteSku(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 批量新增货道对话框 -->
<Dialog
v-model="batchAddDialogVisible"
title="批量新增货道"
width="800px"
:close-on-click-modal="false"
>
<div class="batch-add-channels">
<div class="batch-add-header">
<div class="selected-info">
<span class="selected-count">已选择:<strong>{{ batchAddForm.selectedChannels.length }}</strong> 个货道</span>
<el-button
link
type="primary"
@click="handleSelectAll"
:disabled="availableChannelsForBatch.length === 0"
>
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
</div>
</div>
<div class="channels-grid">
<div
v-for="channel in availableChannelsForBatch"
:key="channel.cabinetChannel"
class="channel-card"
:class="{ 'selected': batchAddForm.selectedChannels.includes(channel.cabinetChannel) }"
@click="toggleChannel(channel.cabinetChannel)"
>
<el-checkbox
:model-value="batchAddForm.selectedChannels.includes(channel.cabinetChannel)"
@change="(val: boolean) => toggleChannel(channel.cabinetChannel, val)"
@click.stop
/>
<div class="channel-info">
<div class="channel-number">货道号:<strong>{{ channel.cabinetChannel }}</strong></div>
<div class="channel-max">最大数量:<span>{{ channel.maxNum || 0 }}</span></div>
<div v-if="channel.channelLine" class="channel-line">行号:{{ channel.channelLine }}</div>
</div>
</div>
<div v-if="availableChannelsForBatch.length === 0" class="empty-tip">
暂无可用货道
</div>
</div>
</div>
<template #footer>
<el-button @click="batchAddDialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleBatchAddConfirm"
:disabled="batchAddForm.selectedChannels.length === 0"
>
确定(已选 {{ batchAddForm.selectedChannels.length }} 个)
</el-button>
</template>
</Dialog>
<!-- 情况二:详情 -->
<el-table
v-if="isDetail"
@@ -147,14 +170,9 @@
@selection-change="handleSelectionChange"
>
<el-table-column v-if="isComponent" type="selection" width="45" />
<el-table-column align="center" label="图片" min-width="80">
<el-table-column align="center" label="货道号" min-width="100">
<template #default="{ row }">
<el-image
v-if="row.picUrl"
:src="row.picUrl"
class="h-50px w-50px"
@click="imagePreview(row.picUrl)"
/>
{{ row.cabinetChannel || '-' }}
</template>
</el-table-column>
<template v-if="formData!.specType && !isBatch">
@@ -198,16 +216,6 @@
{{ row.stock }}
</template>
</el-table-column>
<el-table-column align="center" label="重量(kg)" min-width="80">
<template #default="{ row }">
{{ row.weight }}
</template>
</el-table-column>
<el-table-column align="center" label="体积(m^3)" min-width="80">
<template #default="{ row }">
{{ row.volume }}
</template>
</el-table-column>
<template v-if="formData!.subCommissionType">
<el-table-column align="center" label="一级返佣(元)" min-width="80">
<template #default="{ row }">
@@ -283,16 +291,18 @@
</el-table>
</template>
<script lang="ts" setup>
import { PropType, Ref } from 'vue'
import { PropType, Ref, watch, computed } from 'vue'
import { copyValueToTarget, formatToFraction } from '@/utils'
import { propTypes } from '@/utils/propTypes'
import { UploadImg } from '@/components/UploadFile'
import type { Property, Sku, Spu } from '@/api/mall/product/spu'
import type { Sku, Spu } from '@/api/mall/product/device-spu'
import type { Property } from '@/api/mall/product/spu'
import { createImageViewer } from '@/components/ImageViewer'
import { RuleConfig } from '@/views/mall/product/spu/components/index'
import { PropertyAndValues } from './index'
import { ElTable } from 'element-plus'
import { isEmpty } from '@/utils/is'
import { Dialog } from '@/components/Dialog'
import type { ChannelVO } from '@/api/mall/product/channel'
defineOptions({ name: 'SkuList' })
const message = useMessage() // 消息弹窗
@@ -302,10 +312,6 @@ const props = defineProps({
type: Object as PropType<Spu>,
default: () => {}
},
propertyList: {
type: Array as PropType<PropertyAndValues[]>,
default: () => []
},
ruleConfig: {
type: Array as PropType<RuleConfig[]>,
default: () => []
@@ -313,23 +319,55 @@ const props = defineProps({
isBatch: propTypes.bool.def(false), // 是否作为批量操作组件
isDetail: propTypes.bool.def(false), // 是否作为 sku 详情组件
isComponent: propTypes.bool.def(false), // 是否作为 sku 选择组件
isActivityComponent: propTypes.bool.def(false) // 是否作为 sku 活动配置组件
isActivityComponent: propTypes.bool.def(false), // 是否作为 sku 活动配置组件
channelList: {
type: Array as PropType<ChannelVO[]>,
default: () => []
},
deviceId: {
type: Number,
default: undefined
}
})
const formData: Ref<Spu | undefined> = ref<Spu>() // 表单数据
const skuList = ref<Sku[]>([
{
price: 0, // 商品价格
marketPrice: 0, // 市场价
costPrice: 0, // 成本价
barCode: '', // 商品条码
picUrl: '', // 图片地址
stock: 0, // 库存
weight: 0, // 商品重量
volume: 0, // 商品体积
firstBrokeragePrice: 0, // 一级分销的佣金
secondBrokeragePrice: 0 // 二级分销的佣金
const selectedSkus = ref<Sku[]>([]) // 选中的货道列表
const checkedStatus = ref<Record<string, boolean>>({}) // 复选框选中状态
const isCheckAll = ref(false) // 全选状态
const isIndeterminate = ref(false) // 半选状态
const tableHeaders = ref<Array<{ prop: string; label: string }>>([]) // 表头列表
const skuList = ref<Sku[]>([]) // 批量操作时的 sku 列表
const batchAddDialogVisible = ref(false) // 批量新增对话框显示状态
const batchAddForm = ref<{ selectedChannels: string[] }>({
selectedChannels: []
}) // 批量新增表单数据
/** 获取行的唯一标识 */
const getRowKey = (row: Sku, index: number) => {
// 如果有 id,使用 id
if (row.id !== undefined && row.id !== null) {
return row.id
}
]) // 批量添加时的临时数据
// 如果有 rowKey,使用 rowKey
if ((row as any).rowKey) {
return (row as any).rowKey
}
// 否则使用索引作为唯一标识
return `row_${index}`
}
const defaultSku = (): Sku => ({
cabinetChannel: '', // 货道号
price: 0, // 商品价格
marketPrice: 0, // 市场价
costPrice: 0, // 成本价
barCode: '', // 商品条码
picUrl: '', // 图片地址
stock: 0, // 库存
weight: 0, // 商品重量
volume: 0, // 商品体积
firstBrokeragePrice: 0, // 一级分销的佣金
secondBrokeragePrice: 0 // 二级分销的佣金
})
/** 商品图预览 */
const imagePreview = (imgUrl: string) => {
@@ -339,38 +377,112 @@ const imagePreview = (imgUrl: string) => {
})
}
/** 批量添加 */
const batchAdd = () => {
validateProperty()
formData.value!.skus!.forEach((item) => {
copyValueToTarget(item, skuList.value[0])
/** 新增一行货道 */
const addSku = () => {
if (!formData.value!.skus) {
formData.value!.skus = []
}
const newSku = defaultSku()
// 给新 sku 添加唯一标识
;(newSku as any).rowKey = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
formData.value!.skus!.push(newSku)
}
/** 处理表格选中变化 */
const handleSelectionChange = (selection: Sku[]) => {
selectedSkus.value = selection
}
/** 处理单个复选框变化 */
const handleCheckOne = () => {
updateCheckStatus()
}
/** 处理全选/取消全选 */
const handleCheckAll = (checked: boolean) => {
if (!formData.value?.skus) return
formData.value.skus.forEach((sku, index) => {
const key = getRowKey(sku, index)
checkedStatus.value[key] = checked
})
updateCheckStatus()
}
/** 更新选中状态 */
const updateCheckStatus = () => {
if (!formData.value?.skus) {
isCheckAll.value = false
isIndeterminate.value = false
return
}
const total = formData.value.skus.length
const checkedCount = Object.values(checkedStatus.value).filter(Boolean).length
isCheckAll.value = checkedCount === total && total > 0
isIndeterminate.value = checkedCount > 0 && checkedCount < total
// 更新 selectedSkus
selectedSkus.value = formData.value.skus.filter((sku, index) => {
const key = getRowKey(sku, index)
return checkedStatus.value[key]
})
}
/** 校验商品属性属性值 */
const validateProperty = () => {
// 校验商品属性属性值是否为空,有一个为空都不给过
const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!'
for (const item of props.propertyList) {
if (!item.values || isEmpty(item.values)) {
message.warning(warningInfo)
throw new Error(warningInfo)
}
}
}
/** 监听数据变化,更新复选框状态 */
watch(
() => formData.value?.skus,
(skus) => {
if (!skus) return
// 初始化复选框状态
const newCheckedStatus: Record<string, boolean> = {}
skus.forEach((sku, index) => {
const key = getRowKey(sku, index)
newCheckedStatus[key] = checkedStatus.value[key] || false
})
checkedStatus.value = newCheckedStatus
updateCheckStatus()
},
{ deep: true, immediate: true }
)
/** 删除 sku */
const deleteSku = (row) => {
const index = formData.value!.skus!.findIndex(
// 直接把列表转成字符串比较
(sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
)
formData.value!.skus!.splice(index, 1)
if (!formData.value!.skus || formData.value!.skus.length <= 1) {
message.warning('至少需要保留一行货道数据')
return
}
const index = formData.value!.skus!.indexOf(row)
if (index > -1) {
formData.value!.skus!.splice(index, 1)
}
}
/** 批量删除选中的货道 */
const deleteSelectedSkus = () => {
if (!selectedSkus.value || selectedSkus.value.length === 0) {
message.warning('请先选择要删除的货道')
return
}
if (!formData.value!.skus) {
return
}
// 检查删除后是否至少保留一行
if (formData.value!.skus.length - selectedSkus.value.length < 1) {
message.warning('至少需要保留一行货道数据')
return
}
// 删除选中的货道
selectedSkus.value.forEach((selectedSku) => {
const index = formData.value!.skus!.indexOf(selectedSku)
if (index > -1) {
formData.value!.skus!.splice(index, 1)
}
})
// 清空选中状态
selectedSkus.value = []
}
const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表头
/**
* 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
*/
const validateSku = () => {
validateProperty()
let warningInfo = '请检查商品各行相关属性配置,'
let validate = true // 默认通过
for (const sku of formData.value!.skus!) {
@@ -407,13 +519,6 @@ const getValue = (obj, arg) => {
const emit = defineEmits<{
(e: 'selectionChange', value: Sku[]): void
}>()
/**
* 选择时触发
* @param Sku 传递过来的选中的 sku 是一个数组
*/
const handleSelectionChange = (val: Sku[]) => {
emit('selectionChange', val)
}
/**
* 将传进来的值赋值给 skuList
@@ -423,6 +528,23 @@ watch(
(data) => {
if (!data) return
formData.value = data
// 确保每个 sku 都有唯一的 rowKey
if (formData.value?.skus) {
formData.value.skus.forEach((sku, index) => {
if (!sku.id && !(sku as any).rowKey) {
// 给没有 id 的 sku 添加一个持久的唯一标识(使用索引)
;(sku as any).rowKey = `row_${index}_${Date.now()}`
}
})
// 初始化复选框状态
const newCheckedStatus: Record<string, boolean> = {}
formData.value.skus.forEach((sku, index) => {
const key = getRowKey(sku, index)
newCheckedStatus[key] = checkedStatus.value[key] || false
})
checkedStatus.value = newCheckedStatus
updateCheckStatus()
}
},
{
deep: true,
@@ -477,15 +599,10 @@ const generateTableData = (propertyList: any[]) => {
*/
const validateData = (propertyList: any[]) => {
const skuPropertyIds: number[] = []
formData.value!.skus!.forEach((sku) =>
sku.properties
?.map((property) => property.propertyId)
?.forEach((propertyId) => {
if (skuPropertyIds.indexOf(propertyId!) === -1) {
skuPropertyIds.push(propertyId!)
}
})
)
formData.value!.skus!.forEach((sku) => {
// 容器商品场景不再使用多属性组合,这里直接跳过
return sku
})
const propertyIds = propertyList.map((item) => item.id)
return skuPropertyIds.length === propertyIds.length
}
@@ -571,6 +688,246 @@ const activitySkuListRef = ref<InstanceType<typeof ElTable>>()
const getSkuTableRef = () => {
return activitySkuListRef.value
}
/** 可用货道列表(含当前已选货道,保证编辑时能回显) */
const availableChannels = computed(() => {
const list = props.channelList || []
const fromSkus = (formData.value?.skus || [])
.map((s) => s.cabinetChannel)
.filter(Boolean) as string[]
const inListSet = new Set(list.map((ch) => ch.cabinetChannel))
const extra = [...new Set(fromSkus.filter((ch) => !inListSet.has(ch)))].map(
(cabinetChannel) => ({ cabinetChannel, maxNum: undefined as number | undefined })
)
return [...list, ...extra]
})
/** 批量新增时可用的货道列表(排除已使用的) */
const availableChannelsForBatch = computed(() => {
const usedChannels = new Set(
(formData.value?.skus || [])
.map((sku) => sku.cabinetChannel)
.filter(Boolean)
)
return (props.channelList || []).filter(
(channel) => !usedChannels.has(channel.cabinetChannel)
)
})
/** 检查货道是否已被使用 */
const isChannelUsed = (cabinetChannel: string | undefined, currentRow: Sku) => {
if (!cabinetChannel) return false
return (formData.value?.skus || []).some(
(sku) => sku.cabinetChannel === cabinetChannel && sku !== currentRow
)
}
/** 根据选择的货道获取最大库存 */
const getMaxStock = (row: Sku) => {
if (!row.cabinetChannel) return undefined
const channel = availableChannels.value.find(
(ch) => ch.cabinetChannel === row.cabinetChannel
)
return channel?.maxNum
}
/** 货道变化时的处理 */
const handleChannelChange = (row: Sku) => {
if (!row.cabinetChannel) {
row.stock = 0
return
}
const channel = availableChannels.value.find(
(ch) => ch.cabinetChannel === row.cabinetChannel
)
if (channel && channel.maxNum !== undefined) {
// 货道的最大数默认等于库存
row.stock = channel.maxNum
}
}
/** 库存变化时的处理 */
const handleStockChange = (row: Sku) => {
const maxStock = getMaxStock(row)
if (maxStock !== undefined && row.stock > maxStock) {
row.stock = maxStock
message.warning(`库存不能超过货道最大数量 ${maxStock}`)
}
}
/** 打开批量新增对话框 */
const openBatchAddDialog = (channels: ChannelVO[]) => {
batchAddForm.value.selectedChannels = []
batchAddDialogVisible.value = true
}
/** 切换货道选择状态 */
const toggleChannel = (cabinetChannel: string, checked?: boolean) => {
const index = batchAddForm.value.selectedChannels.indexOf(cabinetChannel)
if (checked !== undefined) {
if (checked && index === -1) {
batchAddForm.value.selectedChannels.push(cabinetChannel)
} else if (!checked && index > -1) {
batchAddForm.value.selectedChannels.splice(index, 1)
}
} else {
// 点击卡片切换
if (index > -1) {
batchAddForm.value.selectedChannels.splice(index, 1)
} else {
batchAddForm.value.selectedChannels.push(cabinetChannel)
}
}
}
/** 全选/取消全选 */
const isAllSelected = computed(() => {
return availableChannelsForBatch.value.length > 0 &&
batchAddForm.value.selectedChannels.length === availableChannelsForBatch.value.length
})
const handleSelectAll = () => {
if (isAllSelected.value) {
batchAddForm.value.selectedChannels = []
} else {
batchAddForm.value.selectedChannels = availableChannelsForBatch.value.map(ch => ch.cabinetChannel).filter(Boolean) as string[]
}
}
/** 确认批量新增 */
const handleBatchAddConfirm = () => {
if (!batchAddForm.value.selectedChannels || batchAddForm.value.selectedChannels.length === 0) {
message.warning('请至少选择一个货道')
return
}
if (!formData.value?.skus) {
formData.value.skus = []
}
const selectedCount = batchAddForm.value.selectedChannels.length
// 为每个选中的货道创建 SKU
batchAddForm.value.selectedChannels.forEach((cabinetChannel) => {
const channel = availableChannels.value.find((ch) => ch.cabinetChannel === cabinetChannel)
if (channel) {
const newSku: Sku = {
cabinetChannel: channel.cabinetChannel,
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: channel.maxNum || 0, // 默认等于货道最大数
weight: 0,
volume: 0,
firstBrokeragePrice: 0,
secondBrokeragePrice: 0
}
;(newSku as any).rowKey = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
formData.value.skus.push(newSku)
}
})
batchAddDialogVisible.value = false
batchAddForm.value.selectedChannels = []
message.success(`成功添加 ${selectedCount} 个货道`)
}
// 暴露出生成 sku 方法,给添加属性成功时调用
defineExpose({ generateTableData, validateSku, getSkuTableRef })
defineExpose({ generateTableData, validateSku, getSkuTableRef, deleteSelectedSkus, openBatchAddDialog })
</script>
<style scoped lang="scss">
.batch-add-channels {
.batch-add-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-light);
.selected-info {
display: flex;
justify-content: space-between;
align-items: center;
.selected-count {
font-size: 14px;
color: var(--el-text-color-regular);
strong {
color: var(--el-color-primary);
font-size: 16px;
}
}
}
}
.channels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
max-height: 400px;
overflow-y: auto;
padding: 8px 0;
.channel-card {
border: 2px solid var(--el-border-color-light);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s;
background: var(--el-bg-color);
display: flex;
align-items: flex-start;
gap: 12px;
&:hover {
border-color: var(--el-color-primary-light-7);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&.selected {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.2);
}
.channel-info {
flex: 1;
.channel-number {
font-size: 15px;
margin-bottom: 8px;
color: var(--el-text-color-primary);
strong {
font-size: 18px;
color: var(--el-color-primary);
font-weight: 600;
}
}
.channel-max {
font-size: 13px;
color: var(--el-text-color-regular);
margin-bottom: 4px;
span {
color: var(--el-color-success);
font-weight: 500;
}
}
.channel-line {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
.empty-tip {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: var(--el-text-color-placeholder);
font-size: 14px;
}
}
}
</style>
@@ -1,5 +1,5 @@
import SkuList from './SkuList.vue'
import { Spu } from '@/api/mall/product/spu'
import type { Spu } from '@/api/mall/product/device-spu'
interface PropertyAndValues {
id: number
@@ -32,7 +32,7 @@
import { PropType } from 'vue'
import { copyValueToTarget } from '@/utils'
import { propTypes } from '@/utils/propTypes'
import type { Spu } from '@/api/mall/product/spu'
import type { Spu } from '@/api/mall/product/device-spu'
import * as ExpressTemplateApi from '@/api/mall/trade/delivery/expressTemplate'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { DeliveryTypeEnum } from '@/utils/constants'
@@ -8,7 +8,7 @@
</el-form>
</template>
<script lang="ts" setup>
import type { Spu } from '@/api/mall/product/spu'
import type { Spu } from '@/api/mall/product/device-spu'
import { Editor } from '@/components/Editor'
import { PropType } from 'vue'
import { propTypes } from '@/utils/propTypes'
@@ -24,44 +24,34 @@
placeholder="请选择商品分类"
/>
</el-form-item>
<el-form-item label="商品品牌" prop="brandId">
<el-select v-model="formData.brandId" class="w-80" placeholder="请选择商品品牌">
<el-option
v-for="item in brandList"
:key="item.id"
:label="item.name"
:value="item.id as number"
/>
</el-select>
</el-form-item>
<el-form-item label="商品关键字" prop="keyword">
<el-input v-model="formData.keyword" class="w-80!" placeholder="请输入商品关键字" />
</el-form-item>
<el-form-item label="商品简介" prop="introduction">
<el-input
v-model="formData.introduction"
:autosize="{ minRows: 2, maxRows: 2 }"
:clearable="true"
:show-word-limit="true"
class="w-80!"
maxlength="128"
placeholder="请输入商品简介"
type="textarea"
/>
</el-form-item>
<el-form-item label="商品封面图" prop="picUrl">
<UploadImg v-model="formData.picUrl" :disabled="isDetail" height="80px" />
</el-form-item>
<el-form-item label="商品轮播图" prop="sliderPicUrls">
<UploadImgs v-model="formData.sliderPicUrls!" :disabled="isDetail" />
</el-form-item>
<el-form-item label="设备号">
<el-input
v-model="formData.deviceId"
<el-form-item label="商品排序" prop="sort">
<el-input-number
v-model="formData.sort"
:min="0"
placeholder="请输入商品排序"
class="w-80!"
/>
</el-form-item>
<el-form-item label="赠送积分" prop="giveIntegral">
<el-input-number
v-model="formData.giveIntegral"
:min="0"
placeholder="请输入赠送积分"
class="w-80!"
/>
</el-form-item>
<el-form-item label="虚拟销量" prop="virtualSalesCount">
<el-input-number
v-model="formData.virtualSalesCount"
:min="0"
placeholder="请输入虚拟销量"
class="w-80!"
clearable
placeholder="请输入设备号"
type="number"
/>
</el-form-item>
</el-form>
@@ -71,11 +61,9 @@ import { PropType } from 'vue'
import { copyValueToTarget } from '@/utils'
import { propTypes } from '@/utils/propTypes'
import { defaultProps, handleTree } from '@/utils/tree'
import type { Spu } from '@/api/mall/product/spu'
import type { Spu } from '@/api/mall/product/device-spu'
import * as ProductCategoryApi from '@/api/mall/product/category'
import { CategoryVO } from '@/api/mall/product/category'
import * as ProductBrandApi from '@/api/mall/product/brand'
import { BrandVO } from '@/api/mall/product/brand'
defineOptions({ name: 'ProductSpuInfoForm' })
const props = defineProps({
@@ -92,21 +80,21 @@ const formRef = ref() // 表单 Ref
const formData = reactive<Spu>({
name: '', // 商品名称
categoryId: undefined, // 商品分类
keyword: '', // 关键字
picUrl: '', // 商品封面图
sliderPicUrls: [], // 商品轮播图
introduction: '', // 商品简介
brandId: undefined, // 商品品牌
deviceId: undefined // 设备号
deviceId: undefined, // 设备号
sort: 0, // 商品排序
giveIntegral: 0, // 赠送积分
virtualSalesCount: 0 // 虚拟销量
})
const rules = reactive({
name: [required],
categoryId: [required],
keyword: [required],
introduction: [required],
picUrl: [required],
sliderPicUrls: [required],
brandId: [required]
sort: [required],
giveIntegral: [required],
virtualSalesCount: [required]
})
/** 将传进来的值赋值给 formData */
@@ -140,13 +128,10 @@ const validate = async () => {
defineExpose({ validate })
/** 初始化 */
const brandList = ref<BrandVO[]>([]) // 商品品牌列表
const categoryList = ref<CategoryVO[]>([]) // 商品分类树
onMounted(async () => {
// 获得分类树
const data = await ProductCategoryApi.getCategoryList({})
categoryList.value = handleTree(data, 'id')
// 获取商品品牌列表
brandList.value = await ProductBrandApi.getSimpleBrandList()
})
</script>
@@ -28,7 +28,7 @@
</el-form>
</template>
<script lang="ts" setup>
import type { Spu } from '@/api/mall/product/spu'
import type { Spu } from '@/api/mall/product/device-spu'
import { PropType } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { copyValueToTarget } from '@/utils'
@@ -8,71 +8,72 @@
:rules="rules"
label-width="120px"
>
<el-form-item label="分销类型" prop="subCommissionType">
<el-radio-group
v-model="formData.subCommissionType"
<el-form-item label="设备号" prop="deviceId">
<el-select
v-model="formData.deviceId"
class="w-80"
@change="changeSubCommissionType"
placeholder="请选择设备"
clearable
filterable
>
<el-radio :value="false">默认设置</el-radio>
<el-radio :value="true" class="radio">单独设置</el-radio>
</el-radio-group>
<el-option
v-for="item in deviceOptions"
:key="item.id"
:label="`${item.name}(${item.deviceId}` + ')'"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="商品规格" prop="specType">
<el-radio-group v-model="formData.specType" class="w-80" @change="onChangeSpec">
<el-radio :value="false" class="radio">单规格</el-radio>
<el-radio :value="true">多规格</el-radio>
</el-radio-group>
</el-form-item>
<!-- 多规格添加-->
<el-form-item v-if="!formData.specType">
<el-form-item label="货道列表">
<div class="mb-10px">
<el-button
type="primary"
plain
@click="handleAddSku"
:disabled="isDetail || !formData.deviceId"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增货道
</el-button>
<el-button
type="success"
plain
@click="handleBatchAddChannels"
:disabled="isDetail || !formData.deviceId"
>
<Icon icon="ep:plus" class="mr-5px" /> 批量新增
</el-button>
<el-button
type="danger"
plain
@click="handleDeleteSelected"
:disabled="isDetail || !formData.deviceId"
>
<Icon icon="ep:delete" class="mr-5px" /> 删除选中
</el-button>
</div>
<SkuList
ref="skuListRef"
:prop-form-data="formData"
:property-list="propertyList"
:property-list="[]"
:rule-config="ruleConfig"
/>
</el-form-item>
<el-form-item v-if="formData.specType" label="商品属性">
<el-button class="mb-10px mr-15px" @click="attributesAddFormRef.open">添加属性</el-button>
<ProductAttributes
:is-detail="isDetail"
:property-list="propertyList"
@success="generateSkus"
:channel-list="channelList"
:device-id="formData.deviceId"
/>
</el-form-item>
<template v-if="formData.specType && propertyList.length > 0">
<el-form-item v-if="!isDetail" label="批量设置">
<SkuList :is-batch="true" :prop-form-data="formData" :property-list="propertyList" />
</el-form-item>
<el-form-item label="规格列表">
<SkuList
ref="skuListRef"
:is-detail="isDetail"
:prop-form-data="formData"
:property-list="propertyList"
:rule-config="ruleConfig"
/>
</el-form-item>
</template>
</el-form>
<!-- 商品属性添加 Form 表单 -->
<ProductPropertyAddForm ref="attributesAddFormRef" :propertyList="propertyList" />
<!-- 不再使用商品属性添加 Form 表单 -->
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { copyValueToTarget } from '@/utils'
import { propTypes } from '@/utils/propTypes'
import {
getPropertyList,
PropertyAndValues,
RuleConfig,
SkuList
} from '@/views/mall/product/spu/components/index'
import ProductAttributes from './ProductAttributes.vue'
import ProductPropertyAddForm from './ProductPropertyAddForm.vue'
import type { Spu } from '@/api/mall/product/spu'
import { RuleConfig } from '@/views/mall/product/spu/components/index'
import { SkuList } from '@/views/mall/product/containergoods/components/index'
import type { Spu, Sku } from '@/api/mall/product/device-spu'
import { DeviceApi, type DeviceVO } from '@/api/product/device'
import { ChannelApi, type ChannelVO } from '@/api/mall/product/channel'
defineOptions({ name: 'ProductSpuSkuForm' })
@@ -110,36 +111,95 @@ const props = defineProps({
},
isDetail: propTypes.bool.def(false) // 是否作为详情组件
})
const attributesAddFormRef = ref() // 添加商品属性表单
const formRef = ref() // 表单 Ref
const propertyList = ref<PropertyAndValues[]>([]) // 商品属性列表
const skuListRef = ref() // 商品属性列表 Ref
const formData = reactive<Spu>({
specType: false, // 商品规格
subCommissionType: false, // 分销类型
deviceId: undefined, // 设备号(下拉选择)
subCommissionType: false, // 分销类型(容器商品不展示配置,固定为默认)
skus: []
})
const rules = reactive({
specType: [required],
subCommissionType: [required]
deviceId: [required]
})
/** 将传进来的值赋值给 formData */
watch(
() => props.propFormData,
(data) => {
async (data) => {
if (!data) {
return
}
isInitializing.value = true
const oldDeviceId = formData.deviceId
copyValueToTarget(formData, data)
// 将 SKU 的属性,整理成 PropertyAndValues 数组
propertyList.value = getPropertyList(data)
// 如果设备号发生变化或首次加载时有设备号,需要加载货道列表
if (formData.deviceId && formData.deviceId !== oldDeviceId) {
await loadChannelsByDevice(formData.deviceId)
}
// 确保至少有一行货道数据
if (!formData.skus || formData.skus.length === 0) {
const defaultSku = createDefaultSku()
// 给默认 sku 添加唯一标识
;(defaultSku as any).rowKey = `row_0_${Date.now()}`
formData.skus = [defaultSku]
} else {
// 确保每个 sku 都有唯一标识
formData.skus.forEach((sku, index) => {
if (!sku.id && !(sku as any).rowKey) {
;(sku as any).rowKey = `row_${index}_${Date.now()}`
}
})
}
isInitializing.value = false
},
{
immediate: true
}
)
/** 创建默认的货道数据 */
const createDefaultSku = (): Sku => ({
cabinetChannel: '', // 货道号
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: 0,
weight: 0,
volume: 0,
firstBrokeragePrice: 0,
secondBrokeragePrice: 0
})
/** 新增货道 */
const handleAddSku = () => {
if (!formData.deviceId) {
message.warning('请先选择设备号')
return
}
if (!formData.skus) {
formData.skus = []
}
const newSku = createDefaultSku()
// 给新 sku 添加唯一标识,用于表格多选
;(newSku as any).rowKey = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
formData.skus.push(newSku)
}
/** 删除选中的货道 */
const handleDeleteSelected = () => {
if (!formData.deviceId) {
message.warning('请先选择设备号')
return
}
if (skuListRef.value) {
skuListRef.value.deleteSelectedSkus()
}
}
/** 表单校验 */
const emit = defineEmits(['update:activeName'])
const validate = async () => {
@@ -158,38 +218,92 @@ const validate = async () => {
}
defineExpose({ validate })
/** 分销类型 */
const changeSubCommissionType = () => {
// 默认为零,类型切换后也要重置为零
for (const item of formData.skus!) {
item.firstBrokeragePrice = 0
item.secondBrokeragePrice = 0
/** 设备下拉数据 */
const deviceOptions = ref<DeviceVO[]>([])
/** 当前设备的货道列表 */
const channelList = ref<ChannelVO[]>([])
/** 加载设备货道列表 */
const loadChannelsByDevice = async (deviceId: number | undefined) => {
if (!deviceId) {
channelList.value = []
return
}
try {
// 分页加载所有货道
let allChannels: ChannelVO[] = []
let pageNo = 1
const pageSize = 100 // 最大值为 100
let hasMore = true
while (hasMore) {
const data = await ChannelApi.getChannelPage({
pageNo,
pageSize,
deviceId: deviceId
})
if (data.list && data.list.length > 0) {
allChannels = allChannels.concat(data.list)
// 如果返回的数据少于 pageSize,说明已经是最后一页
if (data.list.length < pageSize) {
hasMore = false
} else {
pageNo++
}
} else {
hasMore = false
}
}
channelList.value = allChannels
} catch (e) {
console.error('加载货道列表失败', e)
channelList.value = []
}
}
/** 选择规格 */
const onChangeSpec = () => {
// 重置商品属性列表
propertyList.value = []
// 重置sku列表
formData.skus = [
{
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: 0,
weight: 0,
volume: 0,
firstBrokeragePrice: 0,
secondBrokeragePrice: 0
/** 监听设备号变化(用户手动切换时) */
const isInitializing = ref(false) // 标记是否正在初始化数据
watch(
() => formData.deviceId,
async (deviceId, oldDeviceId) => {
// 如果正在初始化,不执行清空操作
if (isInitializing.value) {
return
}
]
await loadChannelsByDevice(deviceId)
// 只有在用户手动切换设备时才清空货道列表
if (deviceId && oldDeviceId && deviceId !== oldDeviceId) {
formData.skus = []
}
},
{ immediate: false }
)
/** 批量新增货道 */
const handleBatchAddChannels = () => {
if (!formData.deviceId) {
message.warning('请先选择设备号')
return
}
if (channelList.value.length === 0) {
message.warning('该设备暂无可用货道')
return
}
// 打开批量新增对话框
if (skuListRef.value) {
skuListRef.value.openBatchAddDialog(channelList.value)
}
}
/** 调用 SkuList generateTableData 方法*/
const generateSkus = (propertyList: any[]) => {
skuListRef.value.generateTableData(propertyList)
}
onMounted(async () => {
try {
deviceOptions.value = await DeviceApi.getDeviceListSimpleByUser()
// 如果已有设备ID,加载货道列表
if (formData.deviceId) {
await loadChannelsByDevice(formData.deviceId)
}
} catch (e) {
console.error('加载设备列表失败', e)
}
})
</script>
@@ -9,7 +9,7 @@
:propFormData="formData"
/>
</el-tab-pane>
<el-tab-pane label="价格库存" name="sku">
<el-tab-pane label="货道上架" name="sku">
<SkuForm
ref="skuRef"
v-model:activeName="activeName"
@@ -17,30 +17,6 @@
:propFormData="formData"
/>
</el-tab-pane>
<el-tab-pane label="物流设置" name="delivery">
<DeliveryForm
ref="deliveryRef"
v-model:activeName="activeName"
:is-detail="isDetail"
:propFormData="formData"
/>
</el-tab-pane>
<el-tab-pane label="商品详情" name="description">
<DescriptionForm
ref="descriptionRef"
v-model:activeName="activeName"
:is-detail="isDetail"
:propFormData="formData"
/>
</el-tab-pane>
<el-tab-pane label="其它设置" name="other">
<OtherForm
ref="otherRef"
v-model:activeName="activeName"
:is-detail="isDetail"
:propFormData="formData"
/>
</el-tab-pane>
</el-tabs>
<el-form>
<el-form-item style="float: right">
@@ -55,12 +31,9 @@
<script lang="ts" setup>
import { cloneDeep } from 'lodash-es'
import { useTagsViewStore } from '@/store/modules/tagsView'
import * as ProductSpuApi from '@/api/mall/product/spu'
import { DeviceSpuApi, type Spu } from '@/api/mall/product/device-spu'
import InfoForm from './InfoForm.vue'
import DescriptionForm from './DescriptionForm.vue'
import OtherForm from './OtherForm.vue'
import SkuForm from './SkuForm.vue'
import DeliveryForm from './DeliveryForm.vue'
import { convertToInteger, floatToFixed2, formatToFraction } from '@/utils'
defineOptions({ name: 'ProductPickupAdd' })
@@ -76,19 +49,17 @@ const activeName = ref('info') // Tag 激活的窗口
const isDetail = ref(false) // 是否查看详情
const infoRef = ref() // 商品信息 Ref
const skuRef = ref() // 商品规格 Ref
const deliveryRef = ref() // 物流设置 Ref
const descriptionRef = ref() // 商品详情 Ref
const otherRef = ref() // 其他设置 Ref
// SPU 表单数据
const formData = ref<ProductSpuApi.Spu>({
const formData = ref<Spu>({
name: '', // 商品名称
categoryId: undefined, // 商品分类
keyword: '', // 关键字
picUrl: '', // 商品封面图
sliderPicUrls: [], // 商品轮播图
introduction: '', // 商品简介
deliveryTypes: [], // 配送方式数组
deliveryTemplateId: undefined, // 运费模版
// 配送方式固定为自提柜(3),不再在表单中配置
deliveryTypes: [3], // 配送方式数组
deliveryTemplateId: undefined, // 运费模版(自提柜无需模板)
brandId: undefined, // 商品品牌
specType: false, // 商品规格
subCommissionType: false, // 分销类型
@@ -122,7 +93,7 @@ const getDetail = async () => {
if (id) {
formLoading.value = true
try {
const res = (await ProductSpuApi.getSpu(id)) as ProductSpuApi.Spu
const res = (await DeviceSpuApi.getDeviceSpu(id)) as Spu
res.skus?.forEach((item) => {
if (isDetail.value) {
item.price = floatToFixed2(item.price)
@@ -154,11 +125,11 @@ const submitForm = async () => {
// 校验各表单
await unref(infoRef)?.validate()
await unref(skuRef)?.validate()
await unref(deliveryRef)?.validate()
await unref(descriptionRef)?.validate()
await unref(otherRef)?.validate()
// 深拷贝一份, 这样最终 server 端不满足,不需要影响原始数据
const deepCopyFormData = cloneDeep(unref(formData.value)) as ProductSpuApi.Spu
const deepCopyFormData = cloneDeep(unref(formData.value)) as Spu
// 物流设置:静默固定为自提柜
deepCopyFormData.deliveryTypes = [3]
deepCopyFormData.deliveryTemplateId = undefined
deepCopyFormData.skus!.forEach((item) => {
// 给sku name赋值
item.name = deepCopyFormData.name
@@ -181,14 +152,14 @@ const submitForm = async () => {
})
deepCopyFormData.sliderPicUrls = newSliderPicUrls
// 校验都通过后提交表单
const data = deepCopyFormData as ProductSpuApi.Spu;
const data = deepCopyFormData as Spu;
data.types = 1;
const id = params.id as unknown as number
if (!id) {
await ProductSpuApi.createSpu(data)
await DeviceSpuApi.createDeviceSpu(data)
message.success(t('common.createSuccess'))
} else {
await ProductSpuApi.updateSpu(data)
await DeviceSpuApi.updateDeviceSpu(data)
message.success(t('common.updateSuccess'))
}
close()
+61 -28
View File
@@ -29,6 +29,22 @@
placeholder="请选择商品分类"
/>
</el-form-item>
<el-form-item label="设备号" prop="deviceId">
<el-select
v-model="queryParams.deviceId"
class="!w-240px"
clearable
filterable
placeholder="请选择设备"
>
<el-option
v-for="item in deviceOptions"
:key="item.id"
:label="`${item.name}(${item.deviceId}` + ')'"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
@@ -214,7 +230,7 @@
<template #default="{ row }"> ¥ {{ fenToYuan(row.price) }}</template>
</el-table-column>
<el-table-column align="center" label="排序" min-width="80" prop="sort" />
<el-table-column label="创建人" min-width="100" prop="username" />
<el-table-column label="创建人" min-width="100" prop="creatorName" />
<el-table-column label="商品编号" min-width="100" prop="id" />
<el-table-column label="销量" min-width="80" prop="salesCount" />
<el-table-column
@@ -300,8 +316,9 @@ import { defaultProps, handleTree, treeToString } from '@/utils/tree'
import { ProductSpuStatusEnum } from '@/utils/constants'
import { fenToYuan } from '@/utils'
import download from '@/utils/download'
import * as ProductSpuApi from '@/api/mall/product/spu'
import { DeviceSpuApi, type Spu } from '@/api/mall/product/device-spu'
import * as ProductCategoryApi from '@/api/mall/product/category'
import { DeviceApi, type DeviceVO } from '@/api/product/device'
defineOptions({ name: 'ProductPickup' })
@@ -313,8 +330,8 @@ const { push } = useRouter() // 路由跳转
const loading = ref(false) // 列表的加载中
const exportLoading = ref(false) // 导出的加载中
const total = ref(0) // 列表的总页数
const list = ref<ProductSpuApi.Spu[]>([]) // 列表的数据
const selectedRows = ref<ProductSpuApi.Spu[]>([]) // 选中的商品
const list = ref<Spu[]>([]) // 列表的数据
const selectedRows = ref<Spu[]>([]) // 选中的商品
const copyLoading = ref(false) // 复制操作的加载中
const channelDialogVisible = ref(false) // 修改货道号弹窗显示状态
const channelLoading = ref(false) // 修改货道号加载中
@@ -358,10 +375,14 @@ const queryParams = ref({
name: '',
categoryId: undefined,
createTime: undefined,
types:1
deviceId: undefined as number | undefined,
types: 1,
}) // 查询参数
const queryFormRef = ref() // 搜索的表单Ref
/** 设备下拉选项 */
const deviceOptions = ref<DeviceVO[]>([])
const normalizeNumericString = (value?: number | string | null) => {
if (value === undefined || value === null) {
return ''
@@ -369,7 +390,7 @@ const normalizeNumericString = (value?: number | string | null) => {
return String(value)
}
const getDeviceIdValue = (spu: ProductSpuApi.Spu) => {
const getDeviceIdValue = (spu: Spu) => {
if (spu.deviceId !== undefined && spu.deviceId !== null) {
return normalizeNumericString(spu.deviceId)
}
@@ -391,7 +412,7 @@ const getCabinetChannelValue = (spu: ProductSpuApi.Spu) => {
return sortedChannels[0]
}
const compareByDeviceAndCabinet = (a: ProductSpuApi.Spu, b: ProductSpuApi.Spu) => {
const compareByDeviceAndCabinet = (a: Spu, b: Spu) => {
const deviceA = getDeviceIdValue(a)
const deviceB = getDeviceIdValue(b)
const deviceCompare = deviceA.localeCompare(deviceB, undefined, { numeric: true, sensitivity: 'base' })
@@ -411,13 +432,16 @@ const compareByDeviceAndCabinet = (a: ProductSpuApi.Spu, b: ProductSpuApi.Spu) =
const getList = async () => {
loading.value = true
try {
const data = await ProductSpuApi.getSpuPage(queryParams.value)
const data = await DeviceSpuApi.getDeviceSpuPage(queryParams.value)
const sortedList = [...data.list].sort(compareByDeviceAndCabinet)
// 过滤掉没有设备号的商品
const filteredList = sortedList.filter((item) => !!getDeviceIdValue(item))
list.value = filteredList
total.value = filteredList.length
console.log('查询列表',data)
// 使用后端返回的总数,而不是过滤后的列表长度
// 注意:如果前端过滤导致当前页数据减少,分页总数可能不准确
// 理想情况下,过滤逻辑应该在后端完成
total.value = data.total || 0
console.log('查询列表', data)
const cabinetChannels = filteredList
.flatMap((item) => item.skus ?? [])
.map((sku) => sku.cabinetChannel)
@@ -440,10 +464,11 @@ const handleTabClick = (tab: TabsPaneContext) => {
/** 获得每个 Tab 的数量 */
const getTabsCount = async () => {
const res = await ProductSpuApi.getTabsCount(1);
for (let objName in res) {
tabsData.value[Number(objName)].count = res[objName]
}
// TODO: 新 API 中暂无此接口,暂时注释
// const res = await DeviceSpuApi.getTabsCount(1);
// for (let objName in res) {
// tabsData.value[Number(objName)].count = res[objName]
// }
}
/** 添加到仓库 / 回收站的状态 */
@@ -452,8 +477,8 @@ const handleStatus02Change = async (row: any, newStatus: number) => {
// 二次确认
const text = newStatus === ProductSpuStatusEnum.RECYCLE.status ? '加入到回收站' : '恢复到仓库'
await message.confirm(`确认要"${row.name}"${text}吗?`)
// 发起修改
await ProductSpuApi.updateStatus({ id: row.id, status: newStatus })
// TODO: 新 API 中暂无此接口,暂时注释
// await DeviceSpuApi.updateStatus({ id: row.id, status: newStatus })
message.success(text + '成功')
// 刷新 tabs 数据
await getTabsCount()
@@ -468,8 +493,8 @@ const handleStatusChange = async (row: any) => {
// 二次确认
const text = row.status ? '上架' : '下架'
await message.confirm(`确认要${text}"${row.name}"吗?`)
// 发起修改
await ProductSpuApi.updateStatus({ id: row.id, status: row.status })
// TODO: 新 API 中暂无此接口,暂时注释
// await DeviceSpuApi.updateStatus({ id: row.id, status: row.status })
message.success(text + '成功')
// 刷新 tabs 数据
await getTabsCount()
@@ -490,7 +515,7 @@ const handleDelete = async (id: number) => {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await ProductSpuApi.deleteSpu(id)
await DeviceSpuApi.deleteDeviceSpu(id)
message.success(t('common.delSuccess'))
// 刷新tabs数据
await getTabsCount()
@@ -529,7 +554,7 @@ const openForm = (id?: number) => {
}
/** 处理表格选择变化 */
const handleSelectionChange = (selection: ProductSpuApi.Spu[]) => {
const handleSelectionChange = (selection: Spu[]) => {
selectedRows.value = selection
}
@@ -548,11 +573,11 @@ const handleCopy = async () => {
for (const row of selectedRows.value) {
try {
// 获取商品详情
const productDetail = await ProductSpuApi.getSpu(row.id!)
const productDetail = await DeviceSpuApi.getDeviceSpu(row.id!)
// 准备新商品数据,复制所有内容,但不传递商品编号(id)和SKU的id,后端会自动生成
const { id, createTime, skus, ...spuData } = productDetail
const newProduct: ProductSpuApi.Spu = {
const newProduct: Spu = {
...spuData, // 复制所有SPU字段(除了id和createTime)
// 复制SKU时不传递id和spuId,其他所有数据保持原样(包括name字段)
skus: skus?.map(sku => {
@@ -568,7 +593,7 @@ const handleCopy = async () => {
console.log('复制新增的商品数据:', newProduct)
// 创建新商品
await ProductSpuApi.createSpu(newProduct)
await DeviceSpuApi.createDeviceSpu(newProduct)
} catch (error) {
console.error(`复制商品 ${row.name} 失败:`, error)
message.error(`复制商品 "${row.name}" 失败`)
@@ -618,7 +643,7 @@ const handleConfirmModifyChannel = async () => {
for (const row of selectedRows.value) {
try {
// 获取商品详情
const productDetail = await ProductSpuApi.getSpu(row.id!)
const productDetail = await DeviceSpuApi.getDeviceSpu(row.id!)
// 更新 skus 中的货道号,name 使用 spu 的 name,其他参数保持不变
const updatedSkus = productDetail.skus?.map(sku => ({
@@ -628,13 +653,13 @@ const handleConfirmModifyChannel = async () => {
})) || []
// 准备更新数据,只修改 skus 的货道号,其他参数保持原样
const updateData: ProductSpuApi.Spu = {
const updateData: Spu = {
...productDetail,
skus: updatedSkus
}
// 调用更新接口
await ProductSpuApi.updateSpu(updateData)
await DeviceSpuApi.updateDeviceSpu(updateData)
} catch (error) {
console.error(`修改商品 ${row.name} 货道号失败:`, error)
message.error(`修改商品 "${row.name}" 货道号失败`)
@@ -666,8 +691,10 @@ const handleExport = async () => {
await message.exportConfirm()
// 发起导出
exportLoading.value = true
const data = await ProductSpuApi.exportSpu(queryParams.value)
download.excel(data, '商品列表.xls')
// TODO: 新 API 中暂无此接口,暂时注释
// const data = await DeviceSpuApi.exportDeviceSpu(queryParams.value)
// download.excel(data, '商品列表.xls')
message.warning('导出功能暂未实现')
} catch {
} finally {
exportLoading.value = false
@@ -695,6 +722,12 @@ onMounted(async () => {
queryParams.value.categoryId = id as any
}
}
// 获取设备下拉数据
try {
deviceOptions.value = await DeviceApi.getDeviceListSimpleByUser()
} catch (e) {
console.error('加载设备列表失败', e)
}
// 获得商品信息
await getTabsCount()
await getList()
@@ -0,0 +1,274 @@
<template>
<Dialog :title="'批量新增货道'" v-model="dialogVisible" width="80%" :close-on-click-modal="false">
<div v-loading="formLoading">
<el-alert
type="info"
:closable="false"
style="margin-bottom: 20px"
>
<template #default>
<div>设备:{{ deviceInfo?.name }} ({{ deviceInfo?.deviceId }})</div>
<div style="margin-top: 8px;">提示:可以添加多个货道,每个货道需要填写货道号、最大数量、行号等信息</div>
</template>
</el-alert>
<el-form ref="formRef" :model="formData" label-width="100px">
<el-form-item label="货道列表">
<div class="channel-list-container">
<div
v-for="(channel, index) in formData.channels"
:key="index"
class="channel-item-row"
>
<div class="item-header">
<span class="item-number">货道 {{ index + 1 }}</span>
<el-button
link
type="danger"
size="small"
@click="removeChannel(index)"
:disabled="formData.channels.length <= 1"
>
<Icon icon="ep:delete" /> 删除
</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6">
<el-form-item
:prop="`channels.${index}.name`"
:rules="[{ required: false, message: '请输入名称', trigger: 'blur' }]"
label="名称"
>
<el-input v-model="channel.name" placeholder="货道名称(可选)" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item
:prop="`channels.${index}.cabinetChannel`"
:rules="[{ required: true, message: '请输入货道号', trigger: 'blur' }]"
label="货道号"
>
<el-input v-model="channel.cabinetChannel" placeholder="如:A01" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item
:prop="`channels.${index}.maxNum`"
:rules="[
{ required: true, message: '请输入最大数量', trigger: 'blur' },
{ type: 'number', min: 1, message: '数量必须大于0', trigger: 'blur' }
]"
label="最大数量"
>
<el-input-number
v-model="channel.maxNum"
:min="1"
:max="999"
placeholder="最大货物数"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item
:prop="`channels.${index}.channelLine`"
:rules="[
{ required: true, message: '请输入行号', trigger: 'blur' },
{ type: 'number', min: 1, message: '行号必须大于0', trigger: 'blur' }
]"
label="行号"
>
<el-input-number
v-model="channel.channelLine"
:min="1"
:max="999"
placeholder="货道行号"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<el-button
type="primary"
plain
@click="addChannel"
style="width: 100%; margin-top: 16px"
>
<Icon icon="ep:plus" /> 添加货道
</el-button>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="submitForm" :disabled="formLoading" :loading="formLoading">
批量创建
</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ChannelApi, ChannelVO } from '@/api/mall/product/channel'
import { DeviceVO } from '@/api/product/device'
defineOptions({ name: 'ChannelBatchForm' })
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const formLoading = ref(false)
const deviceInfo = ref<DeviceVO | null>(null)
const formRef = ref()
const formData = ref({
channels: [
{
name: undefined,
cabinetChannel: undefined,
maxNum: undefined,
channelLine: undefined
}
] as Partial<ChannelVO>[]
})
/** 打开弹窗 */
const open = (device: DeviceVO) => {
dialogVisible.value = true
deviceInfo.value = device
resetForm()
}
defineExpose({ open })
/** 添加货道 */
const addChannel = () => {
formData.value.channels.push({
name: undefined,
cabinetChannel: undefined,
maxNum: undefined,
channelLine: undefined
})
}
/** 删除货道 */
const removeChannel = (index: number) => {
if (formData.value.channels.length > 1) {
formData.value.channels.splice(index, 1)
}
}
/** 提交表单 */
const emit = defineEmits(['success'])
const submitForm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (!deviceInfo.value) {
message.error('设备信息不存在')
return
}
// 构建提交数据
const channels = formData.value.channels.map(channel => ({
name: channel.name,
deviceId: deviceInfo.value!.id,
deviceImei: deviceInfo.value!.deviceId,
cabinetChannel: channel.cabinetChannel,
maxNum: channel.maxNum,
channelLine: channel.channelLine
}))
formLoading.value = true
const result = await ChannelApi.batchCreateChannel({ channels })
// 显示结果
if (result?.data) {
const { successIds = [], failureInfos = {}, successCount = 0, failureCount = 0 } = result.data
if (failureCount > 0) {
// 有失败的情况,显示详细信息
const failureDetails = Object.entries(failureInfos)
.map(([name, reason]) => `"${name}": ${reason}`)
.join('\n')
message.warning(
`批量创建完成!\n成功:${successCount} 个\n失败:${failureCount} 个\n\n失败详情:\n${failureDetails}`,
{ duration: 8000 }
)
} else {
// 全部成功
message.success(`批量创建成功!共创建 ${successCount} 个货道`)
}
} else {
message.success('批量创建成功')
}
dialogVisible.value = false
emit('success')
} catch (error: any) {
if (error?.message && !error.message.includes('validate')) {
message.error(error.message || '批量创建失败')
}
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
channels: [
{
name: undefined,
cabinetChannel: undefined,
maxNum: undefined,
channelLine: undefined
}
]
}
formRef.value?.resetFields()
}
</script>
<style scoped lang="scss">
.channel-list-container {
max-height: 500px;
overflow-y: auto;
padding: 8px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fafafa;
}
.channel-item-row {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
border: 1px solid #e4e7ed;
&:last-child {
margin-bottom: 0;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px dashed #e4e7ed;
.item-number {
font-weight: 600;
color: #303133;
font-size: 14px;
}
}
}
</style>
@@ -10,8 +10,14 @@
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="设备IMEI" prop="deviceId">
<el-input v-model="formData.deviceId" placeholder="请输入设备IMEI" />
<el-form-item label="设备" prop="deviceId">
<el-input
v-if="deviceInfo"
:value="`${deviceInfo.name} (${deviceInfo.deviceId})`"
disabled
placeholder="设备信息"
/>
<el-input v-else v-model="formData.deviceId" placeholder="请输入设备IMEI" />
</el-form-item>
<el-form-item label="货道号" prop="cabinetChannel">
<el-input v-model="formData.cabinetChannel" placeholder="请输入货道号" />
@@ -31,6 +37,7 @@
</template>
<script setup lang="ts">
import { ChannelApi, ChannelVO } from '@/api/mall/product/channel'
import { DeviceVO } from '@/api/product/device'
// import { ChannelApi, ChannelVO } from '@/api/product/channel'
@@ -44,30 +51,46 @@ const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formType = ref('') // 表单的类型:create - 新增;update - 修改
const deviceInfo = ref<DeviceVO | null>(null) // 设备信息
const formData = ref({
id: undefined,
name: undefined,
deviceId: undefined,
deviceId: undefined as number | string | undefined,
deviceImei: undefined as string | undefined,
cabinetChannel: undefined,
maxNum: undefined,
channelLine: undefined
})
const formRules = reactive({
deviceId: [{ required: true, message: '设备IMEI不能为空', trigger: 'blur' }]
deviceId: [{ required: true, message: '设备不能为空', trigger: 'blur' }],
cabinetChannel: [{ required: true, message: '货道号不能为空', trigger: 'blur' }],
maxNum: [{ required: true, message: '最大数量不能为空', trigger: 'blur' }],
channelLine: [{ required: true, message: '货道行号不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
const open = async (type: string, id?: number, device?: DeviceVO) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
dialogTitle.value = type === 'create' ? '新增货道' : '修改货道'
formType.value = type
deviceInfo.value = device || null
resetForm()
// 如果有设备信息,自动填充
if (device) {
formData.value.deviceId = device.id
formData.value.deviceImei = device.deviceId
}
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await ChannelApi.getChannel(id)
const channelData = await ChannelApi.getChannel(id)
formData.value = {
...channelData,
deviceId: device?.id || channelData.deviceId,
deviceImei: device?.deviceId || channelData.deviceId
}
} finally {
formLoading.value = false
}
@@ -83,12 +106,32 @@ const submitForm = async () => {
// 提交请求
formLoading.value = true
try {
const data = formData.value as unknown as ChannelVO
// 构建提交数据,优先使用deviceId(number),如果没有则使用deviceImei
const submitData: any = {
...formData.value
}
// 如果有设备ID(number类型),优先使用
if (typeof submitData.deviceId === 'number') {
// 保留deviceId
} else if (submitData.deviceImei) {
// 如果没有deviceId但有deviceImei,使用deviceImei
submitData.deviceImei = submitData.deviceImei
delete submitData.deviceId
}
// 清理undefined字段
Object.keys(submitData).forEach(key => {
if (submitData[key] === undefined) {
delete submitData[key]
}
})
if (formType.value === 'create') {
await ChannelApi.createChannel(data)
await ChannelApi.createChannel(submitData)
message.success(t('common.createSuccess'))
} else {
await ChannelApi.updateChannel(data)
await ChannelApi.updateChannel(submitData)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
@@ -104,7 +147,8 @@ const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
deviceId: undefined,
deviceId: deviceInfo.value?.id || undefined,
deviceImei: deviceInfo.value?.deviceId || undefined,
cabinetChannel: undefined,
maxNum: undefined,
channelLine: undefined
@@ -0,0 +1,419 @@
<template>
<Dialog v-model="dialogVisible" :title="`货道管理 - ${deviceInfo?.name || ''}`" width="90%" :close-on-click-modal="false">
<div v-loading="loading" class="channel-manage-container">
<!-- 设备信息栏 -->
<div class="device-info-bar">
<div class="device-info-item">
<span class="label">设备名称:</span>
<span class="value">{{ deviceInfo?.name || '-' }}</span>
</div>
<div class="device-info-item">
<span class="label">设备IMEI:</span>
<span class="value">{{ deviceInfo?.deviceId || '-' }}</span>
</div>
<div class="device-info-item">
<span class="label">货道总数:</span>
<span class="value highlight">{{ channelList.length }}</span>
</div>
<div class="device-info-actions">
<el-button type="primary" @click="handleAddChannel" v-hasPermi="['product:channel:create']">
<Icon icon="ep:plus" /> 新增货道
</el-button>
<el-button type="warning" @click="handleBatchAddChannel" v-hasPermi="['product:channel:create']">
<Icon icon="ep:plus" /> 批量新增
</el-button>
<el-button type="success" @click="handleRefresh">
<Icon icon="ep:refresh" /> 刷新
</el-button>
</div>
</div>
<!-- 售货机可视化区域 -->
<div class="vending-machine-view">
<div class="machine-header">
<div class="machine-title">{{ deviceInfo?.name || '售货机' }}</div>
</div>
<!-- 按行号分组的货道展示 -->
<div v-if="groupedChannels.size > 0" class="channels-by-line">
<div
v-for="[lineNumber, channels] in groupedChannels"
:key="lineNumber"
class="channel-line-group"
>
<div class="line-header">
<span class="line-label">第 {{ lineNumber }} 行</span>
<span class="line-count">共 {{ channels.length }} 个货道</span>
</div>
<div class="channels-grid">
<div
v-for="channel in channels"
:key="channel.id"
class="channel-item"
:class="{ 'has-product': channel.maxNum > 0 }"
@click="handleEditChannel(channel)"
>
<div class="channel-number">{{ channel.cabinetChannel }}</div>
<div class="channel-name">{{ channel.name || `货道${channel.cabinetChannel}` }}</div>
<div class="channel-info">
<div class="info-row">
<span class="info-label">行号:</span>
<span class="info-value">{{ channel.channelLine }}</span>
</div>
<div class="info-row">
<span class="info-label">容量:</span>
<span class="info-value">{{ channel.maxNum }}</span>
</div>
</div>
<div class="channel-actions">
<el-button
link
type="primary"
size="small"
@click.stop="handleEditChannel(channel)"
v-hasPermi="['product:channel:update']"
>
编辑
</el-button>
<el-button
link
type="success"
size="small"
@click.stop="handleTestChannel(channel)"
v-hasPermi="['product:channel:test']"
>
测试
</el-button>
<el-button
link
type="danger"
size="small"
@click.stop="handleDeleteChannel(channel)"
v-hasPermi="['product:channel:delete']"
>
删除
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty v-else description="暂无货道,请点击上方按钮新增货道" />
</div>
</div>
<!-- 货道表单对话框 -->
<ChannelForm ref="channelFormRef" @success="handleRefresh" />
<!-- 批量新增货道对话框 -->
<ChannelBatchForm ref="batchFormRef" @success="handleRefresh" />
</Dialog>
</template>
<script lang="ts" setup>
import { DeviceVO } from '@/api/product/device'
import { ChannelApi, ChannelVO } from '@/api/mall/product/channel'
import ChannelForm from './ChannelForm.vue'
import ChannelBatchForm from './ChannelBatchForm.vue'
defineOptions({ name: 'ChannelManageDialog' })
const message = useMessage()
const { t } = useI18n()
const dialogVisible = ref(false)
const loading = ref(false)
const deviceInfo = ref<DeviceVO | null>(null)
const channelList = ref<ChannelVO[]>([])
const channelFormRef = ref()
/** 打开对话框 */
const open = async (device: DeviceVO) => {
dialogVisible.value = true
deviceInfo.value = device
await loadChannels()
}
defineExpose({ open })
/** 加载货道列表 */
const loadChannels = async () => {
if (!deviceInfo.value) return
loading.value = true
try {
const data = await ChannelApi.getChannelPage({
pageNo: 1,
pageSize: 100, // 最大值为 100
deviceId: deviceInfo.value.id,
deviceImei: deviceInfo.value.deviceId // 兼容字段
})
channelList.value = data.list || []
} catch (error) {
console.error('加载货道列表失败:', error)
message.error('加载货道列表失败')
} finally {
loading.value = false
}
}
/** 按行号分组的货道列表 */
const groupedChannels = computed(() => {
const groups = new Map<number, ChannelVO[]>()
// 先排序
const sorted = [...channelList.value].sort((a, b) => {
if (a.channelLine !== b.channelLine) {
return a.channelLine - b.channelLine
}
return Number(a.cabinetChannel) - Number(b.cabinetChannel)
})
// 按行号分组
sorted.forEach(channel => {
const line = channel.channelLine || 0
if (!groups.has(line)) {
groups.set(line, [])
}
groups.get(line)!.push(channel)
})
// 转换为有序的 Map
return new Map([...groups.entries()].sort((a, b) => a[0] - b[0]))
})
/** 新增货道 */
const handleAddChannel = () => {
if (!deviceInfo.value) return
channelFormRef.value.open('create', undefined, deviceInfo.value)
}
/** 批量新增货道 */
const batchFormRef = ref()
const handleBatchAddChannel = () => {
if (!deviceInfo.value) return
batchFormRef.value.open(deviceInfo.value)
}
/** 编辑货道 */
const handleEditChannel = (channel: ChannelVO) => {
channelFormRef.value.open('update', channel.id, deviceInfo.value)
}
/** 删除货道 */
const handleDeleteChannel = async (channel: ChannelVO) => {
try {
await message.delConfirm(`确定要删除货道"${channel.name || channel.cabinetChannel}"吗?`)
await ChannelApi.deleteChannel(channel.id)
message.success(t('common.delSuccess'))
await loadChannels()
} catch {}
}
/** 测试货道 */
const handleTestChannel = async (channel: ChannelVO) => {
try {
await message.confirm(`确定要测试货道"${channel.name || channel.cabinetChannel}"吗?`)
loading.value = true
const result = await ChannelApi.testChannel(channel.id)
if (result?.data) {
message.success(result.data)
} else {
message.success('测试成功')
}
} catch {}
finally {
loading.value = false
}
}
/** 刷新 */
const handleRefresh = () => {
loadChannels()
}
</script>
<style scoped lang="scss">
.channel-manage-container {
min-height: 500px;
}
.device-info-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
margin-bottom: 20px;
color: white;
flex-wrap: wrap;
gap: 16px;
.device-info-item {
display: flex;
align-items: center;
gap: 8px;
.label {
font-size: 14px;
opacity: 0.9;
}
.value {
font-size: 16px;
font-weight: 600;
&.highlight {
color: #ffd700;
font-size: 18px;
}
}
}
.device-info-actions {
display: flex;
gap: 12px;
margin-left: auto;
}
}
.vending-machine-view {
background: #f5f7fa;
border-radius: 12px;
padding: 24px;
min-height: 400px;
}
.machine-header {
text-align: center;
margin-bottom: 24px;
.machine-title {
font-size: 24px;
font-weight: bold;
color: #303133;
padding: 12px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.channels-by-line {
display: flex;
flex-direction: column;
gap: 24px;
}
.channel-line-group {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.line-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #e4e7ed;
.line-label {
font-size: 18px;
font-weight: bold;
color: #303133;
}
.line-count {
font-size: 14px;
color: #909399;
}
}
.channels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.channel-item {
background: white;
border: 2px solid #e4e7ed;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #409eff;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
transform: translateY(-2px);
}
&.has-product {
border-color: #67c23a;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
}
.channel-number {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.channel-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
padding-right: 40px;
}
.channel-info {
margin-bottom: 12px;
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
.info-label {
color: #909399;
}
.info-value {
color: #303133;
font-weight: 500;
}
}
}
.channel-actions {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e4e7ed;
justify-content: center;
}
}
:deep(.el-empty) {
padding: 60px 0;
}
</style>
@@ -70,6 +70,14 @@ type="success" plain @click="handleExport" :loading="exportLoading"
<el-button link type="primary" @click="refreshWechatCode(row)">
刷新小程序二维码
</el-button>
<el-button
link
type="success"
@click="handleChannelManage(row)"
v-hasPermi="['product:channel:query']"
>
<Icon icon="ep:grid" class="mr-5px" />货道管理
</el-button>
<el-button type="primary" size="small" @click="openForm('update', row.id)" v-hasPermi="['product:device:update']">
编辑
</el-button>
@@ -88,6 +96,8 @@ type="success" plain @click="handleExport" :loading="exportLoading"
<!-- 表单弹窗:添加/修改 -->
<!-- 表单弹窗:添加/修改 -->
<DeviceForm ref="formRef" @success="handleFormSuccess" />
<!-- 货道管理对话框 -->
<ChannelManageDialog ref="channelManageRef" />
</template>
<script setup lang="ts">
@@ -95,6 +105,7 @@ import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { DeviceApi, DeviceVO } from '@/api/product/device'
import DeviceForm from './DeviceForm.vue'
import ChannelManageDialog from './ChannelManageDialog.vue'
import { createImageViewer } from '@/components/ImageViewer'
import { ElLoading } from 'element-plus'
@@ -204,6 +215,12 @@ const handleExport = async () => {
}
}
/** 货道管理 */
const channelManageRef = ref()
const handleChannelManage = (row: DeviceVO) => {
channelManageRef.value.open(row)
}
/** 初始化 **/
onMounted(() => {
getList()
@@ -0,0 +1,146 @@
<template>
<Dialog v-model="dialogVisible" title="绑定设备">
<el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px">
<el-form-item label="用户名称">
<el-input v-model="formData.username" :disabled="true" />
</el-form-item>
<el-form-item label="用户昵称">
<el-input v-model="formData.nickname" :disabled="true" />
</el-form-item>
<el-form-item label="绑定设备">
<el-select
v-model="formData.deviceIds"
multiple
placeholder="请选择设备"
style="width: 100%"
filterable
>
<el-option
v-for="device in deviceList"
:key="device.id"
:label="`${device.name} (${device.deviceId})`"
:value="device.id"
/>
</el-select>
<div class="tip" style="margin-top: 8px; color: #909399; font-size: 12px">
可绑定多个设备,设备下拉列表会根据当前用户绑定的设备自动过滤
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import * as UserApi from '@/api/system/user'
import { DeviceApi, DeviceVO } from '@/api/product/device'
defineOptions({ name: 'SystemUserDeviceBindingForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formData = ref({
id: -1,
nickname: '',
username: '',
deviceIds: [] as number[]
})
const formRef = ref() // 表单 Ref
const deviceList = ref([] as DeviceVO[]) // 设备列表
/** 打开弹窗 */
const open = async (row: UserApi.UserVO) => {
dialogVisible.value = true
resetForm()
// 设置数据
formData.value.id = row.id
formData.value.username = row.username
formData.value.nickname = row.nickname
// 加载用户已绑定的设备
formLoading.value = true
try {
// 获取用户已绑定的设备ID列表
const deviceIdsRes = await UserApi.getUserDeviceIds(row.id)
if (deviceIdsRes?.code === 0) {
formData.value.deviceIds = deviceIdsRes.data?.deviceIds || []
} else if (deviceIdsRes?.deviceIds) {
// 兼容直接返回数据的情况
formData.value.deviceIds = deviceIdsRes.deviceIds || []
}
// 获取设备下拉列表(自动根据当前登录用户过滤)
const deviceRes = await DeviceApi.getDeviceListAllSimple()
if (deviceRes?.code === 0) {
deviceList.value = deviceRes.data || []
} else {
// 兼容直接返回数据的情况
deviceList.value = deviceRes || []
}
} finally {
formLoading.value = false
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 提交请求
formLoading.value = true
try {
// 先获取当前已绑定的设备
const currentDeviceIdsRes = await UserApi.getUserDeviceIds(formData.value.id)
let currentDeviceIds: number[] = []
if (currentDeviceIdsRes?.code === 0) {
currentDeviceIds = currentDeviceIdsRes.data?.deviceIds || []
} else if (currentDeviceIdsRes?.deviceIds) {
// 兼容直接返回数据的情况
currentDeviceIds = currentDeviceIdsRes.deviceIds || []
}
// 计算需要绑定的设备(新增的)
const newDeviceIds = formData.value.deviceIds.filter(id => !currentDeviceIds.includes(id))
// 计算需要解绑的设备(删除的)
const removeDeviceIds = currentDeviceIds.filter(id => !formData.value.deviceIds.includes(id))
// 执行绑定操作
if (newDeviceIds.length > 0) {
await UserApi.bindUserDevices({
userId: formData.value.id,
deviceIds: newDeviceIds
})
}
// 执行解绑操作
if (removeDeviceIds.length > 0) {
await UserApi.unbindUserDevices({
userId: formData.value.id,
deviceIds: removeDeviceIds
})
}
message.success(t('common.updateSuccess'))
dialogVisible.value = false
// 发送操作成功的事件
emit('success', true)
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: -1,
nickname: '',
username: '',
deviceIds: []
}
formRef.value?.resetFields()
}
</script>