feat(商品管理): 实现商品管理模块的完整功能

新增商品管理相关页面和功能,包括:
1. 商品列表展示、搜索和分页
2. 商品新增、编辑和详情页面
3. 商品图片上传和压缩功能
4. 商品状态管理和操作
5. 相关API接口文档和类型定义

调整路由配置和权限设置以支持新功能
This commit is contained in:
dzq 2025-12-11 17:31:11 +08:00
parent 78a228507e
commit 2e7e2366f5
9 changed files with 1259 additions and 149 deletions

View File

@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(tree -L 3 -I 'node_modules|dist')"
"Bash(tree -L 3 -I 'node_modules|dist')",
"Bash(cat:*)"
],
"deny": [],
"ask": []

View File

@ -0,0 +1,295 @@
# ManageGoodsController 接口文档
## 控制器概述
**基础路径:** `/api/manage/goods`
**控制器类:** `com.agileboot.api.controller.manage.ManageGoodsController`
**功能描述:** 商品管理接口,提供商品的增删改查功能。
---
## 接口列表
### 1. 获取商品列表
**接口路径:** `GET /api/manage/goods/list`
**功能描述:** 分页查询商品列表,支持多条件筛选和排序。
**请求参数:**
| 参数名 | 类型 | 必填 | 描述 | 示例 |
|--------|------|------|------|------|
| `pageNum` | Integer | 否 | 页码默认1最大值200 | `1` |
| `pageSize` | Integer | 否 | 每页大小默认10最大值500 | `10` |
| `orderColumn` | String | 否 | 排序字段 | `"createTime"` |
| `orderDirection` | String | 否 | 排序方向:`"ascending"`(升序) 或 `"descending"`(降序) | `"descending"` |
| `beginTime` | Date | 否 | 开始时间格式yyyy-MM-dd | `"2025-01-01"` |
| `endTime` | Date | 否 | 结束时间格式yyyy-MM-dd | `"2025-12-31"` |
| `goodsName` | String | 否 | 商品名称(模糊查询) | `"测试商品"` |
| `categoryId` | Long | 否 | 商品分类ID | `1` |
| `externalGoodsId` | Long | 否 | 外部归属类型的商品ID | `1001` |
| `corpid` | String | 否 | 企业微信id | `"wxcorpid123"` |
| `status` | Integer | 否 | 商品状态1上架 2下架 | `1` |
| `autoApproval` | Integer | 否 | 免审批0否 1是 | `1` |
| `minPrice` | BigDecimal | 否 | 最低价格 | `10.00` |
| `maxPrice` | BigDecimal | 否 | 最高价格 | `100.00` |
| `belongType` | Integer | 否 | 归属类型0-借还柜 1-固资通) | `0` |
**返回类型:** `ResponseDTO<PageDTO<ShopGoodsDTO>>`
**响应字段:**
`ResponseDTO` 通用结构:
- `code`: Integer - 响应码
- `msg`: String - 响应消息
- `data`: `PageDTO<ShopGoodsDTO>` - 分页数据
`PageDTO` 分页结构:
- `total`: Long - 总记录数
- `rows`: `List<ShopGoodsDTO>` - 当前页数据列表
`ShopGoodsDTO` 商品详情字段:
| 字段名 | 类型 | 描述 |
|--------|------|------|
| `goodsId` | Long | 商品唯一ID |
| `goodsName` | String | 商品名称 |
| `categoryId` | Long | 分类ID |
| `externalGoodsId` | Long | 外部商品ID |
| `corpid` | String | 企业微信id |
| `categoryName` | String | 分类名称 |
| `belongType` | Long | 归属类型0-借还柜 1-固资通) |
| `price` | BigDecimal | 销售价格 |
| `stock` | Integer | 库存数量 |
| `status` | Integer | 商品状态1上架 2下架 |
| `autoApproval` | Integer | 免审批0否 1是 |
| `coverImg` | String | 封面图URL |
| `creatorId` | Long | 创建者ID |
| `creatorName` | String | 创建者名称 |
| `createTime` | Date | 创建时间 |
| `remark` | String | 备注 |
| `cabinetName` | String | 柜机名称 |
| `cellNo` | Integer | 格口号 |
| `cellNoStr` | String | 格口号(字符串格式) |
| `totalStock` | Integer | 已分配库存 |
| `shopNameStr` | String | 地址名称 |
| `usageInstruction` | String | 商品使用说明 |
| `monthlyPurchaseLimit` | Integer | 每人每月限购数量 |
**示例请求:**
```
GET /api/manage/goods/list?pageNum=1&pageSize=10&status=1&goodsName=测试
```
---
### 2. 新增商品
**接口路径:** `POST /api/manage/goods`
**功能描述:** 创建新商品。
**请求体类型:** `AddGoodsCommand` (继承自 `ShopGoodsEntity`)
**请求字段:**
| 字段名 | 类型 | 必填 | 描述 | 示例 |
|--------|------|------|------|------|
| `goodsName` | String | 是 | 商品名称 | `"测试商品"` |
| `categoryId` | Long | 否 | 商品分类ID | `1` |
| `externalGoodsId` | Long | 否 | 外部归属类型的商品ID | `1001` |
| `corpid` | String | 否 | 企业微信id | `"wxcorpid123"` |
| `monthlyPurchaseLimit` | Integer | 否 | 每人每月限购数量 | `5` |
| `price` | BigDecimal | 是 | 销售价格 | `99.99` |
| `stock` | Integer | 否 | 库存数量 | `100` |
| `status` | Integer | 否 | 商品状态1上架 2下架默认值需确认 | `1` |
| `autoApproval` | Integer | 否 | 免审批0否 1是默认值需确认 | `0` |
| `coverImg` | String | 否 | 封面图URL | `"https://example.com/image.jpg"` |
| `goodsDetail` | String | 否 | 商品详情支持2000汉字+10个图片链接 | `"<p>商品描述</p>"` |
| `remark` | String | 否 | 备注 | `"测试商品备注"` |
| `usageInstruction` | String | 否 | 商品使用说明 | `"使用说明内容"` |
| `belongType` | Integer | 否 | 归属类型0-借还柜 1-固资通) | `0` |
**注意:** `goodsId` 字段为自增主键,请求时无需提供。
**返回类型:** `ResponseDTO<Void>`
**响应字段:**
- `code`: Integer - 响应码
- `msg`: String - 响应消息
- `data`: null
**示例请求:**
```json
{
"goodsName": "测试商品",
"price": 99.99,
"stock": 100,
"status": 1,
"coverImg": "https://example.com/image.jpg"
}
```
---
### 3. 删除商品
**接口路径:** `DELETE /api/manage/goods/{goodsIds}`
**功能描述:** 批量删除商品。
**路径参数:**
- `goodsIds`: 商品ID列表多个ID用逗号分隔
**请求示例:**
```
DELETE /api/manage/goods/1,2,3
```
**内部处理:** 控制器将路径参数转换为 `BulkOperationCommand<Long>` 对象:
```java
{
"ids": [1, 2, 3] // Set<Long> 类型,自动去重
}
```
**返回类型:** `ResponseDTO<Void>`
**响应字段:**
- `code`: Integer - 响应码
- `msg`: String - 响应消息
- `data`: null
---
### 4. 修改商品
**接口路径:** `PUT /api/manage/goods/{goodsId}`
**功能描述:** 更新商品信息。
**路径参数:**
- `goodsId`: 商品ID
**请求体类型:** `UpdateGoodsCommand` (继承自 `AddGoodsCommand`)
**请求字段:** 与 `AddGoodsCommand` 相同,所有字段均为可选更新。
**特殊处理:** 接口会自动将路径参数中的 `goodsId` 设置到 command 对象中。
**返回类型:** `ResponseDTO<Void>`
**响应字段:**
- `code`: Integer - 响应码
- `msg`: String - 响应消息
- `data`: null
**示例请求:**
```
PUT /api/manage/goods/1
```
```json
{
"goodsName": "更新后的商品名称",
"price": 88.88
}
```
---
### 5. 获取单个商品信息
**接口路径:** `GET /api/manage/goods/getGoodsInfo`
**功能描述:** 根据商品ID获取单个商品的详细信息。
**查询参数:**
| 参数名 | 类型 | 必填 | 描述 | 示例 |
|--------|------|------|------|------|
| `goodsId` | Long | 是 | 商品ID | `1` |
**返回类型:** `ResponseDTO<ShopGoodsDTO>`
**响应字段:**
- `code`: Integer - 响应码
- `msg`: String - 响应消息
- `data`: `ShopGoodsDTO` - 商品详情字段同接口1
**示例请求:**
```
GET /api/manage/goods/getGoodsInfo?goodsId=1
```
---
## 通用数据结构
### ResponseDTO 通用响应结构
```java
public class ResponseDTO<T> {
private Integer code; // 响应码
private String msg; // 响应消息
private T data; // 响应数据
}
```
### PageDTO 分页结构
```java
public class PageDTO<T> {
private Long total; // 总记录数
private List<T> rows; // 当前页数据列表
}
```
### BulkOperationCommand 批量操作命令
```java
public class BulkOperationCommand<T> {
private Set<T> ids; // 操作ID集合自动去重
}
```
---
## 注意事项
1. **字段默认值:** 部分字段(如 `status`, `autoApproval`)的默认值需查看业务逻辑确认
2. **ID 生成:** `goodsId` 为数据库自增字段,创建时无需提供
3. **批量删除:** 删除接口支持批量操作ID列表会自动去重
4. **分页限制:** 最大页码200每页最大大小500
5. **时间格式:** 时间参数需使用 `yyyy-MM-dd` 格式
6. **企业微信集成:** `corpid` 字段用于企业微信多租户支持
---
## 相关实体类位置
| 类名 | 路径 |
|------|------|
| `ManageGoodsController` | `agileboot-api/src/main/java/com/agileboot/api/controller/manage/` |
| `ShopGoodsEntity` | `agileboot-domain/src/main/java/com/agileboot/domain/shop/goods/db/` |
| `AddGoodsCommand` | `agileboot-domain/src/main/java/com/agileboot/domain/shop/goods/command/` |
| `UpdateGoodsCommand` | `agileboot-domain/src/main/java/com/agileboot/domain/shop/goods/command/` |
| `ShopGoodsDTO` | `agileboot-domain/src/main/java/com/agileboot/domain/shop/goods/dto/` |
| `SearchShopGoodsQuery` | `agileboot-domain/src/main/java/com/agileboot/domain/shop/goods/query/` |
| `BulkOperationCommand` | `agileboot-domain/src/main/java/com/agileboot/domain/common/command/` |
---
## 架构设计说明
本控制器遵循项目的 **DDD/CQRS 架构**
- **查询操作**GET使用 `SearchShopGoodsQuery` 对象,通过 `GoodsApplicationService.getGoodsList()` 处理
- **命令操作**POST/PUT/DELETE使用 `AddGoodsCommand`/`UpdateGoodsCommand`/`BulkOperationCommand`,通过 `GoodsApplicationService` 的对应方法处理
- **数据流转**Controller → Command/Query → ApplicationService → Domain Model → DB Entity
**注意**`AddGoodsCommand` 和 `UpdateGoodsCommand` 都继承自 `ShopGoodsEntity`,因此共享相同的字段定义。在实际使用中,`goodsId` 字段在新增时由数据库自增生成,在更新时通过路径参数传入。
---
*文档生成时间2025-12-11*
*基于代码分析:`agileboot-api/src/main/java/com/agileboot/api/controller/manage/ManageGoodsController.java`*

View File

@ -2,36 +2,52 @@ import { request } from "@/http/axios"
import { PageDTO, ResponseData, BasePageQuery } from "../type"
export interface ShopGoodsDTO {
goodsId?: number
goodsName?: string
categoryId?: number
categoryName?: string
price?: number
stock?: number
status?: number
autoApproval?: number
coverImg?: string
creatorId?: number
creatorName?: string
createTime?: string
remark?: string
cabinetName?: string
cellNo?: number
cellNoStr?: string
totalStock?: number
usageInstruction?: string
goodsId?: number // 商品唯一ID
goodsName?: string // 商品名称
categoryId?: number // 分类ID
externalGoodsId?: number // 外部归属类型的商品ID
corpid?: string // 企业微信id
categoryName?: string // 分类名称
belongType?: number // 归属类型0-借还柜 1-固资通)
price?: number // 销售价格
stock?: number // 库存数量
status?: number // 商品状态1上架 2下架
autoApproval?: number // 免审批0否 1是
coverImg?: string // 封面图URL
creatorId?: number // 创建者ID
creatorName?: string // 创建者名称
createTime?: string // 创建时间
remark?: string // 备注
cabinetName?: string // 柜机名称
cellNo?: number // 格口号
cellNoStr?: string // 格口号(字符串格式)
totalStock?: number // 已分配库存
shopNameStr?: string // 地址名称
usageInstruction?: string // 商品使用说明
monthlyPurchaseLimit?: number // 每人每月限购数量
goodsDetail?: string // 商品详情支持2000汉字+10个图片链接
}
export interface SearchShopGoodsQuery extends BasePageQuery {
goodsName?: string
categoryId?: number
status?: number
autoApproval?: number
minPrice?: number
maxPrice?: number
goodsName?: string // 商品名称(模糊查询)
categoryId?: number // 商品分类ID
externalGoodsId?: number // 外部归属类型的商品ID
corpid?: string // 企业微信id
status?: number // 商品状态1上架 2下架
autoApproval?: number // 免审批0否 1是
minPrice?: number // 最低价格
maxPrice?: number // 最高价格
belongType?: number // 归属类型0-借还柜 1-固资通)
beginTime?: string // 开始时间格式yyyy-MM-dd
endTime?: string // 结束时间格式yyyy-MM-dd
}
/** 获取商品列表 */
/**
*
*
* @param query
* @returns Promise<ResponseData<PageDTO<ShopGoodsDTO>>>
*/
export function getGoodsList(query: SearchShopGoodsQuery) {
return request<ResponseData<PageDTO<ShopGoodsDTO>>>({
url: "manage/goods/list",
@ -40,17 +56,27 @@ export function getGoodsList(query: SearchShopGoodsQuery) {
})
}
/** 新增商品 */
/**
*
*
* @param data
* @returns Promise<ResponseData<void>>
*/
export function addGoods(data: {
goodsName: string
categoryId: number
price: number
stock: number
status: number
autoApproval: number
coverImg: string
goodsDetail?: string
usageInstruction?: string
goodsName: string // 商品名称(必填)
categoryId?: number // 商品分类ID
externalGoodsId?: number // 外部归属类型的商品ID
corpid?: string // 企业微信id
monthlyPurchaseLimit?: number // 每人每月限购数量
price: number // 销售价格(必填)
stock?: number // 库存数量
status?: number // 商品状态1上架 2下架
autoApproval?: number // 免审批0否 1是
coverImg?: string // 封面图URL
goodsDetail?: string // 商品详情支持2000汉字+10个图片链接
remark?: string // 备注
usageInstruction?: string // 商品使用说明
belongType?: number // 归属类型0-借还柜 1-固资通)
}) {
return request<ResponseData<void>>({
url: "manage/goods",
@ -59,7 +85,12 @@ export function addGoods(data: {
})
}
/** 删除商品 */
/**
*
*
* @param goodsIds ID数组
* @returns Promise<ResponseData<void>>
*/
export function deleteGoods(goodsIds: number[]) {
return request<ResponseData<void>>({
url: `manage/goods/${goodsIds.join(',')}`,
@ -67,17 +98,28 @@ export function deleteGoods(goodsIds: number[]) {
})
}
/** 修改商品 */
/**
*
*
* @param goodsId ID
* @param data
* @returns Promise<ResponseData<void>>
*/
export function updateGoods(goodsId: number, data: {
goodsName?: string
categoryId?: number
price?: number
stock?: number
status?: number
autoApproval?: number
coverImg?: string
goodsDetail?: string
usageInstruction?: string
goodsName?: string // 商品名称
categoryId?: number // 商品分类ID
externalGoodsId?: number // 外部归属类型的商品ID
corpid?: string // 企业微信id
monthlyPurchaseLimit?: number // 每人每月限购数量
price?: number // 销售价格
stock?: number // 库存数量
status?: number // 商品状态1上架 2下架
autoApproval?: number // 免审批0否 1是
coverImg?: string // 封面图URL
goodsDetail?: string // 商品详情支持2000汉字+10个图片链接
remark?: string // 备注
usageInstruction?: string // 商品使用说明
belongType?: number // 归属类型0-借还柜 1-固资通)
}) {
return request<ResponseData<void>>({
url: `manage/goods/${goodsId}`,
@ -86,7 +128,12 @@ export function updateGoods(goodsId: number, data: {
})
}
/** 获取单个商品信息 */
/**
*
* ID获取商品的详细信息
* @param goodsId ID
* @returns Promise<ResponseData<ShopGoodsDTO>>
*/
export function getGoodsInfo(goodsId: number) {
return request<ResponseData<ShopGoodsDTO>>({
url: "manage/goods/getGoodsInfo",

View File

@ -20,6 +20,11 @@ const wxStore = useWxStoreOutside();
const tabbarItemList = computed(() => {
if (wxStore.isCabinetAdmin) {
return [{
title: '商品管理',
icon: 'home-o',
path: '/manage/goods'
},
{
title: '柜机管理',
icon: 'manager-o',
path: '/cabinet'

View File

@ -0,0 +1,387 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import Compressor from 'compressorjs'
import axios from "axios"
import { UploaderFileListItem } from 'vant'
import {
addGoods,
updateGoods,
getGoodsInfo,
type ShopGoodsDTO
} from '@/common/apis/manage/goods'
import { useWxStoreOutside } from '@/pinia/stores/wx'
const { VITE_APP_BASE_API } = import.meta.env
const route = useRoute()
const router = useRouter()
const wxStore = useWxStoreOutside()
//
const isEditMode = ref(false)
const goodsId = ref<number | undefined>(undefined)
//
const formData = reactive({
goodsName: '',
categoryId: undefined as number | undefined,
externalGoodsId: undefined as number | undefined,
corpid: wxStore.corpid || '',
monthlyPurchaseLimit: undefined as number | undefined,
price: undefined as number | undefined,
stock: undefined as number | undefined,
status: 1, //
autoApproval: 0, //
coverImg: '',
goodsDetail: '',
remark: '',
usageInstruction: '',
belongType: undefined as number | undefined
})
//
const loading = ref(false)
const submitting = ref(false)
//
const fileList = ref<UploaderFileListItem[]>([])
const uploading = ref(false)
// URL
watch(fileList, (newList) => {
if (newList.length === 0) {
formData.coverImg = ''
} else {
// URLURL
const item = newList[0]
if (item.url) {
formData.coverImg = item.url
}
}
}, { deep: true })
//
const loadGoodsData = async (id: number) => {
try {
loading.value = true
const res = await getGoodsInfo(id)
const goods = res.data
//
Object.assign(formData, {
goodsName: goods.goodsName || '',
categoryId: goods.categoryId,
externalGoodsId: goods.externalGoodsId,
corpid: goods.corpid || wxStore.corpid || '',
monthlyPurchaseLimit: goods.monthlyPurchaseLimit,
price: goods.price,
stock: goods.stock,
status: goods.status || 1,
autoApproval: goods.autoApproval || 0,
coverImg: goods.coverImg || '',
goodsDetail: goods.goodsDetail || '',
remark: goods.remark || '',
usageInstruction: goods.usageInstruction || '',
belongType: goods.belongType
})
// URL
if (goods.coverImg) {
fileList.value = [{
url: goods.coverImg,
status: 'done',
message: '已上传'
}]
}
} catch (error) {
console.error('加载商品数据失败:', error)
showFailToast('加载商品数据失败')
router.back()
} finally {
loading.value = false
}
}
//
const handleFileUpload = async (items: UploaderFileListItem | UploaderFileListItem[]) => {
const files = Array.isArray(items) ? items : [items]
uploading.value = true
try {
const uploadPromises = files.map(async (item) => {
item.status = 'uploading'
item.message = '上传中...'
const file = item.file as File
let compressedFile = file
try {
compressedFile = await new Promise<File>((resolve, reject) => {
new Compressor(file, {
quality: 0.8,
maxWidth: 1280,
maxHeight: 1280,
success(result) {
resolve(new File([result], file.name, {
type: 'image/jpeg',
lastModified: Date.now()
}))
},
error(err) {
reject(err)
}
})
})
} catch (error) {
console.error('压缩失败:', error)
}
const uploadFormData = new FormData()
uploadFormData.append('file', compressedFile)
const { data } = await axios.post<{
code: number
data: { url: string }
message?: string
}>(VITE_APP_BASE_API + '/file/upload', uploadFormData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (data.code !== 0) {
throw new Error(data.message || '文件上传失败')
}
return { url: data.data.url }
})
const urls = await Promise.all(uploadPromises)
files.forEach((item, index) => {
item.status = 'done'
item.message = '上传成功'
item.url = urls[index].url
})
} catch (error) {
showConfirmDialog({
title: '上传失败',
message: error instanceof Error ? error.message : '未知错误'
})
} finally {
uploading.value = false
}
}
//
onMounted(() => {
const id = route.params.id
if (id) {
const idStr = Array.isArray(id) ? id[0] : id
if (idStr) {
isEditMode.value = true
goodsId.value = parseInt(idStr, 10)
if (!isNaN(goodsId.value)) {
loadGoodsData(goodsId.value)
} else {
showFailToast('商品ID无效')
router.back()
}
}
}
})
//
const submitForm = async () => {
submitting.value = true
try {
//
if (!formData.goodsName.trim()) {
showFailToast('请输入商品名称')
submitting.value = false
return
}
if (!formData.price || formData.price <= 0) {
showFailToast('请输入有效的价格')
submitting.value = false
return
}
if (isEditMode.value && goodsId.value) {
//
await updateGoods(goodsId.value, {
goodsName: formData.goodsName,
price: formData.price,
stock: formData.stock,
status: formData.status,
autoApproval: formData.autoApproval,
coverImg: formData.coverImg || undefined,
usageInstruction: formData.usageInstruction || undefined,
corpid: formData.corpid || undefined
})
showSuccessToast('更新成功')
} else {
//
await addGoods({
goodsName: formData.goodsName,
price: formData.price!,
stock: formData.stock,
status: formData.status,
autoApproval: formData.autoApproval,
coverImg: formData.coverImg || undefined,
usageInstruction: formData.usageInstruction || undefined,
corpid: formData.corpid || undefined
})
showSuccessToast('添加成功')
}
//
router.push('/manage/goods')
} catch (error) {
console.error('提交失败:', error)
showFailToast('提交失败,请重试')
} finally {
submitting.value = false
}
}
//
const handleBack = () => {
showConfirmDialog({
title: '提示',
message: '确定放弃编辑并返回吗?'
}).then(() => {
router.back()
}).catch(() => {
//
})
}
</script>
<template>
<div class="goods-add-edit-page">
<!-- 导航栏 -->
<van-nav-bar
:title="isEditMode ? '编辑商品' : '新增商品'"
left-arrow
@click-left="handleBack"
>
<template #right>
<van-button
v-if="!loading"
size="small"
type="primary"
:loading="submitting"
@click="submitForm"
>
{{ submitting ? '提交中...' : '提交' }}
</van-button>
</template>
</van-nav-bar>
<!-- 加载中 -->
<van-loading v-if="loading" size="24px" vertical>加载中...</van-loading>
<!-- 表单 -->
<van-form v-else @submit="submitForm">
<van-cell-group inset>
<!-- 商品名称 -->
<van-field
v-model="formData.goodsName"
label="商品名称"
placeholder="请输入商品名称"
:rules="[{ required: true, message: '请输入商品名称' }]"
/>
<!-- 价格 -->
<van-field
v-model.number="formData.price"
label="价格"
type="number"
placeholder="请输入价格"
:rules="[{ required: true, message: '请输入价格' }]"
>
<template #extra>
<span v-if="formData.price !== undefined">¥{{ formData.price.toFixed(2) }}</span>
</template>
</van-field>
<!-- 库存 -->
<van-field
v-model.number="formData.stock"
label="库存"
type="number"
placeholder="请输入库存数量"
/>
<!-- 状态 -->
<van-field name="status" label="状态">
<template #input>
<van-radio-group v-model="formData.status" direction="horizontal">
<van-radio :name="1">上架</van-radio>
<van-radio :name="2">下架</van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 免审批 -->
<van-field name="autoApproval" label="免审批">
<template #input>
<van-radio-group v-model="formData.autoApproval" direction="horizontal">
<van-radio :name="0"></van-radio>
<van-radio :name="1"></van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 封面图 -->
<van-field name="coverImg" label="封面图">
<template #input>
<van-uploader
v-model="fileList"
:max-count="1"
:after-read="handleFileUpload"
:disabled="uploading"
/>
</template>
</van-field>
<!-- 使用说明 -->
<van-field
v-model="formData.usageInstruction"
label="使用说明"
type="textarea"
placeholder="请输入商品使用说明"
rows="2"
autosize
/>
</van-cell-group>
<!-- 提交按钮 -->
<div style="margin: 16px">
<van-button round block type="primary" native-type="submit" :loading="submitting">
{{ submitting ? '提交中...' : '提交' }}
</van-button>
</div>
</van-form>
</div>
</template>
<style scoped>
.goods-add-edit-page {
min-height: 100vh;
background-color: #f7f8fa;
}
.van-form {
padding-top: 12px;
}
.van-field__label {
min-width: 90px;
}
</style>

View File

@ -0,0 +1,274 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showLoadingToast, closeToast, showFailToast } from 'vant'
import { getGoodsInfo, type ShopGoodsDTO } from '@/common/apis/manage/goods'
const route = useRoute()
const router = useRouter()
const goodsId = ref<number | null>(null)
const goods = ref<ShopGoodsDTO>({})
const loading = ref(false)
//
const fetchGoodsDetail = async (id: number) => {
try {
loading.value = true
const toast = showLoadingToast({ message: '加载中...', duration: 0 })
const res = await getGoodsInfo(id)
goods.value = res.data
closeToast()
} catch (error) {
console.error('加载商品详情失败:', error)
showFailToast('加载失败')
router.back()
} finally {
loading.value = false
}
}
//
const navigateToEdit = () => {
if (goods.value.goodsId) {
router.push(`/manage/goods/edit/${goods.value.goodsId}`)
}
}
//
onMounted(() => {
const id = route.params.id
if (id) {
const idStr = Array.isArray(id) ? id[0] : id
if (idStr) {
goodsId.value = parseInt(idStr, 10)
if (!isNaN(goodsId.value)) {
fetchGoodsDetail(goodsId.value)
} else {
showFailToast('商品ID无效')
router.back()
}
}
}
})
</script>
<template>
<div class="goods-detail-page">
<!-- 导航栏 -->
<van-nav-bar
title="商品详情"
left-arrow
@click-left="router.back"
/>
<!-- 加载中 -->
<van-loading v-if="loading" size="24px" vertical>加载中...</van-loading>
<!-- 商品详情内容 -->
<div v-else class="goods-content">
<!-- 商品图片 -->
<div class="goods-image-section">
<van-image
v-if="goods.coverImg"
:src="goods.coverImg"
class="goods-main-image"
fit="cover"
>
<template #error>
<div class="image-error">
<van-icon name="photo-fail" size="40" />
<div>图片加载失败</div>
</div>
</template>
</van-image>
<div v-else class="no-image">
<van-icon name="photo" size="60" />
<div>暂无图片</div>
</div>
</div>
<!-- 商品基本信息 -->
<div class="goods-basic-info">
<h2 class="goods-name">{{ goods.goodsName || '未知商品' }}</h2>
<div class="goods-price">¥{{ goods.price?.toFixed(2) || '0.00' }}</div>
<div class="goods-meta">
<van-tag v-if="goods.status === 1" type="success">上架</van-tag>
<van-tag v-else-if="goods.status === 2" type="danger">下架</van-tag>
<van-tag v-if="goods.stock! > 0" type="primary">库存: {{ goods.stock }}</van-tag>
<van-tag v-else type="warning">已售罄</van-tag>
<van-tag v-if="goods.autoApproval === 1" type="success">免审批</van-tag>
</div>
</div>
<!-- 商品详情 -->
<div class="goods-detail-section">
<h3 class="section-title">商品详情</h3>
<div class="detail-content" v-html="goods.goodsDetail || '暂无商品详情'"></div>
</div>
<!-- 使用说明 -->
<div v-if="goods.usageInstruction" class="usage-section">
<h3 class="section-title">使用说明</h3>
<div class="usage-content">{{ goods.usageInstruction }}</div>
</div>
<!-- 备注 -->
<div v-if="goods.remark" class="remark-section">
<h3 class="section-title">备注</h3>
<div class="remark-content">{{ goods.remark }}</div>
</div>
</div>
<!-- 固定在右下角的圆形编辑按钮 -->
<div class="floating-edit-button">
<van-button
round
type="primary"
size="large"
icon="edit"
@click="navigateToEdit"
/>
</div>
</div>
</template>
<style scoped>
.goods-detail-page {
min-height: 100vh;
background-color: #f7f8fa;
padding-bottom: 80px; /* 为浮动按钮留出空间 */
}
.goods-content {
padding: 12px;
}
/* 商品图片区域 */
.goods-image-section {
background: white;
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.goods-main-image {
width: 100%;
height: 300px;
display: block;
}
.image-error {
width: 100%;
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #969799;
background-color: #f7f8fa;
}
.no-image {
width: 100%;
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #969799;
background-color: #f7f8fa;
}
/* 商品基本信息 */
.goods-basic-info {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.goods-name {
font-size: 18px;
color: #333;
font-weight: 600;
margin: 0 0 8px 0;
line-height: 1.4;
}
.goods-price {
font-size: 24px;
color: #ff4444;
font-weight: 700;
margin: 8px 0 12px 0;
}
.goods-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
/* 详情区域 */
.goods-detail-section,
.usage-section,
.remark-section {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.section-title {
font-size: 16px;
color: #333;
font-weight: 600;
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.detail-content {
font-size: 14px;
color: #555;
line-height: 1.6;
}
.detail-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 8px 0;
}
.usage-content,
.remark-content {
font-size: 14px;
color: #555;
line-height: 1.6;
white-space: pre-wrap;
}
/* 浮动编辑按钮 */
.floating-edit-button {
position: fixed;
right: 20px;
bottom: 30px;
z-index: 1000;
}
.floating-edit-button .van-button {
width: 56px;
height: 56px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.floating-edit-button .van-button .van-icon {
font-size: 20px;
}
</style>

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, onMounted, watch, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { showConfirmDialog, showDialog, showSuccessToast } from 'vant';
import { debounce } from 'lodash-es';
import {
getGoodsList,
addGoods,
deleteGoods,
updateGoods,
type ShopGoodsDTO,
type SearchShopGoodsQuery
} from '@/common/apis/manage/goods';
@ -23,14 +23,13 @@ const searchParams = reactive<SearchShopGoodsQuery>({
status: undefined
});
//
const showEditDialog = ref(false);
const currentGoods = ref<Partial<ShopGoodsDTO>>({});
const isEditMode = ref(false);
//
const router = useRouter();
//
const fetchGoodsList = async () => {
try {
searchParams.pageNum = 1;
const res = await getGoodsList(searchParams);
goodsList.value = res.data.rows;
} catch (e) {
@ -38,6 +37,23 @@ const fetchGoodsList = async () => {
}
};
//
const debouncedFetchGoodsList = debounce(fetchGoodsList, 500);
//
watch(() => searchParams.goodsName, debouncedFetchGoodsList);
//
onUnmounted(() => {
debouncedFetchGoodsList.cancel();
});
//
const handleSearch = () => {
debouncedFetchGoodsList.cancel();
fetchGoodsList();
};
//
const onLoad = async () => {
try {
@ -68,36 +84,21 @@ const handleDelete = async (ids: number[]) => {
await fetchGoodsList();
};
//
const submitForm = async () => {
try {
if (isEditMode.value) {
await updateGoods(currentGoods.value.goodsId!, currentGoods.value);
} else {
await addGoods({
goodsName: currentGoods.value.goodsName || '',
categoryId: currentGoods.value.categoryId || 0,
price: currentGoods.value.price || 0,
stock: currentGoods.value.stock || 0,
status: currentGoods.value.status || 1,
autoApproval: currentGoods.value.autoApproval || 0,
coverImg: currentGoods.value.coverImg || '',
usageInstruction: currentGoods.value.usageInstruction
});
}
showSuccessToast('操作成功');
showEditDialog.value = false;
await fetchGoodsList();
} catch (e) {
showDialog({ message: '操作失败' });
// /
const navigateToEdit = (goods?: ShopGoodsDTO) => {
if (goods?.goodsId) {
router.push(`/manage/goods/edit/${goods.goodsId}`);
} else {
router.push('/manage/goods/add');
}
};
//
const openEdit = (goods?: ShopGoodsDTO) => {
currentGoods.value = goods ? { ...goods } : { status: 1 };
isEditMode.value = !!goods;
showEditDialog.value = true;
//
const navigateToDetail = (goods: ShopGoodsDTO) => {
if (goods?.goodsId) {
router.push(`/manage/goods/detail/${goods.goodsId}`);
}
};
onMounted(fetchGoodsList);
@ -107,16 +108,16 @@ onMounted(fetchGoodsList);
<div class="goods-manage">
<!-- 搜索栏和操作按钮 -->
<div class="search-action-bar">
<van-search v-model="searchParams.goodsName" placeholder="输入商品名称搜索" @search="fetchGoodsList" />
<van-button type="primary" @click="openEdit">添加商品</van-button>
<van-search v-model="searchParams.goodsName" placeholder="输入商品名称搜索" @search="handleSearch" />
<van-button type="primary" @click="navigateToEdit">添加商品</van-button>
</div>
<!-- 商品表格 -->
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<div class="goods-grid">
<van-cell v-for="item in goodsList" :key="item.goodsId" class="goods-card">
<template #icon>
<van-image :src="item.coverImg" width="80" height="80" class="goods-image">
<div v-for="item in goodsList" :key="item.goodsId" class="goods-card" @click="navigateToDetail(item)" style="cursor: pointer;">
<div class="goods-image-container">
<van-image :src="item.coverImg" class="goods-image">
<div v-if="item.stock === 0" class="sold-out-overlay">
<span class="sold-out-text">已售罄</span>
</div>
@ -126,109 +127,141 @@ onMounted(fetchGoodsList);
</div>
</template>
</van-image>
</template>
</div>
<div class="goods-info">
<div class="goods-name van-ellipsis">{{ item.goodsName }}</div>
<div class="goods-price">¥{{ item.price?.toFixed(2) }}</div>
<div class="action-row">
<div class="goods-footer">
<span v-if="item.stock! > 0" class="stock-count">库存: {{ item.stock }}</span>
<div class="goods-actions">
<van-button size="mini" @click="openEdit(item)">编辑</van-button>
</div>
</div>
</div>
</van-cell>
</div>
</div>
</van-list>
<!-- 编辑弹窗 -->
<van-dialog v-model:show="showEditDialog" :title="isEditMode ? '编辑商品' : '新增商品'">
<van-form @submit="submitForm">
<van-cell-group inset>
<van-field v-model="currentGoods.goodsName" label="商品名称" placeholder="请输入"
:rules="[{ required: true }]" />
<van-field v-model="currentGoods.price" label="价格" type="number" placeholder="请输入" />
<van-field v-model="currentGoods.stock" label="库存" type="number" placeholder="请输入" />
</van-cell-group>
<div style="padding: 16px">
<van-button block type="primary" native-type="submit">提交</van-button>
</div>
</van-form>
</van-dialog>
</div>
</template>
<style scoped>
.goods-manage {
padding: 4px;
padding: 12px;
background-color: #f7f8fa;
min-height: 100vh;
}
.search-action-bar {
margin: 0;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 4px;
gap: 12px;
background: white;
padding: 8px 12px;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.search-action-bar .van-search {
flex: 1;
background: transparent;
padding: 0;
}
.search-action-bar .van-search .van-search__content {
border-radius: 8px;
background-color: #f7f8fa;
}
.goods-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 4px;
margin: 4px 0;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin: 0;
}
.goods-card {
margin-bottom: 10px;
padding: min(2.667vw, 20px) 0;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
}
.goods-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.goods-image-container {
position: relative;
width: 100%;
padding-top: 100%; /* 1:1 正方形比例 */
background-color: #f7f8fa;
}
.goods-image {
margin-right: 12px;
border-radius: 4px;
overflow: hidden;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.goods-image .van-image__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.goods-info {
padding: 12px;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 80px;
position: relative;
gap: 6px;
}
.goods-name {
font-size: 14px;
color: #333;
line-height: 1.4;
text-align: left;
font-weight: 500;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 40px;
}
.goods-price {
font-size: 16px;
color: #e95d5d;
font-weight: bold;
text-align: left;
font-size: 18px;
color: #ff4444;
font-weight: 700;
margin: 4px 0;
}
.action-row {
.goods-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
align-items: center;
margin-top: auto;
}
.stock-count {
font-size: 11px;
color: #bbbbbb;
margin-right: 8px;
.goods-actions {
display: flex;
align-items: flex-end;
height: 100%;
line-height: 1;
gap: 4px;
}
.stock-count {
font-size: 12px;
color: #969799;
background-color: #f7f8fa;
padding: 2px 8px;
border-radius: 10px;
font-weight: 400;
}
.sold-out-overlay {
@ -237,25 +270,37 @@ onMounted(fetchGoodsList);
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.sold-out-text {
color: #999;
color: #ff4444;
font-size: 14px;
transform: rotate(-15deg);
border: 1px solid #eee;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
background: rgba(255, 255, 255, 0.9);
padding: 6px 12px;
border-radius: 20px;
border: 2px solid #ff4444;
transform: rotate(-5deg);
box-shadow: 0 2px 8px rgba(255, 68, 68, 0.2);
}
.custom-error {
color: #999;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #969799;
font-size: 12px;
text-align: center;
padding: 10px;
background-color: #f7f8fa;
}
</style>

View File

@ -181,7 +181,7 @@ export const routes: RouteRecordRaw[] = [
}
}
},
/* {
{
path: '/manage/goods',
component: () => import('@/pages/manage/goods/goodsList.vue'),
name: "ManageGoods",
@ -190,16 +190,70 @@ export const routes: RouteRecordRaw[] = [
keepAlive: false,
layout: {
navBar: {
showNavBar: true,
showLeftArrow: true
showNavBar: false,
showLeftArrow: false
},
tabbar: {
showTabbar: false,
showTabbar: true,
icon: "home-o"
}
}
}
}, */
},
{
path: '/manage/goods/add',
component: () => import('@/pages/manage/goods/goodsAddEdit.vue'),
name: "ManageGoodsAdd",
meta: {
title: '新增商品',
keepAlive: false,
layout: {
navBar: {
showNavBar: true,
showLeftArrow: true
},
tabbar: {
showTabbar: false
}
}
}
},
{
path: '/manage/goods/edit/:id',
component: () => import('@/pages/manage/goods/goodsAddEdit.vue'),
name: "ManageGoodsEdit",
meta: {
title: '编辑商品',
keepAlive: false,
layout: {
navBar: {
showNavBar: true,
showLeftArrow: true
},
tabbar: {
showTabbar: false
}
}
}
},
{
path: '/manage/goods/detail/:id',
component: () => import('@/pages/manage/goods/goodsDetail.vue'),
name: "ManageGoodsDetail",
meta: {
title: '商品详情',
keepAlive: false,
layout: {
navBar: {
showNavBar: true,
showLeftArrow: true
},
tabbar: {
showTabbar: false
}
}
}
},
{
path: "/productList",
component: () => import("@/pages/product/ProductList.vue"),

View File

@ -31,6 +31,8 @@ declare module 'vue' {
VanPicker: typeof import('vant/es')['Picker']
VanPopup: typeof import('vant/es')['Popup']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanRadio: typeof import('vant/es')['Radio']
VanRadioGroup: typeof import('vant/es')['RadioGroup']
VanRow: typeof import('vant/es')['Row']
VanSearch: typeof import('vant/es')['Search']
VanSidebar: typeof import('vant/es')['Sidebar']