小如首页

This commit is contained in:
zjf committed 2026-04-22 15:11:49 +08:00
1 parent 6a9abdce9f
commit cd7f291892
13 files changed
+1264 -36

No files matched your search

+1
View File
@@ -66,6 +66,7 @@ export interface Spu {
deviceId?: number // 设备号
purchaseLimitType?: number // 限购类型:0-不限购,1-每天限购,2-每周限购,3-终身限购
purchaseLimitCount?: number // 限购数量
hotRecommend?: boolean // 是否热门推荐(分页筛选:不传则不筛;传 true/false 精确匹配)
}
// 获得 Spu 列表
@@ -5,6 +5,7 @@ export interface DiscountActivityVO {
id?: number
spuId?: number
name?: string
picUrl?: string
status?: number
remark?: string
startTime?: Date
+64
View File
@@ -0,0 +1,64 @@
import request from '@/config/axios'
/** 榜单内商品(响应) */
export interface RankingSpuResp {
id: number // 关联表主键
spuId: number
sort: number
}
/** 榜单详情/列表行 */
export interface RankingRespVO {
id: number
title: string
subtitle?: string
iconUrl?: string
sort: number
status: number // 0 开启 1 关闭
remark?: string
createTime?: string
spus: RankingSpuResp[]
}
/** 提交时榜单商品项 */
export interface RankingSpuItem {
spuId: number
sort: number
}
export interface RankingCreateBody {
title: string
subtitle?: string
iconUrl?: string
sort: number
status: number
remark?: string
spus: RankingSpuItem[]
}
export type RankingUpdateBody = RankingCreateBody & { id: number }
/** 分页查询 */
export const getRankingPage = async (params: any) => {
return await request.get({ url: '/promotion/ranking/page', params })
}
/** 详情 */
export const getRanking = async (id: number) => {
return await request.get({ url: '/promotion/ranking/get', params: { id } })
}
/** 创建 */
export const createRanking = async (data: RankingCreateBody) => {
return await request.post({ url: '/promotion/ranking/create', data })
}
/** 更新 */
export const updateRanking = async (data: RankingUpdateBody) => {
return await request.put({ url: '/promotion/ranking/update', data })
}
/** 删除 */
export const deleteRanking = async (id: number) => {
return await request.delete({ url: '/promotion/ranking/delete', params: { id } })
}
@@ -25,16 +25,6 @@ v-for="(item, index) in tableHeaders" :key="index" :label="item.label" align="ce
<el-input v-model="row.barCode" class="w-100%" />
</template>
</el-table-column>
<el-table-column align="center" label="货道/柜门编号" min-width="120">
<template #default="{ row }">
<el-input v-model="row.cabinetChannel" class="w-100%" />
</template>
</el-table-column>
<el-table-column align="center" label="通道类型" min-width="120">
<template #default="{ row }">
<el-input v-model="row.cabinetType" class="w-100%" />
</template>
</el-table-column>
<el-table-column align="center" label="销售价" min-width="168">
<template #default="{ row }">
<el-input-number
@@ -135,16 +125,6 @@ v-for="(item, index) in tableHeaders" :key="index" :label="item.label" align="ce
{{ row.barCode }}
</template>
</el-table-column>
<el-table-column align="center" label="货道/柜门编号" min-width="120">
<template #default="{ row }">
{{ row.cabinetChannel }}
</template>
</el-table-column>
<el-table-column align="center" label="通道类型" min-width="120">
<template #default="{ row }">
{{ row.cabinetType }}
</template>
</el-table-column>
<el-table-column align="center" label="销售价(元)" min-width="80">
<template #default="{ row }">
{{ row.price }}
@@ -221,16 +201,6 @@ v-for="(item, index) in tableHeaders" :key="index" :label="item.label" align="ce
{{ row.barCode }}
</template>
</el-table-column>
<el-table-column align="center" label="货道/柜门编号" min-width="120">
<template #default="{ row }">
{{ row.cabinetChannel }}
</template>
</el-table-column>
<el-table-column align="center" label="通道类型" min-width="120">
<template #default="{ row }">
{{ row.cabinetType }}
</template>
</el-table-column>
<el-table-column align="center" label="销售价(元)" min-width="80">
<template #default="{ row }">
{{ formatToFraction(row.price) }}
+12 -1
View File
@@ -25,6 +25,16 @@
class="w-80!"
/>
</el-form-item>
<el-form-item label="热门推荐" prop="hotRecommend">
<el-switch
v-model="formData.hotRecommend"
:active-value="true"
:inactive-value="false"
active-text="是"
inactive-text="否"
inline-prompt
/>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
@@ -50,7 +60,8 @@ const formRef = ref() // 表单Ref
const formData = ref<Spu>({
sort: 0, // 商品排序
giveIntegral: 0, // 赠送积分
virtualSalesCount: 0 // 虚拟销量
virtualSalesCount: 0, // 虚拟销量
hotRecommend: false // 热门推荐
})
// 表单规则
const rules = reactive({
+8 -1
View File
@@ -109,7 +109,8 @@ const formData = ref<ProductSpuApi.Spu>({
description: '', // 商品详情
sort: 0, // 商品排序
giveIntegral: 0, // 赠送积分
virtualSalesCount: 0 // 虚拟销量
virtualSalesCount: 0, // 虚拟销量
hotRecommend: false // 热门推荐
})
/** 获得详情 */
@@ -139,6 +140,9 @@ const getDetail = async () => {
}
})
formData.value = res
if (formData.value.hotRecommend === undefined) {
formData.value.hotRecommend = false
}
} finally {
formLoading.value = false
}
@@ -167,6 +171,9 @@ const submitForm = async () => {
item.costPrice = convertToInteger(item.costPrice)
item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice)
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice)
// 商城商品(types=0)SKU 不含售货机货道/通道,避免误带历史脏数据
delete (item as any).cabinetChannel
delete (item as any).cabinetType
})
// 处理轮播图列表
const newSliderPicUrls: any[] = []
+31 -3
View File
@@ -42,6 +42,17 @@
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item label="热门推荐" prop="hotRecommend">
<el-select
v-model="queryParams.hotRecommend"
class="!w-240px"
clearable
placeholder="全部"
>
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
@@ -155,6 +166,12 @@
<el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
<el-table-column align="center" label="库存" min-width="90" prop="stock" />
<el-table-column align="center" label="排序" min-width="70" prop="sort" />
<el-table-column align="center" label="热门推荐" min-width="100">
<template #default="{ row }">
<el-tag v-if="row.hotRecommend === true" size="small" type="success">是</el-tag>
<el-tag v-else size="small" type="info">否</el-tag>
</template>
</el-table-column>
<el-table-column align="center" label="销售状态" min-width="80">
<template #default="{ row }">
<template v-if="row.status >= 0">
@@ -289,15 +306,26 @@ const queryParams = ref({
name: '',
categoryId: undefined,
createTime: undefined,
types:0
types: 0,
/** 不传则不参与筛选;仅 true/false 参与后端 eq 查询 */
hotRecommend: undefined as boolean | undefined
}) // 查询参数
/** 分页/导出参数:未选「热门推荐」时不传 hotRecommend,避免 false 被误判为空 */
const buildSpuPageParams = () => {
const p: Record<string, any> = { ...queryParams.value }
if (p.hotRecommend === undefined || p.hotRecommend === null) {
delete p.hotRecommend
}
return p
}
const queryFormRef = ref() // 搜索的表单Ref
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await ProductSpuApi.getSpuPage(queryParams.value)
const data = await ProductSpuApi.getSpuPage(buildSpuPageParams())
list.value = data.list
total.value = data.total
} finally {
@@ -413,7 +441,7 @@ const handleExport = async () => {
await message.exportConfirm()
// 发起导出
exportLoading.value = true
const data = await ProductSpuApi.exportSpu(queryParams.value)
const data = await ProductSpuApi.exportSpu(buildSpuPageParams())
download.excel(data, '商品列表.xls')
} catch {
} finally {
@@ -7,6 +7,9 @@
:rules="rules"
:schema="allSchemas.formSchema"
>
<template #picUrl="model">
<UploadImg v-model="model.picUrl" height="80px" />
</template>
<!-- 先选择 -->
<template #spuId>
<el-button @click="spuSelectRef.open()">选择商品</el-button>
@@ -63,6 +66,7 @@ import * as ProductSpuApi from '@/api/mall/product/spu'
import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components'
import { convertToInteger, erpCalculatePercentage, fenToYuan, yuanToFen } from '@/utils'
import { PromotionDiscountTypeEnum } from '@/utils/constants'
import { UploadImg } from '@/components/UploadFile'
defineOptions({ name: 'PromotionDiscountActivityForm' })
@@ -246,7 +250,8 @@ const resetForm = async () => {
spuPropertyList.value = []
spuIds.value = []
await nextTick()
formRef.value.getElFormRef().resetFields()
// Dialog 首次打开时 formRef 可能尚未挂载
formRef.value?.getElFormRef?.()?.resetFields()
}
/**
@@ -24,6 +24,17 @@ const crudSchemas = reactive<CrudSchema[]>([
width: 120
}
},
{
label: '活动图片',
field: 'picUrl',
isSearch: false,
isTable: false,
form: {
colProps: {
span: 24
}
}
},
{
label: '活动开始时间',
field: 'startTime',
@@ -63,6 +63,18 @@
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="活动编号" prop="id" min-width="80" />
<el-table-column label="活动图片" min-width="80">
<template #default="scope">
<el-image
v-if="scope.row.picUrl"
:src="scope.row.picUrl"
class="h-40px w-40px"
:preview-src-list="[scope.row.picUrl]"
preview-teleported
/>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column label="活动名称" prop="name" min-width="140" />
<el-table-column label="活动时间" min-width="210">
<template #default="scope">
@@ -0,0 +1,243 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="720px">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="110px"
>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入榜单标题" maxlength="64" show-word-limit />
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<el-input v-model="formData.subtitle" placeholder="选填" />
</el-form-item>
<el-form-item label="榜单图标" prop="iconUrl">
<UploadImg v-model="formData.iconUrl" height="80px" />
</el-form-item>
<el-form-item label="榜单排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" class="!w-240px" controls-position="right" />
<span class="ml-12px text-12px text-gray-500">数值越大越靠前</span>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" placeholder="选填" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="榜单商品" required>
<div class="mb-8px">
<el-button type="primary" plain @click="spuSelectRef?.open()">
<Icon class="mr-5px" icon="ep:plus" />
添加商品
</el-button>
<span class="ml-8px text-12px text-gray-500">至少 1 个商品,排序越大越靠前</span>
</div>
<el-table :data="spuRows" border max-height="360" size="small">
<el-table-column align="center" label="商品图" width="72">
<template #default="{ row }">
<el-image v-if="row.picUrl" :src="row.picUrl" class="h-36px w-36px" fit="cover" />
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column label="商品名称" min-width="160" prop="name" show-overflow-tooltip />
<el-table-column align="center" label="SPU编号" prop="spuId" width="100" />
<el-table-column align="center" label="排序" width="140">
<template #default="{ row }">
<el-input-number v-model="row.sort" :min="0" class="!w-110px" controls-position="right" />
</template>
</el-table-column>
<el-table-column align="center" fixed="right" label="操作" width="80">
<template #default="{ $index }">
<el-button link type="danger" @click="removeSpuRow($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</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>
<SpuSelect ref="spuSelectRef" :is-select-sku="false" @confirm="onSpuSelected" />
</Dialog>
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { UploadImg } from '@/components/UploadFile'
import * as RankingApi from '@/api/mall/promotion/ranking'
import * as ProductSpuApi from '@/api/mall/product/spu'
import { SpuSelect } from '../components'
defineOptions({ name: 'PromotionRankingForm' })
interface SpuRow {
spuId: number
sort: number
name?: string
picUrl?: string
}
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const formRef = ref()
const spuSelectRef = ref<InstanceType<typeof SpuSelect>>()
const formData = ref({
id: undefined as number | undefined,
title: '',
subtitle: '',
iconUrl: '',
sort: 0,
status: 0,
remark: ''
})
const spuRows = ref<SpuRow[]>([])
const formRules = reactive({
title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
sort: [{ required: true, message: '排序不能为空', trigger: 'change' }],
status: [{ required: true, message: '状态不能为空', trigger: 'change' }]
})
const nextSort = () => {
if (!spuRows.value.length) return 100
return Math.max(...spuRows.value.map((r) => r.sort)) + 1
}
const onSpuSelected = async (spuId: number) => {
if (spuRows.value.some((r) => r.spuId === spuId)) {
message.warning('该商品已在榜单中')
return
}
try {
const spu = (await ProductSpuApi.getSpu(spuId)) as ProductSpuApi.Spu
spuRows.value.push({
spuId,
sort: nextSort(),
name: spu.name,
picUrl: spu.picUrl
})
} catch {
message.error('获取商品信息失败')
}
}
const removeSpuRow = (index: number) => {
spuRows.value.splice(index, 1)
}
const resetForm = () => {
formData.value = {
id: undefined,
title: '',
subtitle: '',
iconUrl: '',
sort: 0,
status: 0,
remark: ''
}
spuRows.value = []
formRef.value?.resetFields()
}
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 detail = (await RankingApi.getRanking(id)) as RankingApi.RankingRespVO
if (!detail) return
formData.value = {
id: detail.id,
title: detail.title,
subtitle: detail.subtitle ?? '',
iconUrl: detail.iconUrl ?? '',
sort: detail.sort,
status: detail.status,
remark: detail.remark ?? ''
}
const ordered = [...(detail.spus || [])].sort((a, b) => b.sort - a.sort)
const spuIds = ordered.map((s) => s.spuId)
if (spuIds.length) {
const list = (await ProductSpuApi.getSpuDetailList(spuIds)) as ProductSpuApi.Spu[]
const map = new Map(list.map((s) => [s.id!, s]))
spuRows.value = ordered.map((link) => {
const spu = map.get(link.spuId)
return {
spuId: link.spuId,
sort: link.sort,
name: spu?.name,
picUrl: spu?.picUrl
}
})
}
} finally {
formLoading.value = false
}
}
}
defineExpose({ open })
const emit = defineEmits(['success'])
const submitForm = async () => {
await formRef.value?.validate()
if (!spuRows.value.length) {
message.warning('请至少添加一个榜单商品')
return
}
const ids = spuRows.value.map((r) => r.spuId)
if (new Set(ids).size !== ids.length) {
message.warning('榜单内存在重复的 SPU')
return
}
const spus: RankingApi.RankingSpuItem[] = spuRows.value.map((r) => ({
spuId: r.spuId,
sort: r.sort
}))
const payload = {
title: formData.value.title,
subtitle: formData.value.subtitle || undefined,
iconUrl: formData.value.iconUrl || undefined,
sort: formData.value.sort,
status: formData.value.status,
remark: formData.value.remark || undefined,
spus
}
formLoading.value = true
try {
if (formType.value === 'create') {
await RankingApi.createRanking(payload)
message.success(t('common.createSuccess'))
} else {
await RankingApi.updateRanking({ ...payload, id: formData.value.id! })
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
</script>
+196
View File
@@ -0,0 +1,196 @@
<template>
<ContentWrap>
<el-form
ref="queryFormRef"
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="88px"
>
<el-form-item label="榜单标题" prop="title">
<el-input
v-model="queryParams.title"
class="!w-240px"
clearable
placeholder="模糊搜索标题"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="全部">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
end-placeholder="结束日期"
start-placeholder="开始日期"
type="daterange"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
<el-button
v-hasPermi="['promotion:ranking:create']"
plain
type="primary"
@click="openForm('create')"
>
<Icon class="mr-5px" icon="ep:plus" />
新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<div class="mb-10px text-13px text-gray-500">排序数值越大越靠前(榜单与榜单内商品规则一致)</div>
<el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
<el-table-column align="center" label="编号" prop="id" width="80" />
<el-table-column align="center" label="标题" min-width="140" prop="title" />
<el-table-column align="center" label="副标题" min-width="120" prop="subtitle" />
<el-table-column align="center" label="图标" width="80">
<template #default="{ row }">
<el-image
v-if="row.iconUrl"
:src="row.iconUrl"
class="h-32px w-32px"
fit="cover"
@click="imagePreview(row.iconUrl)"
/>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column align="center" label="排序" prop="sort" width="80" />
<el-table-column align="center" label="状态" prop="status" width="100">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column align="center" label="商品数量" width="100">
<template #default="{ row }">
{{ row.spus?.length ?? 0 }}
</template>
</el-table-column>
<el-table-column
:formatter="dateFormatter"
align="center"
label="创建时间"
prop="createTime"
width="170"
/>
<el-table-column align="center" fixed="right" label="操作" width="160">
<template #default="scope">
<el-button
v-hasPermi="['promotion:ranking:update']"
link
type="primary"
@click="openForm('update', scope.row.id)"
>
编辑
</el-button>
<el-button
v-hasPermi="['promotion:ranking:delete']"
link
type="danger"
@click="handleDelete(scope.row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNo"
:total="total"
@pagination="getList"
/>
</ContentWrap>
<RankingForm ref="formRef" @success="getList" />
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { createImageViewer } from '@/components/ImageViewer'
import * as RankingApi from '@/api/mall/promotion/ranking'
import RankingForm from './RankingForm.vue'
defineOptions({ name: 'PromotionRanking' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const total = ref(0)
const list = ref<RankingApi.RankingRespVO[]>([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
title: undefined as string | undefined,
status: undefined as number | undefined,
createTime: [] as string[]
})
const queryFormRef = ref()
const imagePreview = (url: string) => {
createImageViewer({ urlList: [url] })
}
const getList = async () => {
loading.value = true
try {
const data = await RankingApi.getRankingPage(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 RankingApi.deleteRanking(id)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
onMounted(() => {
getList()
})
</script>
@@ -0,0 +1,679 @@
# 用户设备绑定功能 API 文档
## 概述
本功能实现了系统用户与设备的多对多绑定关系,一个用户账号可以绑定多个设备进行管理。设备下拉列表会根据当前登录用户绑定的设备自动过滤。
## 数据库表结构
### system_user_device(用户设备关联表)
| 字段名 | 类型 | 说明 | 备注 |
|--------|------|------|------|
| id | bigint | 自增主键 | PRIMARY KEY |
| user_id | bigint | 用户ID | 关联 system_users.id |
| device_id | bigint | 设备ID | 关联 product_device.id |
| creator | varchar(64) | 创建者 | |
| create_time | datetime | 创建时间 | |
| updater | varchar(64) | 更新者 | |
| update_time | datetime | 更新时间 | |
| deleted | bit(1) | 是否删除 | 默认 0 |
| tenant_id | bigint | 租户编号 | 默认 0 |
**唯一索引**:`uk_user_device` (user_id, device_id, tenant_id, deleted) - 防止重复绑定
---
## API 接口列表
### 1. 绑定用户设备
**接口地址**:`POST /system/user/bind-devices`
**接口说明**:为指定用户绑定一个或多个设备。如果设备已绑定,会自动跳过(去重)。
**权限要求**:`system:user:update`
**请求参数**:
```json
{
"userId": 1,
"deviceIds": [1, 2, 3]
}
```
**参数说明**:
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|--------|------|------|------|------|
| userId | Long | 是 | 用户ID | 1 |
| deviceIds | List<Long> | 是 | 设备ID列表 | [1, 2, 3] |
**请求示例**:
```bash
curl -X POST "http://localhost:8080/system/user/bind-devices" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {token}" \
-d '{
"userId": 1,
"deviceIds": [1, 2, 3]
}'
```
**响应示例**:
```json
{
"code": 0,
"data": true,
"msg": "操作成功"
}
```
**错误响应**:
```json
{
"code": 1_003_000_001,
"data": null,
"msg": "用户不存在"
}
```
---
### 2. 解绑用户设备
**接口地址**:`DELETE /system/user/unbind-devices`
**接口说明**:为指定用户解绑一个或多个设备。
**权限要求**:`system:user:update`
**请求参数**:
```json
{
"userId": 1,
"deviceIds": [2, 3]
}
```
**参数说明**:
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|--------|------|------|------|------|
| userId | Long | 是 | 用户ID | 1 |
| deviceIds | List<Long> | 是 | 要解绑的设备ID列表 | [2, 3] |
**请求示例**:
```bash
curl -X DELETE "http://localhost:8080/system/user/unbind-devices" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {token}" \
-d '{
"userId": 1,
"deviceIds": [2, 3]
}'
```
**响应示例**:
```json
{
"code": 0,
"data": true,
"msg": "操作成功"
}
```
---
### 3. 获取用户绑定的设备ID列表
**接口地址**:`GET /system/user/get-device-ids`
**接口说明**:查询指定用户已绑定的所有设备ID列表。
**权限要求**:`system:user:query`
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|--------|------|------|------|------|
| userId | Long | 是 | 用户ID | 1 |
**请求示例**:
```bash
curl -X GET "http://localhost:8080/system/user/get-device-ids?userId=1" \
-H "Authorization: Bearer {token}"
```
**响应示例**:
```json
{
"code": 0,
"data": {
"userId": 1,
"deviceIds": [1, 2, 3]
},
"msg": "操作成功"
}
```
**响应字段说明**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| userId | Long | 用户ID |
| deviceIds | List<Long> | 绑定的设备ID列表 |
---
### 4. 获取设备下拉列表(精简)
**接口地址**:`GET /product/device/list-all-simple`
**接口说明**:获取设备下拉列表,**自动根据当前登录用户绑定的设备进行过滤**。如果用户未绑定任何设备,返回空列表。
**权限要求**:`product:device:query`
**请求参数**:无
**请求示例**:
```bash
curl -X GET "http://localhost:8080/product/device/list-all-simple" \
-H "Authorization: Bearer {token}"
```
**响应示例**:
```json
{
"code": 0,
"data": [
{
"id": 1,
"name": "贩卖机001",
"deviceId": "123456789012345"
},
{
"id": 2,
"name": "贩卖机002",
"deviceId": "123456789012346"
}
],
"msg": "操作成功"
}
```
**响应字段说明**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 设备编号 |
| name | String | 设备名称 |
| deviceId | String | 设备IMEI |
**重要说明**:
- 此接口会根据当前登录用户自动过滤设备
- 只返回当前用户已绑定的设备
- 如果用户未绑定任何设备,返回空数组 `[]`
- 兼容旧逻辑:如果无法获取用户ID,则根据部门ID查询
---
## 前端实现建议
### 1. 用户管理页面 - 设备绑定功能
在用户详情或编辑页面,添加设备绑定功能:
```vue
<template>
<el-form>
<!-- 其他用户信息字段 -->
<!-- 设备绑定 -->
<el-form-item label="绑定设备">
<el-select
v-model="form.deviceIds"
multiple
placeholder="请选择设备"
style="width: 100%"
@change="handleDeviceChange"
>
<el-option
v-for="device in deviceList"
:key="device.id"
:label="`${device.name} (${device.deviceId})`"
:value="device.id"
/>
</el-select>
<div class="tip">可绑定多个设备,设备下拉列表会根据当前用户绑定的设备自动过滤</div>
</el-form-item>
</el-form>
</template>
<script>
export default {
data() {
return {
form: {
userId: null,
deviceIds: []
},
deviceList: [] // 所有可用设备列表
}
},
methods: {
// 加载用户已绑定的设备
async loadUserDevices(userId) {
const res = await this.$api.get(`/system/user/get-device-ids?userId=${userId}`)
if (res.code === 0) {
this.form.deviceIds = res.data.deviceIds || []
}
},
// 保存设备绑定
async saveDeviceBinding() {
await this.$api.post('/system/user/bind-devices', {
userId: this.form.userId,
deviceIds: this.form.deviceIds
})
this.$message.success('设备绑定成功')
},
// 解绑设备
async unbindDevices(deviceIds) {
await this.$api.delete('/system/user/unbind-devices', {
userId: this.form.userId,
deviceIds: deviceIds
})
this.$message.success('设备解绑成功')
}
}
}
</script>
```
### 2. 设备下拉选择组件
在需要使用设备下拉的地方(如商品管理、订单管理等),使用设备下拉接口:
```vue
<template>
<el-select
v-model="selectedDeviceId"
placeholder="请选择设备"
clearable
style="width: 100%"
>
<el-option
v-for="device in deviceList"
:key="device.id"
:label="`${device.name} (${device.deviceId})`"
:value="device.id"
/>
</el-select>
</template>
<script>
export default {
data() {
return {
selectedDeviceId: null,
deviceList: []
}
},
mounted() {
this.loadDeviceList()
},
methods: {
// 加载设备下拉列表(自动根据当前用户过滤)
async loadDeviceList() {
const res = await this.$api.get('/product/device/list-all-simple')
if (res.code === 0) {
this.deviceList = res.data || []
if (this.deviceList.length === 0) {
this.$message.warning('当前用户未绑定任何设备,请先绑定设备')
}
}
}
}
}
</script>
```
### 3. 用户设备管理页面(可选)
可以创建一个专门的页面来管理用户设备绑定关系:
```vue
<template>
<div class="user-device-management">
<el-card>
<div slot="header">
<span>用户设备绑定管理</span>
<el-button
type="primary"
size="small"
style="float: right"
@click="showBindDialog = true"
>
绑定设备
</el-button>
</div>
<!-- 用户选择 -->
<el-select
v-model="selectedUserId"
placeholder="请选择用户"
@change="loadUserDevices"
style="width: 300px; margin-bottom: 20px"
>
<el-option
v-for="user in userList"
:key="user.id"
:label="user.nickname"
:value="user.id"
/>
</el-select>
<!-- 已绑定设备列表 -->
<el-table :data="boundDevices" v-loading="loading">
<el-table-column prop="id" label="设备ID" width="100" />
<el-table-column prop="name" label="设备名称" />
<el-table-column prop="deviceId" label="设备IMEI" />
<el-table-column label="操作" width="150">
<template slot-scope="scope">
<el-button
type="danger"
size="mini"
@click="handleUnbind(scope.row.id)"
>
解绑
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 绑定设备对话框 -->
<el-dialog
title="绑定设备"
:visible.sync="showBindDialog"
width="500px"
>
<el-select
v-model="selectedDeviceIds"
multiple
placeholder="请选择要绑定的设备"
style="width: 100%"
>
<el-option
v-for="device in availableDevices"
:key="device.id"
:label="`${device.name} (${device.deviceId})`"
:value="device.id"
/>
</el-select>
<div slot="footer">
<el-button @click="showBindDialog = false">取消</el-button>
<el-button type="primary" @click="handleBind">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
data() {
return {
selectedUserId: null,
userList: [],
boundDevices: [],
availableDevices: [],
selectedDeviceIds: [],
showBindDialog: false,
loading: false
}
},
mounted() {
this.loadUserList()
this.loadAvailableDevices()
},
methods: {
// 加载用户列表
async loadUserList() {
const res = await this.$api.get('/system/user/list-all-simple')
if (res.code === 0) {
this.userList = res.data || []
}
},
// 加载可用设备列表(所有设备)
async loadAvailableDevices() {
// 这里需要调用获取所有设备的接口,或者使用分页接口
const res = await this.$api.get('/product/device/page', {
pageNo: 1,
pageSize: 1000
})
if (res.code === 0) {
this.availableDevices = res.data.list || []
}
},
// 加载用户已绑定的设备
async loadUserDevices(userId) {
if (!userId) return
this.loading = true
try {
const res = await this.$api.get(`/system/user/get-device-ids?userId=${userId}`)
if (res.code === 0) {
const deviceIds = res.data.deviceIds || []
// 根据设备ID获取设备详情
if (deviceIds.length > 0) {
const deviceRes = await this.$api.get('/product/device/list', {
deviceIds: deviceIds
})
if (deviceRes.code === 0) {
this.boundDevices = deviceRes.data || []
}
} else {
this.boundDevices = []
}
}
} finally {
this.loading = false
}
},
// 绑定设备
async handleBind() {
if (!this.selectedUserId) {
this.$message.warning('请先选择用户')
return
}
if (this.selectedDeviceIds.length === 0) {
this.$message.warning('请选择要绑定的设备')
return
}
const res = await this.$api.post('/system/user/bind-devices', {
userId: this.selectedUserId,
deviceIds: this.selectedDeviceIds
})
if (res.code === 0) {
this.$message.success('绑定成功')
this.showBindDialog = false
this.selectedDeviceIds = []
this.loadUserDevices(this.selectedUserId)
}
},
// 解绑设备
async handleUnbind(deviceId) {
await this.$confirm('确定要解绑该设备吗?', '提示', {
type: 'warning'
})
const res = await this.$api.delete('/system/user/unbind-devices', {
userId: this.selectedUserId,
deviceIds: [deviceId]
})
if (res.code === 0) {
this.$message.success('解绑成功')
this.loadUserDevices(this.selectedUserId)
}
}
}
}
</script>
```
---
## 业务逻辑说明
### 1. 设备绑定逻辑
- **去重处理**:绑定设备时会自动检查已绑定的设备,只绑定新设备,避免重复
- **事务支持**:绑定操作在事务中执行,保证数据一致性
- **用户校验**:绑定前会校验用户是否存在
### 2. 设备下拉过滤逻辑
- **优先级**:优先根据用户绑定的设备过滤
- **兼容性**:如果无法获取用户ID,则回退到根据部门ID查询(兼容旧逻辑)
- **空列表处理**:如果用户未绑定任何设备,返回空列表
### 3. 数据清理
- **删除用户时**:自动删除该用户的所有设备绑定关系
- **删除设备时**:建议同时删除相关的用户设备绑定关系(需要在删除设备接口中实现)
---
## 错误码说明
| 错误码 | 说明 |
|--------|------|
| 1_003_000_001 | 用户不存在 |
| 1_008_009_000 | 设备不存在 |
---
## 接口调用流程示例
### 场景:为用户绑定设备
1. **获取用户信息**
```
GET /system/user/get?id=1
```
2. **获取所有可用设备列表**(用于选择)
```
GET /product/device/page?pageNo=1&pageSize=100
```
3. **获取用户已绑定的设备**(用于回显)
```
GET /system/user/get-device-ids?userId=1
```
4. **绑定设备**
```
POST /system/user/bind-devices
{
"userId": 1,
"deviceIds": [1, 2, 3]
}
```
### 场景:使用设备下拉(自动过滤)
1. **获取设备下拉列表**(自动根据当前用户过滤)
```
GET /product/device/list-all-simple
```
返回结果只包含当前登录用户已绑定的设备
---
## 注意事项
1. **权限控制**:
- 绑定/解绑设备需要 `system:user:update` 权限
- 查询设备列表需要 `product:device:query` 权限
2. **数据一致性**:
- 绑定操作支持事务,失败会自动回滚
- 建议在删除设备时,同步删除用户设备关联
3. **性能优化**:
- 设备下拉接口已优化,只返回用户绑定的设备,减少数据量
- 如果用户未绑定设备,直接返回空列表,避免不必要的查询
4. **兼容性**:
- 设备下拉接口兼容旧逻辑,如果无法获取用户ID,会回退到部门ID查询
- 新老逻辑可以平滑过渡
---
## 前端开发检查清单
- [ ] 用户管理页面添加设备绑定功能
- [ ] 设备下拉组件使用新的接口(自动过滤)
- [ ] 处理用户未绑定设备的情况(提示信息)
- [ ] 实现设备绑定的增删改查功能
- [ ] 添加权限校验
- [ ] 添加错误处理和提示
- [ ] 添加加载状态显示
- [ ] 实现设备列表的搜索和筛选(如需要)
---
## 测试用例
### 测试场景 1:绑定设备
1. 选择用户ID = 1
2. 选择设备ID = [1, 2, 3]
3. 调用绑定接口
4. 验证返回成功
5. 查询用户设备列表,验证设备已绑定
### 测试场景 2:重复绑定
1. 用户已绑定设备 [1, 2]
2. 再次绑定设备 [2, 3]
3. 验证设备 [2] 不会重复绑定
4. 验证最终绑定设备为 [1, 2, 3]
### 测试场景 3:设备下拉过滤
1. 用户A绑定设备 [1, 2]
2. 用户B绑定设备 [2, 3]
3. 用户A登录,调用设备下拉接口
4. 验证只返回设备 [1, 2]
5. 用户B登录,调用设备下拉接口
6. 验证只返回设备 [2, 3]
### 测试场景 4:解绑设备
1. 用户绑定设备 [1, 2, 3]
2. 解绑设备 [2]
3. 验证用户剩余绑定设备为 [1, 3]
---
## 更新日志
- 2025-01-XX:初始版本,实现用户设备多对多绑定功能