feat(订单): 新增借还动态功能

- 在订单模块中添加借还动态接口及相关类型定义
- 创建借还动态接口文档,详细说明接口使用方式
- 在欢迎页面新增借还动态展示区域,使用时间轴形式展示借出和归还记录
- 实现动态记录的筛选、状态显示和图片预览功能
This commit is contained in:
dzq 2025-11-29 17:41:10 +08:00
parent 5e5f2a5fb3
commit 3e47804f7e
3 changed files with 731 additions and 19 deletions

View File

@ -0,0 +1,245 @@
# 借还动态接口文档
## 概述
本文档介绍新增加的借还动态列表接口,该接口将借出和归还操作分别作为独立记录返回,相比原有的 `getBorrowReturnRecordList` 接口,能更清晰地展示订单的生命周期。
## 接口信息
### 新增接口
**接口地址**: `GET /shop/order/borrow-return-dynamic`
**功能描述**: 查询借还动态分页列表,返回借出和归还的独立记录
## 数据结构
### 请求参数
#### SearchBorrowReturnDynamicQuery
| 字段名 | 类型 | 是否必填 | 说明 |
|--------|------|----------|------|
| goodsId | Long | 否 | 商品ID精确筛选 |
| status | Integer | 否 | 状态筛选(仅对归还记录有效)<br>- 0: 未退还<br>- 1: 待审批<br>- 2: 已通过<br>- 3: 已驳回<br>- 4: 开柜中 |
| dynamicType | Integer | 否 | 动态类型筛选<br>- 0: 借出记录<br>- 1: 归还记录 |
| pageNum | Integer | 否 | 页码默认1 |
| pageSize | Integer | 否 | 每页大小默认10 |
### 响应数据
#### BorrowReturnDynamicDTO
| 字段名 | 类型 | 说明 |
|--------|------|------|
| orderGoodsId | Long | 订单商品ID |
| dynamicType | Integer | 动态类型0-借出 1-归还) |
| dynamicTypeStr | String | 动态类型描述 |
| orderId | Long | 订单ID |
| orderTime | Date | 订单创建时间/借出时间 |
| goodsId | Long | 商品ID |
| goodsName | String | 商品名称 |
| goodsPrice | BigDecimal | 商品单价 |
| quantity | Integer | 数量 |
| paymentMethod | String | 支付方式 |
| orderName | String | 订单姓名 |
| orderMobile | String | 订单手机号 |
| cellId | Long | 格口ID |
| cellNo | Integer | 格口号 |
| cabinetId | Long | 柜机ID |
| cabinetName | String | 柜机名称 |
| operateTime | Date | 归还/审批时间(归还记录时有效) |
| approvalId | Long | 审批ID归还记录时有效 |
| status | Integer | 审批状态(归还记录时有效) |
| statusStr | String | 状态描述 |
| auditName | String | 审批人(归还记录时有效) |
| auditRemark | String | 审核说明(归还记录时有效) |
| images | String | 归还图片(归还记录时有效) |
## 业务说明
### 记录分类
每条订单商品会产生两条动态记录:
1. **借出记录** (`dynamicType = 0`)
- 记录时间:`orderTime` = 订单创建时间
- 状态:固定为 0未退还
- 审批相关字段:均为 null
2. **归还记录** (`dynamicType = 1`)
- 记录时间:`operateTime` = 审批时间(若无则为创建时间)
- 状态:实际的审批状态
- 审批相关字段:审批人、审核说明、归还图片等
### 与原接口对比
| 特性 | getBorrowReturnRecordList | getBorrowReturnDynamicList |
|------|---------------------------|----------------------------|
| 记录方式 | 一条记录包含借出和归还信息 | 借出和归还分别独立记录 |
| 时间展示 | 只显示一个时间 | 分别显示借出时间和归还时间 |
| 状态展示 | 状态混合显示 | 归还记录显示审批状态,借出记录固定为未退还 |
| 数据清晰度 | 信息集中但复杂 | 信息分离但清晰 |
| 适用场景 | 需要快速查看整体情况 | 需要追踪完整操作流程 |
## 使用示例
### 示例1: 查询所有动态记录
**请求**:
```http
GET /shop/order/borrow-return-dynamic
```
**响应**:
```json
{
"code": 200,
"msg": "成功",
"data": {
"records": [
{
"orderGoodsId": 1001,
"dynamicType": 0,
"dynamicTypeStr": "借出",
"orderId": 1000,
"orderTime": "2025-11-28 10:00:00",
"goodsId": 200,
"goodsName": "iPad",
"goodsPrice": 3000.00,
"quantity": 1,
"paymentMethod": "wechat",
"orderName": "张三",
"orderMobile": "13800138000",
"cellId": 1,
"cellNo": 5,
"cabinetId": 1,
"cabinetName": "主柜A",
"operateTime": null,
"approvalId": null,
"status": 0,
"statusStr": "未退还",
"auditName": null,
"auditRemark": null,
"images": null
},
{
"orderGoodsId": 1001,
"dynamicType": 1,
"dynamicTypeStr": "归还",
"orderId": 1000,
"orderTime": "2025-11-28 10:00:00",
"goodsId": 200,
"goodsName": "iPad",
"goodsPrice": 3000.00,
"quantity": 1,
"paymentMethod": "wechat",
"orderName": "张三",
"orderMobile": "13800138000",
"cellId": 1,
"cellNo": 5,
"cabinetId": 1,
"cabinetName": "主柜A",
"operateTime": "2025-11-28 14:00:00",
"approvalId": 500,
"status": 2,
"statusStr": "已通过",
"auditName": "李四",
"auditRemark": "商品完好",
"images": "/images/return/123.jpg"
}
],
"total": 2,
"size": 10,
"current": 1,
"orders": [],
"hitCount": false,
"searchCount": true,
"pages": 1
}
}
```
### 示例2: 仅查询借出记录
**请求**:
```http
GET /shop/order/borrow-return-dynamic?dynamicType=0
```
### 示例3: 查询已通过的归还记录
**请求**:
```http
GET /shop/order/borrow-return-dynamic?dynamicType=1&status=2
```
### 示例4: 按商品筛选
**请求**:
```http
GET /shop/order/borrow-return-dynamic?goodsId=200
```
## 实现细节
### 数据库查询
接口使用 `UNION ALL` 查询实现:
1. 第一部分:查询借出记录
- 来源:`shop_order` + `shop_order_goods` + `cabinet_cell` + `smart_cabinet`
- 时间字段:`so.create_time`
- 状态:固定值 0
2. 第二部分:查询归还记录
- 来源:`shop_order` + `shop_order_goods` + `return_approval` + `cabinet_cell` + `smart_cabinet`
- 时间字段:`COALESCE(ra.approval_time, ra.create_time)`
- 状态:实际审批状态
### 服务层实现
- **Mapper**: `ShopOrderMapper.getBorrowReturnDynamicList()`
- **Service**: `ShopOrderService.getBorrowReturnDynamicList()`
- **ApplicationService**: `OrderApplicationService.getBorrowReturnDynamicList()`
- **Controller**: `ShopOrderController.getBorrowReturnDynamicList()`
### 分页处理
使用 MyBatis-Plus 的 `Page` 插件实现分页查询,支持:
- 页码控制pageNum
- 每页大小控制pageSize
- 总记录数统计
## 注意事项
1. **状态筛选**: `status` 参数仅对 `dynamicType=1`(归还记录)有效,对借出记录无效
2. **空值处理**: 借出记录的审批相关字段approvalId、auditName、auditRemark、images均为 null
3. **时间排序**: 查询结果按动态发生时间dynamic_time倒序排列
4. **数据权限**: 接口遵循现有的数据权限控制逻辑
5. **性能考虑**: 大量数据时建议使用分页,并合理设置筛选条件
## 相关文件
### 新增文件
- `agileboot-domain/src/main/java/com/agileboot/domain/shop/order/dto/BorrowReturnDynamicDTO.java`
- `agileboot-domain/src/main/java/com/agileboot/domain/shop/order/query/SearchBorrowReturnDynamicQuery.java`
### 修改文件
- `agileboot-domain/src/main/java/com/agileboot/domain/shop/order/db/ShopOrderMapper.java`
- `agileboot-domain/src/main/java/com/agileboot/domain/shop/order/db/ShopOrderService.java`
- `agileboot-domain/src/main/java/com/agileboot/domain/shop/order/db/ShopOrderServiceImpl.java`
- `agileboot-domain/src/main/java/com/agileboot/domain/shop/order/OrderApplicationService.java`
- `agileboot-admin/src/main/java/com/agileboot/admin/controller/shop/ShopOrderController.java`
## 更新日志
| 日期 | 版本 | 更新内容 |
|------|------|----------|
| 2025-11-28 | v1.0 | 新增借还动态接口,支持借出和归还分别独立查询 |
## 联系信息
如有问题或建议,请联系开发团队。

View File

@ -203,4 +203,85 @@ export const getBorrowReturnRecordListApi = (params?: BorrowReturnRecordQuery) =
return http.request<ResponseData<PageDTO<BorrowReturnRecordDTO>>>("get", "/shop/order/borrow-return-record", {
params
});
};
// 借还动态接口相关定义
export interface BorrowReturnDynamicQuery extends BasePageQuery {
/** 商品ID */
goodsId?: number;
/** 格口ID精确筛选 */
cellId?: number;
/**
*
* @remarks
* 0-退 | 1- | 2- | 3- | 4-
*/
status?: number;
/**
*
* @remarks
* 0- | 1-
*/
dynamicType?: number;
}
export interface BorrowReturnDynamicDTO {
/** 订单商品ID */
orderGoodsId: number;
/** 动态类型0-借出 1-归还) */
dynamicType: number;
/** 动态类型描述 */
dynamicTypeStr: string;
/** 订单ID */
orderId: number;
/** 订单创建时间/借出时间 */
orderTime?: Date;
/** 商品ID */
goodsId: number;
/** 商品名称 */
goodsName: string;
/** 商品单价 */
goodsPrice: number;
/** 数量 */
quantity: number;
/** 商品封面图 */
coverImg?: string;
/** 支付方式 */
paymentMethod?: string;
/** 订单姓名 */
orderName?: string;
/** 订单手机号 */
orderMobile?: string;
/** 格口ID */
cellId?: number;
/** 格口号 */
cellNo?: number;
/** 柜机ID */
cabinetId?: number;
/** 柜机名称 */
cabinetName?: string;
/** 归还/审批时间(归还记录时有效) */
operateTime?: Date;
/** 审批ID归还记录时有效 */
approvalId?: number;
/** 审批状态(归还记录时有效) */
status?: number;
/** 状态描述 */
statusStr?: string;
/** 审批人(归还记录时有效) */
auditName?: string;
/** 审核说明(归还记录时有效) */
auditRemark?: string;
/** 归还图片(归还记录时有效) */
images?: string;
/** 审核图片(归还记录时有效) */
auditImages?: string;
}
/** 获取借还动态分页列表 */
export const getBorrowReturnDynamicListApi = (params?: BorrowReturnDynamicQuery) => {
return http.request<ResponseData<PageDTO<BorrowReturnDynamicDTO>>>("get", "/shop/order/borrow-return-dynamic", {
params
});
};

View File

@ -2,6 +2,7 @@
import { Goods, Shop, Document, Money } from '@element-plus/icons-vue';
import { getStats, TodayLatestOrderGoodsDTO, TopGoodsDTO } from '@/api/shop/stats';
import { getShopListApi, ShopDTO, getModeText } from '@/api/shop/shop';
import { getBorrowReturnDynamicListApi, BorrowReturnDynamicDTO } from '@/api/shop/order';
import { markRaw, onMounted, ref } from 'vue';
import { useWxStore } from '@/store/modules/wx';
import { ElDialog, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
@ -43,6 +44,10 @@ const topGoods = ref<TopGoodsDTO[]>([]);
const todayLatestOrderGoods = ref<TodayLatestOrderGoodsDTO[]>([]);
const maxOccurrenceCount = ref(0);
//
const borrowReturnDynamicList = ref<BorrowReturnDynamicDTO[]>([]);
const loading = ref(false);
const showDialog = ref(false);
const form = ref({ name: '', idCard: '' });
@ -79,7 +84,7 @@ const handleShopChange = (shopId: number) => {
const fetchShopStats = async () => {
if (!currentShop.value) return;
try {
const { data } = await getStats(wxStore.corpid);
shopData.value = [
@ -121,9 +126,69 @@ const fetchShopList = async () => {
}
};
//
const fetchBorrowReturnDynamic = async () => {
loading.value = true;
try {
const { data } = await getBorrowReturnDynamicListApi({
pageNum: 1,
pageSize: 20
});
if (data && data.rows) {
borrowReturnDynamicList.value = data.rows;
}
} catch (error) {
console.error('获取借还动态失败:', error);
ElMessage.error('获取借还动态失败');
} finally {
loading.value = false;
}
};
//
const formatTime = (time: Date | string) => {
if (!time) return '';
const date = new Date(time);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
//
const getDynamicTypeIcon = (dynamicType: number) => {
return dynamicType === 0 ? 'el-icon-shopping-bag-1' : 'el-icon-refresh';
};
//
const getDynamicTypeColor = (dynamicType: number) => {
return dynamicType === 0 ? '#409EFF' : '#67C23A';
};
//
const getStatusColor = (status?: number) => {
if (status === undefined) return '#909399';
switch (status) {
case 0: return '#909399'; // 退
case 1: return '#E6A23C'; //
case 2: return '#67C23A'; //
case 3: return '#F56C6C'; //
case 4: return '#409EFF'; //
default: return '#909399';
}
};
// URL
const parseImages = (images?: string): string[] => {
if (!images) return [];
return images.split(',').map(img => img.trim()).filter(img => img.length > 0);
};
onMounted(async () => {
await fetchShopList();
try {
const { data } = await getStats(wxStore.corpid);
shopData.value = [
@ -151,6 +216,8 @@ onMounted(async () => {
console.error('获取统计数据失败:', error);
}
//
await fetchBorrowReturnDynamic();
if (wxStore.qyUser && !wxStore.qyUser.ab98UserId) {
showDialog.value = true;
@ -170,11 +237,8 @@ onMounted(async () => {
</div>
<div class="shop-content">
<div class="shop-info">
<el-image
:src="currentShop.coverImg || '/src/assets/login/login-bg.png'"
fit="cover"
class="shop-image"
/>
<el-image :src="currentShop.coverImg || '/src/assets/login/login-bg.png'" fit="cover"
class="shop-image" />
<div class="shop-details">
<div class="shop-name-container">
<h3 class="shop-name">{{ currentShop.shopName }}</h3>
@ -196,11 +260,7 @@ onMounted(async () => {
</div>
</div>
<div class="shop-qrcode">
<el-image
:src="qrcode"
fit="contain"
class="qrcode-image"
/>
<el-image :src="qrcode" fit="contain" class="qrcode-image" />
<p class="qrcode-text">扫码进入小程序</p>
</div>
</div>
@ -303,7 +363,7 @@ onMounted(async () => {
</el-row>
</el-col>
<!-- 热门商品 -->
<el-col :span="7">
<!-- <el-col :span="7">
<div class="section-container goods-container">
<div class="section-title">
<div class="title-bar"></div>
@ -326,6 +386,114 @@ onMounted(async () => {
</el-card>
</div>
</div>
</el-col> -->
<!-- 借还动态 -->
<el-col :span="7">
<div class="section-container dynamic-container">
<div class="section-title">
<div class="title-bar"></div>
<div class="title-text">借还动态</div>
</div>
<div class="dynamic-content">
<el-timeline v-if="borrowReturnDynamicList.length > 0" class="dynamic-timeline">
<el-timeline-item v-for="item in borrowReturnDynamicList"
:key="`${item.orderGoodsId}-${item.dynamicType}`"
:timestamp="formatTime(item.dynamicType === 0 ? item.orderTime : item.operateTime)" placement="top"
:color="getDynamicTypeColor(item.dynamicType)">
<el-card shadow="hover" class="dynamic-card">
<div class="dynamic-card-content">
<div class="dynamic-header">
<el-icon :color="getDynamicTypeColor(item.dynamicType)" class="dynamic-icon">
<component :is="getDynamicTypeIcon(item.dynamicType)" />
</el-icon>
<span class="dynamic-type" :style="{ color: getDynamicTypeColor(item.dynamicType) }">
{{ item.dynamicTypeStr }}
</span>
<el-tag v-if="item.dynamicType === 1 && item.status !== undefined"
:color="getStatusColor(item.status)" size="small" class="status-tag">
{{ item.statusStr }}
</el-tag>
</div>
<div class="dynamic-info">
<!-- 商品图片和基本信息 -->
<div class="goods-info">
<el-image v-if="item.coverImg" :src="item.coverImg" fit="cover" class="goods-image"
:preview-src-list="[item.coverImg]" preview-teleported>
<template #error>
<div class="image-error">图片加载失败</div>
</template>
</el-image>
<div class="goods-details">
<span class="goods-name">{{ item.goodsName }}</span>
<span class="goods-price">¥{{ item.goodsPrice }}</span>
<div class="user-info">
<span class="user-name">{{ item.orderName }}</span>
<span class="user-mobile">{{ item.orderMobile }}</span>
</div>
<div class="location-info">
<span class="cabinet-name">{{ item.cabinetName }}</span>
<span class="cell-no">格口{{ item.cellNo }}</span>
</div>
</div>
</div>
<div v-if="item.dynamicType === 1" class="approval-info">
<!-- 图片区域 - 归还图片和审核图片放在同一行 -->
<div class="images-row" v-if="item.images || item.auditImages">
<!-- 归还图片 -->
<div v-if="item.images" class="images-section">
<div class="images-label">归还图片</div>
<div class="images-list">
<el-image v-for="(img, index) in parseImages(item.images)" :key="index" :src="img"
fit="cover" class="return-image" :preview-src-list="parseImages(item.images)"
preview-teleported>
<template #error>
<div class="image-error">图片加载失败</div>
</template>
</el-image>
</div>
</div>
<!-- 审核图片 -->
<div v-if="item.auditImages" class="images-section">
<div class="images-label">审核图片</div>
<div class="images-list">
<el-image v-for="(img, index) in parseImages(item.auditImages)" :key="index" :src="img"
fit="cover" class="audit-image" :preview-src-list="parseImages(item.auditImages)"
preview-teleported>
<template #error>
<div class="image-error">图片加载失败</div>
</template>
</el-image>
</div>
</div>
</div>
<div v-if="item.auditName" class="audit-info">
<span class="audit-label">审批人</span>
<span class="audit-name">{{ item.auditName }}</span>
</div>
<div v-if="item.auditRemark && item.auditRemark != '自动审批'" class="remark-info">
<span class="remark-label">说明</span>
<span class="audit-remark">{{ item.auditRemark }}</span>
</div>
</div>
</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
<div v-else-if="loading" class="dynamic-empty">
<el-skeleton :rows="5" animated />
</div>
<div v-else class="dynamic-empty">
<el-empty description="暂无借还动态" />
</div>
</div>
</div>
</el-col>
</el-row>
@ -557,13 +725,13 @@ onMounted(async () => {
.shop-details {
padding-top: 16px;
.shop-name-container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.shop-name {
font-size: 20px;
font-weight: bold;
@ -571,19 +739,19 @@ onMounted(async () => {
margin: 0;
white-space: nowrap;
}
.shop-selector {
min-width: 200px;
:deep(.el-input__wrapper) {
border-radius: 4px;
border: 1px solid var(--el-border-color);
box-shadow: none;
&:hover {
border-color: var(--el-color-primary);
}
&.is-focus {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary);
@ -622,5 +790,223 @@ onMounted(async () => {
}
}
}
//
.dynamic-container {
margin-bottom: 0;
height: 88vh;
overflow-y: auto;
.dynamic-content {
.dynamic-timeline {
padding: 0;
:deep(.el-timeline-item) {
.el-timeline-item__timestamp {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.el-timeline-item__node {
width: 12px;
height: 12px;
}
}
}
.dynamic-card {
margin-bottom: 12px;
border: 1px solid var(--el-border-color);
.dynamic-card-content {
.dynamic-header {
display: flex;
align-items: center;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-light);
.dynamic-icon {
font-size: 16px;
margin-right: 8px;
}
.dynamic-type {
font-size: 14px;
font-weight: bold;
margin-right: 8px;
}
.status-tag {
margin-left: auto;
color: white;
border: none;
}
}
.dynamic-info {
.goods-info {
display: flex;
align-items: flex-start;
margin-bottom: 8px;
.goods-image {
width: 120px;
height: 90px;
border-radius: 4px;
margin-right: 12px;
flex-shrink: 0;
cursor: pointer;
border: 1px solid var(--el-border-color-light);
}
.goods-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 60px;
.goods-name {
font-size: 14px;
font-weight: bold;
color: var(--el-text-color-primary);
margin-bottom: 4px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.goods-price {
font-size: 14px;
color: var(--el-color-primary);
font-weight: bold;
align-self: flex-start;
margin-bottom: 6px;
}
.user-info {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
.user-name,
.user-mobile {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.location-info {
display: flex;
justify-content: space-between;
.cabinet-name,
.cell-no {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
.user-info,
.location-info {
display: none;
}
.approval-info {
background-color: var(--el-fill-color-light);
border-radius: 4px;
padding: 12px;
margin-top: 8px;
.images-row {
display: flex;
gap: 20px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.images-section {
flex: 1;
min-width: 0;
.images-label {
font-size: 12px;
font-weight: bold;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.images-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
.return-image,
.audit-image {
width: 110px;
height: 70px;
border-radius: 4px;
cursor: pointer;
border: 1px solid var(--el-border-color-light);
flex-shrink: 0;
}
}
}
.audit-info,
.remark-info {
font-size: 12px;
color: var(--el-text-color-secondary);
.audit-label,
.remark-label {
font-weight: bold;
}
.audit-name,
.audit-remark {
color: var(--el-text-color-primary);
}
}
.remark-info {
margin-top: 4px;
}
}
}
}
}
.image-error {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
font-size: 12px;
border-radius: 4px;
}
.dynamic-empty {
text-align: center;
padding: 40px 0;
:deep(.el-empty) {
.el-empty__description {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
}
}
}
}
</style>