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

- 添加商品借还动态页面及路由配置
- 实现借还动态列表展示、下拉刷新和加载更多功能
- 支持预览商品封面、归还图片和审核图片
- 新增相关API接口和类型定义
- 调整底部导航栏,添加商品动态入口
- 优化审批中心与耗材核销的导航位置
- 更新README文档,移除lint相关命令
- 配置Vite代理,调整API基础路径
This commit is contained in:
dzq 2025-12-10 17:56:46 +08:00
parent 45ac3a9bd2
commit 3b0dada98c
15 changed files with 1213 additions and 82 deletions

View File

@ -1,7 +1,7 @@
# 开发环境的环境变量(命名必须以 VITE_ 开头)
## 后端接口地址(如果解决跨域问题采用反向代理就只需写相对路径)
VITE_BASE_URL = http://localhost:8090/api
VITE_BASE_URL = /api
## 开发环境域名和静态资源公共路径(一般 / 或 ./ 都可以)
VITE_PUBLIC_PATH = /

View File

@ -28,30 +28,21 @@ pnpm i
# Start development server (port 3333, opens browser)
pnpm dev
# Build for staging environment
pnpm build:staging
# Build for production (includes zip)
pnpm build
# Preview production build
pnpm preview
# Lint and auto-fix code
pnpm lint
# Run unit tests
pnpm test
```
## Environment Configuration
Three environment files exist:
- `.env.development` - Development settings
- `.env.staging` - Staging settings
- `.env.production` - Production settings
Key environment variables:
- `VITE_BASE_URL` - API base URL
- `VITE_PUBLIC_PATH` - Public path for deployment
- `VITE_ROUTER_HISTORY` - Router mode (`hash` or `history`)
@ -65,12 +56,15 @@ Key environment variables:
## Code Architecture
### Entry Point (`src/main.ts`)
Application bootstrapping:
1. Creates Vue app instance
2. Installs plugins (global components, custom directives)
3. Mounts app after router is ready
### Routing (`src/router/`)
- **index.ts**: Main router configuration with two route groups:
- `systemRoutes`: Error pages (403, 404)
- `routes`: Business routes (cabinet, approval, orders, rentals, products)
@ -78,16 +72,20 @@ Application bootstrapping:
- **guard.ts**: Route navigation guards for authentication and permission checks
### HTTP Client (`src/http/axios.ts`)
- Axios instance with request/response interceptors
- Automatic token handling via cookies
- Standardized error handling for HTTP status codes
- Business logic codes (0 = success, 401 = unauthorized)
### State Management (`src/pinia/`)
Pinia stores for application state. Check `src/pinia/stores/` for store implementations.
### API Layer (`src/common/apis/`)
Organized by business domain:
- `ab98/` - AB98 login integration
- `approval/` - Approval workflow APIs
- `cabinet/` - Cabinet management APIs
@ -96,12 +94,14 @@ Organized by business domain:
- `users/` - User management APIs
### Utilities (`src/common/`)
- `utils/` - Helper functions (cache, datetime, validation, permissions, wx)
- `composables/` - Vue composables (watermark, grayscale, dark mode)
- `components/` - Shared components
- `constants/` - Application constants
### Plugins (`src/plugins/`)
- `index.ts` - Plugin registration
- `console.ts` - VConsole integration for mobile debugging
- `permission-directive.ts` - v-permission directive for button-level permissions
@ -117,6 +117,7 @@ Organized by business domain:
- **Vue SFC Order**: [script, template, style]
**Important**:
- Disable Prettier in VS Code settings (already configured)
- ESLint auto-fixes on save
- Stylistic rules are silently enforced
@ -124,6 +125,7 @@ Organized by business domain:
## Testing
Tests use **Vitest** with **Happy DOM** environment:
- Test files: `tests/**/*.test.{ts,js}`
- Components use `@vue/test-utils`
- Example test: `tests/demo.test.ts`
@ -167,12 +169,14 @@ Tests use **Vitest** with **Happy DOM** environment:
## VS Code Configuration
Recommended extensions (`.vscode/extensions.json`):
- vue.volar (Vue language support)
- dbaeumer.vscode-eslint (ESLint integration)
- antfu.unocss (UnoCSS support)
- vitest.explorer (Test runner)
Configured settings:
- Use workspace TypeScript version
- Disable default formatter, use ESLint
- Auto-fix ESLint on save
@ -181,6 +185,7 @@ Configured settings:
## Commit Message Convention
Use conventional commits:
- `feat`: New feature
- `fix`: Bug fix
- `perf`: Performance improvement

View File

@ -82,14 +82,6 @@ pnpm preview
<br>
```bash
# Code linting and formatting
pnpm lint
# Unit tests
pnpm test
```
</details>
<details>

View File

@ -82,14 +82,6 @@ pnpm preview
<br>
```bash
# 代码校验与格式化
pnpm lint
# 单元测试
pnpm test
```
</details>
<details>

View File

@ -0,0 +1,300 @@
> 本文由 [简悦 SimpRead](http://ksria.com/simpread/) 转码, 原文地址 [develop365.gitlab.io](https://develop365.gitlab.io/vant/zh-CN/image-preview/#/zh-CN/image-preview)
> ImagePreview 图片预览 vant-image-preview Doc | 组件 中文文档 documentation | v4.9.6 v4.0 v4.x | Vant UI (for v......
### 介绍
图片放大预览,支持组件调用和函数调用两种方式。
### 引入
通过以下方式来全局注册组件,更多注册方式请参考[组件注册](https://develop365.gitlab.io/vant/zh-CN/advanced-usage#zu-jian-zhu-ce//vant/zh-CN/advanced-usage)。
```
import { createApp } from 'vue';
import { ImagePreview } from 'vant';
const app = createApp();
app.use(ImagePreview);
```
### 函数调用
为了便于使用 `ImagePreview`Vant 提供了一系列辅助函数,通过辅助函数可以快速唤起全局的图片预览组件。
比如使用 `showImagePreview` 函数,调用后会直接在页面中渲染对应的图片预览组件。
```
import { showImagePreview } from 'vant';
showImagePreview(['https://fastly.jsdelivr.net/npm/@vant/assets/apple-1.jpeg']);
```
代码演示
----
### 基础用法
在调用 `showImagePreview` 时,直接传入图片数组,即可展示图片预览。
```
import { showImagePreview } from 'vant';
showImagePreview([
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-1.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-2.jpeg',
]);
```
### 指定初始位置
`showImagePreview` 支持传入配置对象,并通过 `startPosition` 选项指定图片的初始位置(索引值)。
```
import { showImagePreview } from 'vant';
showImagePreview({
images: [
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-1.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-2.jpeg',
],
startPosition: 1,
});
```
### 展示关闭按钮
开启 `closeable` 选项后,会在弹出层的右上角显示关闭图标,并且可以通过 `close-icon` 属性自定义图标,使用`close-icon-position` 属性可以自定义图标位置。
```
import { showImagePreview } from 'vant';
showImagePreview({
images: [
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-1.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-2.jpeg',
],
closeable: true,
});
```
### 监听关闭事件
通过 `onClose` 选项监听图片预览的关闭事件。
```
import { showToast, showImagePreview } from 'vant';
showImagePreview({
images: [
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-1.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-2.jpeg',
],
onClose() {
showToast('关闭');
},
});
```
### 异步关闭
通过 `beforeClose` 属性可以传入一个回调函数,在图片预览关闭前进行特定操作。
```
import { showImagePreview } from 'vant';
const instance = showImagePreview({
images: [
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-1.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-2.jpeg',
],
beforeClose: () => false,
});
setTimeout(() => {
// 调用实例上的 close 方法手动关闭图片预览
instance.close();
}, 2000);
```
### 使用 ImagePreview 组件
如果需要在 ImagePreview 内嵌入组件或其他自定义内容,可以直接使用 ImagePreview 组件,并使用 `index` 插槽进行定制。使用前需要通过 `app.use` 等方式注册组件。
```
<van-image-preview v-model:show="show" :images="images" @change="onChange">
<template v-slot:index>第{{ index + 1 }}页</template>
</van-image-preview>
```
```
import { ref } from 'vue';
export default {
setup() {
const show = ref(false);
const index = ref(0);
const images = [
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-1.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/apple-2.jpeg',
];
const onChange = (newIndex) => {
index.value = newIndex;
};
return {
show,
index,
images,
onChange,
};
},
};
```
### 使用 image 插槽
当以组件调用的方式使用 ImagePreview 时,可以通过 `image` 插槽来插入自定义的内容,比如展示一个视频内容。在这个例子中,你可以将 `close-on-click-image` 属性设置为 `false`,这样当你点击视频时就不会意外关闭预览了。
```
<van-image-preview
v-model:show="show"
:images="images"
:close-on-click-image="false"
>
<template #image="{ src }">
<video style="width: 100%;" controls>
<source :src="src" />
</video>
</template>
</van-image-preview>
```
```
import { ref } from 'vue';
export default {
setup() {
const show = ref(false);
const images = [
'https://www.w3school.com.cn/i/movie.ogg',
'https://www.w3school.com.cn/i/movie.ogg',
'https://www.w3school.com.cn/i/movie.ogg',
];
return {
show,
images,
};
},
};
```
当你通过 `image` 插槽自定义图片时,可以通过插槽的参数绑定 `style` 样式和 `onLoad` 回调函数,这可以让 `<img>` 标签支持图片缩放。
```
<van-image-preview
v-model:show="show"
:images="images"
:close-on-click-image="false"
>
<template #image="{ src, style, onLoad }">
<img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" />
</template>
</van-image-preview>
```
API
---
### 方法
Vant 中导出了以下 ImagePreview 相关的辅助函数:
<table><thead><tr><th>方法名</th><th>说明</th><th>参数</th><th>返回值</th></tr></thead><tbody><tr><td>showImagePreview</td><td>展示一个全屏的图片预览组件</td><td><em>string[] | ImagePreviewOptions</em></td><td>ImagePreview 实例</td></tr></tbody></table>
### ImagePreviewOptions
调用 `showImagePreview` 方法时,支持传入以下选项:
<table><thead><tr><th>参数名</th><th>说明</th><th>类型</th><th>默认值</th></tr></thead><tbody><tr><td>images</td><td>需要预览的图片 URL 数组</td><td><em>string[]</em></td><td><code>[]</code></td></tr><tr><td>startPosition</td><td>图片预览起始位置索引</td><td><em>number | string</em></td><td><code>0</code></td></tr><tr><td>swipeDuration</td><td>动画时长,单位为 <code>ms</code></td><td><em>number | string</em></td><td><code>300</code></td></tr><tr><td>showIndex</td><td>是否显示页码</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>showIndicators</td><td>是否显示轮播指示器</td><td><em>boolean</em></td><td><code>false</code></td></tr><tr><td>loop</td><td>是否开启循环播放</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>doubleScale <code>v4.7.2</code></td><td>是否启用双击缩放手势,禁用后,点击时会立即关闭图片预览</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>onClose</td><td>关闭时的回调函数</td><td><em>Function</em></td><td>-</td></tr><tr><td>onChange</td><td>切换图片时的回调函数,回调参数为当前索引</td><td><em>Function</em></td><td>-</td></tr><tr><td>onScale</td><td>缩放图片时的回调函数,回调参数为当前索引和当前缩放值组成的对象</td><td><em>Function</em></td><td>-</td></tr><tr><td>beforeClose</td><td>关闭前的回调函数,返回 <code>false</code> 可阻止关闭,支持返回 Promise</td><td><em>(active: number) =&gt; boolean | Promise&lt;boolean&gt;</em></td><td>-</td></tr><tr><td>closeOnPopstate</td><td>是否在页面回退时自动关闭</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>closeOnClickImage <code>v4.8.3</code></td><td>是否在点击图片后关闭图片预览</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>closeOnClickOverlay <code>v4.6.4</code></td><td>是否在点击遮罩层后关闭图片预览</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>vertical <code>v4.8.6</code></td><td>是否开启纵向手势滑动</td><td><em>boolean</em></td><td><code>false</code></td></tr><tr><td>className</td><td>自定义类名 (应用在图片预览的弹出层)</td><td><em>string | Array | object</em></td><td>-</td></tr><tr><td>maxZoom</td><td>手势缩放时,最大缩放比例</td><td><em>number | string</em></td><td><code>3</code></td></tr><tr><td>minZoom</td><td>手势缩放时,最小缩放比例</td><td><em>number | string</em></td><td><code>1/3</code></td></tr><tr><td>closeable</td><td>是否显示关闭图标</td><td><em>boolean</em></td><td><code>false</code></td></tr><tr><td>closeIcon</td><td>关闭图标名称或图片链接</td><td><em>string</em></td><td><code>clear</code></td></tr><tr><td>closeIconPosition</td><td>关闭图标位置,可选值为 <code>top-left</code><br><code>bottom-left</code> <code>bottom-right</code></td><td><em>string</em></td><td><code>top-right</code></td></tr><tr><td>transition</td><td>动画类名,等价于 <a href="https://cn.vuejs.org/api/built-in-components.html#transition" target="_blank">transition</a><code>name</code> 属性</td><td><em>string</em></td><td><code>van-fade</code></td></tr><tr><td>overlayClass</td><td>自定义遮罩层类名</td><td><em>string | Array | object</em></td><td>-</td></tr><tr><td>overlayStyle</td><td>自定义遮罩层样式</td><td><em>object</em></td><td>-</td></tr><tr><td>teleport</td><td>指定挂载的节点,等同于 Teleport 组件的 <a href="https://cn.vuejs.org/api/built-in-components.html#teleport" target="_blank">to 属性</a></td><td><em>string | Element</em></td><td>-</td></tr></tbody></table>
### Props
通过组件调用 `ImagePreview` 时,支持以下 Props
<table><thead><tr><th>参数</th><th>说明</th><th>类型</th><th>默认值</th></tr></thead><tbody><tr><td>v-model:show</td><td>是否展示图片预览</td><td><em>boolean</em></td><td><code>false</code></td></tr><tr><td>images</td><td>需要预览的图片 URL 数组</td><td><em>string[]</em></td><td><code>[]</code></td></tr><tr><td>start-position</td><td>图片预览起始位置索引</td><td><em>number | string</em></td><td><code>0</code></td></tr><tr><td>swipe-duration</td><td>动画时长,单位为 ms</td><td><em>number | string</em></td><td><code>300</code></td></tr><tr><td>show-index</td><td>是否显示页码</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>show-indicators</td><td>是否显示轮播指示器</td><td><em>boolean</em></td><td><code>false</code></td></tr><tr><td>loop</td><td>是否开启循环播放</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>double-scale <code>v4.7.2</code></td><td>是否启用双击缩放手势,禁用后,点击时会立即关闭图片预览</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>before-close</td><td>关闭前的回调函数,返回 <code>false</code> 可阻止关闭,支持返回 Promise</td><td><em>(active: number) =&gt; boolean | Promise&lt;boolean&gt;</em></td><td>-</td></tr><tr><td>close-on-popstate</td><td>是否在页面回退时自动关闭</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>close-on-click-image <code>v4.8.3</code></td><td>是否在点击图片后关闭图片预览</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>close-on-click-overlay <code>v4.6.4</code></td><td>是否在点击遮罩层后关闭图片预览</td><td><em>boolean</em></td><td><code>true</code></td></tr><tr><td>vertical <code>v4.8.6</code></td><td>是否开启纵向手势滑动</td><td><em>boolean</em></td><td><code>false</code></td></tr><tr><td>class-name</td><td>自定义类名</td><td><em>string | Array | object</em></td><td>-</td></tr><tr><td>max-zoom</td><td>手势缩放时,最大缩放比例</td><td><em>number | string</em></td><td><code>3</code></td></tr><tr><td>min-zoom</td><td>手势缩放时,最小缩放比例</td><td><em>number | string</em></td><td><code>1/3</code></td></tr><tr><td>closeable</td><td>是否显示关闭图标</td><td><em>boolean</em></td><td><code>false</code></td></tr><tr><td>close-icon</td><td>关闭图标名称或图片链接</td><td><em>string</em></td><td><code>clear</code></td></tr><tr><td>close-icon-position</td><td>关闭图标位置,可选值为 <code>top-left</code><br><code>bottom-left</code> <code>bottom-right</code></td><td><em>string</em></td><td><code>top-right</code></td></tr><tr><td>transition</td><td>动画类名,等价于 <a href="https://cn.vuejs.org/api/built-in-components.html#transition" target="_blank">transition</a><code>name</code> 属性</td><td><em>string</em></td><td><code>van-fade</code></td></tr><tr><td>overlay-class</td><td>自定义遮罩层类名</td><td><em>string | Array | object</em></td><td>-</td></tr><tr><td>overlay-style</td><td>自定义遮罩层样式</td><td><em>object</em></td><td>-</td></tr><tr><td>teleport</td><td>指定挂载的节点,等同于 Teleport 组件的 <a href="https://cn.vuejs.org/api/built-in-components.html#teleport" target="_blank">to 属性</a></td><td><em>string | Element</em></td><td>-</td></tr></tbody></table>
### Events
通过组件调用 `ImagePreview` 时,支持以下事件:
<table><thead><tr><th>事件名</th><th>说明</th><th>回调参数</th></tr></thead><tbody><tr><td>close</td><td>关闭时触发</td><td><em>{index: number, url: string}</em></td></tr><tr><td>closed</td><td>关闭且且动画结束后触发</td><td>-</td></tr><tr><td>change</td><td>切换当前图片时触发</td><td><em>index: number</em></td></tr><tr><td>scale</td><td>缩放当前图片时触发</td><td><em>{index: number, scale: number}</em></td></tr><tr><td>long-press</td><td>长按当前图片时触发</td><td><em>{index: number}</em></td></tr></tbody></table>
### 方法
通过组件调用 `ImagePreview` 时,通过 ref 可以获取到 ImagePreview 实例并调用实例方法,详见[组件实例方法](https://develop365.gitlab.io/vant/zh-CN/advanced-usage#zu-jian-shi-li-fang-fa//vant/zh-CN/advanced-usage)。
<table><thead><tr><th>方法名</th><th>说明</th><th>参数</th><th>返回值</th></tr></thead><tbody><tr><td>resetScale <code>4.7.4</code></td><td>重置当前图片的缩放比</td><td>-</td><td>-</td></tr><tr><td>swipeTo</td><td>切换到指定位置</td><td><em>index: number, options?: SwipeToOptions</em></td><td>-</td></tr></tbody></table>
### 类型定义
组件导出以下类型定义:
```
import type {
ImagePreviewProps,
ImagePreviewOptions,
ImagePreviewInstance,
ImagePreviewScaleEventParams,
} from 'vant';
```
`ImagePreviewInstance` 是组件实例的类型,用法如下:
```
import { ref } from 'vue';
import type { ImagePreviewInstance } from 'vant';
const imagePreviewRef = ref<ImagePreviewInstance>();
imagePreviewRef.value?.swipeTo(1);
```
### Slots
通过组件调用 `ImagePreview` 时,支持以下插槽:
<table><thead><tr><th>名称</th><th>说明</th><th>参数</th></tr></thead><tbody><tr><td>index</td><td>自定义页码内容</td><td><em>{index: 当前图片的索引}</em></td></tr><tr><td>cover</td><td>自定义覆盖在图片预览上方的内容</td><td>-</td></tr><tr><td>image</td><td>自定义图片内容</td><td><em>{src: 当前资源地址, onLoad: 加载图片函数, style: 当前图片样式}</em></td></tr></tbody></table>
### onClose 回调参数
<table><thead><tr><th>参数名</th><th>说明</th><th>类型</th></tr></thead><tbody><tr><td>url</td><td>当前图片 URL</td><td><em>string</em></td></tr><tr><td>index</td><td>当前图片的索引值</td><td><em>number</em></td></tr></tbody></table>
### onScale 回调参数
<table><thead><tr><th>参数名</th><th>说明</th><th>类型</th></tr></thead><tbody><tr><td>index</td><td>当前图片的索引值</td><td><em>number</em></td></tr><tr><td>scale</td><td>当前图片的缩放值</td><td><em>number</em></td></tr></tbody></table>
主题定制
----
### 样式变量
组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](https://develop365.gitlab.io/vant/zh-CN/config-provider/#/zh-CN/config-provider)。
<table><thead><tr><th>名称</th><th>默认值</th><th>描述</th></tr></thead><tbody><tr><td>--van-image-preview-index-text-color</td><td><em>var(--van-white)</em></td><td>-</td></tr><tr><td>--van-image-preview-index-font-size</td><td><em>var(--van-font-size-md)</em></td><td>-</td></tr><tr><td>--van-image-preview-index-line-height</td><td><em>var(--van-line-height-md)</em></td><td>-</td></tr><tr><td>--van-image-preview-index-text-shadow</td><td><em>0 1px 1px var(--van-gray-8)</em></td><td>-</td></tr><tr><td>--van-image-preview-overlay-background</td><td><em>rgba(0, 0, 0, 0.9)</em></td><td>-</td></tr><tr><td>--van-image-preview-close-icon-size</td><td><em>22px</em></td><td>-</td></tr><tr><td>--van-image-preview-close-icon-color</td><td><em>var(--van-gray-5)</em></td><td>-</td></tr><tr><td>--van-image-preview-close-icon-margin</td><td><em>var(--van-padding-md)</em></td><td>-</td></tr><tr><td>--van-image-preview-close-icon-z-index</td><td><em>1</em></td><td>-</td></tr></tbody></table>
常见问题
----
### 引用 showImagePreview 时出现编译报错?
如果引用 `showImagePreview` 方法时出现以下报错,说明项目中使用了 `babel-plugin-import` 插件,导致代码被错误编译。
```
These dependencies were not found:
* vant/es/show-image-preview in ./src/xxx.js
* vant/es/show-image-preview/style in ./src/xxx.js
```
Vant 从 4.0 版本开始不再支持 `babel-plugin-import` 插件,请参考 [迁移指南](https://develop365.gitlab.io/vant/zh-CN/migrate-from-v3#yi-chu-babel-plugin-import//vant/zh-CN/migrate-from-v3) 移除该插件。

View File

@ -375,10 +375,6 @@ pnpm dev
pnpm build
pnpm build:staging
# 代码检查
pnpm lint
pnpm test
# 依赖管理
pnpm i
pnpm update

View File

@ -76,15 +76,6 @@ pnpm build:staging
pnpm build
```
### 代码检查
```bash
# 代码校验与格式化
pnpm lint
# 单元测试
pnpm test
```
## 核心模块说明
### 1. 路由系统 (src/router/)

View File

@ -10,7 +10,6 @@
"build:staging": "vue-tsc && vite build --mode staging",
"build": "vue-tsc && vite build && npm run zip",
"preview": "vite preview",
"lint": "eslint . --fix",
"prepare": "husky",
"test": "vitest",
"zip": "7z a -tzip dist/shop-web-%date:~0,4%%date:~5,2%%date:~8,2%-%time:~0,2%%time:~3,2%%time:~6,2%.zip .\\dist\\* -xr!*.zip"

View File

@ -0,0 +1,80 @@
import type { PageDTO, ResponseData } from "../type"
import { request } from "@/http/axios"
/** 借还动态查询参数 */
export interface SearchBorrowReturnDynamicQuery {
/** 商品ID精确筛选 */
goodsId?: number
/** 格口ID精确筛选 */
cellId?: number
/** 状态筛选(仅对归还记录有效) */
status?: number
/** 动态类型筛选 */
dynamicType?: number
/** 页码默认1 */
pageNum?: number
/** 每页大小默认10 */
pageSize?: number
}
/** 借还动态响应数据 */
export interface BorrowReturnDynamicDTO {
/** 订单商品ID */
orderGoodsId: number
/** 动态类型0-借出 1-归还) */
dynamicType: number
/** 动态类型描述 */
dynamicTypeStr: string
/** 订单ID */
orderId: number
/** 订单创建时间/借出时间 */
orderTime: string
/** 商品ID */
goodsId: number
/** 商品名称 */
goodsName: string
/** 商品封面图片 */
coverImg: string
/** 商品单价 */
goodsPrice: number
/** 数量 */
quantity: number
/** 支付方式 */
paymentMethod: string
/** 订单姓名 */
orderName: string
/** 订单手机号 */
orderMobile: string
/** 格口ID */
cellId: number
/** 格口号 */
cellNo: number
/** 柜机ID */
cabinetId: number
/** 柜机名称 */
cabinetName: string
/** 归还/审批时间(归还记录时有效) */
operateTime?: string
/** 审批ID归还记录时有效 */
approvalId?: number
/** 审批状态(归还记录时有效) */
status: number
/** 状态描述 */
statusStr: string
/** 审批人(归还记录时有效) */
auditName?: string
/** 审核说明(归还记录时有效) */
auditRemark?: string
/** 归还图片(归还记录时有效) */
images?: string
/** 审核图片(归还记录时有效) */
auditImages?: string
}
export function getBorrowReturnDynamicApi(query: SearchBorrowReturnDynamicQuery) {
return request<ResponseData<PageDTO<BorrowReturnDynamicDTO>>>({
url: "order/borrow-return-dynamic",
method: "get",
params: query
})
}

View File

@ -23,11 +23,18 @@ const tabbarItemList = computed(() => {
title: '柜机管理',
icon: 'manager-o',
path: '/cabinet'
}, {
},
/* {
title: '审批中心',
icon: 'records-o',
path: '/approval/list'
}, {
}, */
{
title: '商品动态',
icon: 'exchange',
path: '/order/borrow-return-dynamic'
},
{
title: '我的',
icon: 'user-o',
path: '/'

View File

@ -133,18 +133,19 @@ wxStore.refreshBalance();
<span>柜机管理</span>
</div>
</van-col>
<van-col span="8">
<div v-if="wxStore.isCabinetAdmin" class="custom-btn" @click="router.push('/approval/list')">
<van-icon name="comment-o" size="20px" />
<span>审批中心</span>
</div>
</van-col> -->
-->
<van-col span="8">
<div v-if="wxStore.isCabinetAdmin" class="custom-btn" @click="router.push('/approvalAsset/list')">
<van-icon name="comment-o" size="20px" />
<span>耗材核销</span>
</div>
</van-col>
<van-col span="8">
<div v-if="wxStore.isCabinetAdmin" class="custom-btn" @click="router.push('/approval/list')">
<van-icon name="comment-o" size="20px" />
<span>审批中心</span>
</div>
</van-col>
<van-col span="8"></van-col>
</van-row>
</div>

View File

@ -0,0 +1,747 @@
<script setup lang="ts">
import type { BorrowReturnDynamicDTO, SearchBorrowReturnDynamicQuery } from "@/common/apis/manage/order"
import { getBorrowReturnDynamicApi } from "@/common/apis/manage/order"
import { showToast, showImagePreview } from "vant"
import { computed, onMounted, ref } from "vue"
//
const loading = ref(false)
//
const refreshing = ref(false)
//
const dynamicList = ref<BorrowReturnDynamicDTO[]>([])
//
const queryParams = ref<SearchBorrowReturnDynamicQuery>({
pageNum: 1,
pageSize: 10
})
//
const total = ref(0)
//
const hasMore = computed(() => dynamicList.value.length < total.value)
//
async function fetchBorrowReturnDynamic(isLoadMore = false) {
if (!isLoadMore) {
loading.value = true
}
try {
const res = await getBorrowReturnDynamicApi(queryParams.value)
if (res.code === 0) {
if (isLoadMore) {
//
dynamicList.value = [...dynamicList.value, ...(res.data?.rows || [])]
} else {
//
dynamicList.value = res.data?.rows || []
}
total.value = res.data?.total || 0
} else {
showToast(res.msg || "获取数据失败")
}
} catch (_error) {
showToast("网络错误,请重试")
} finally {
loading.value = false
refreshing.value = false
}
}
//
function loadMore() {
if (loading.value || !hasMore.value) return
queryParams.value.pageNum!++
fetchBorrowReturnDynamic(true)
}
//
function onRefresh() {
refreshing.value = true
queryParams.value.pageNum = 1
dynamicList.value = []
fetchBorrowReturnDynamic()
}
//
function getImageArray(imagesStr?: string): string[] {
if (!imagesStr || imagesStr.trim() === "") return []
return imagesStr.split(",").filter(img => img.trim() !== "")
}
//
function previewCoverImage(imgUrl: string) {
if (!imgUrl) return
showImagePreview([imgUrl])
}
//
function previewReturnImages(images: string[], startIndex = 0) {
if (!images || images.length === 0) return
showImagePreview({
images,
startPosition: startIndex,
closeable: true
})
}
//
function previewAuditImages(images: string[], startIndex = 0) {
if (!images || images.length === 0) return
showImagePreview({
images,
startPosition: startIndex,
closeable: true
})
}
//
onMounted(() => {
fetchBorrowReturnDynamic()
})
</script>
<template>
<div class="borrow-return-dynamic-page">
<!-- 下拉刷新 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 时间线容器 -->
<div class="timeline-container">
<!-- 时间线线条 -->
<div class="timeline-line" />
<!-- 动态条目 -->
<div
v-for="item in dynamicList"
:key="item.orderGoodsId"
class="timeline-item"
>
<!-- 时间点 -->
<div class="timeline-dot">
<div class="dot" :class="`dot-type-${item.dynamicType}`" />
</div>
<!-- 内容卡片 -->
<div class="dynamic-card">
<!-- 时间显示 -->
<div class="dynamic-time">
{{ item.dynamicType === 0 ? item.orderTime : item.operateTime }}
</div>
<!-- 卡片头部类型和状态 -->
<div class="card-header">
<span class="dynamic-type" :class="`type-${item.dynamicType}`">
{{ item.dynamicTypeStr }}
</span>
<span v-if="item.dynamicType === 1" class="audit-status" :class="`status-${item.status}`">
{{ item.statusStr }}
</span>
</div>
<!-- 分隔线 -->
<van-divider />
<!-- 商品信息 -->
<div class="goods-info">
<!-- 商品图片 -->
<van-image
v-if="item.coverImg"
:src="item.coverImg"
width="70"
height="70"
class="goods-image"
fit="cover"
@click="previewCoverImage(item.coverImg)"
/>
<div v-else class="goods-image-placeholder">
<div class="placeholder-icon">
商品
</div>
</div>
<div class="goods-details">
<div class="goods-name">
{{ item.goodsName }}
</div>
<div class="goods-price">
¥{{ item.goodsPrice.toFixed(2) }}
</div>
<div class="order-info">
<div class="order-name">
{{ item.orderName }}
</div>
<div class="order-mobile">
{{ item.orderMobile }}
</div>
</div>
<div class="cabinet-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="image-section">
<!-- 归还图片 -->
<div v-if="getImageArray(item.images).length > 0" class="image-group">
<div class="image-label">
归还图片
</div>
<div class="image-list">
<van-image
v-for="(img, imgIndex) in getImageArray(item.images)"
:key="imgIndex"
:src="img"
width="50"
height="50"
fit="cover"
class="return-image"
@click="previewReturnImages(getImageArray(item.images), imgIndex)"
/>
</div>
</div>
<!-- 审核图片 -->
<div v-if="getImageArray(item.auditImages).length > 0" class="image-group">
<div class="image-label">
审核图片
</div>
<div class="image-list">
<van-image
v-for="(img, imgIndex) in getImageArray(item.auditImages)"
:key="imgIndex"
:src="img"
width="50"
height="50"
fit="cover"
class="audit-image"
@click="previewAuditImages(getImageArray(item.auditImages), imgIndex)"
/>
</div>
</div>
</div>
<!-- 审批人和审核说明 -->
<div v-if="item.dynamicType === 1 && (item.auditName || item.auditRemark)" class="audit-info">
<div v-if="item.auditName" class="audit-person">
<span class="label">审批人:</span>
<span class="value">{{ item.auditName }}</span>
</div>
<div v-if="item.auditRemark && item.auditRemark != '自动审批'" class="audit-remark">
<span class="label">审核说明:</span>
<span class="value">{{ item.auditRemark }}</span>
</div>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more">
<van-button
:loading="loading"
loading-text="加载中..."
@click="loadMore"
block
>
加载更多
</van-button>
</div>
<div v-else-if="dynamicList.length > 0" class="no-more">
没有更多了
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && dynamicList.length === 0" class="empty-state">
<van-empty description="暂无借还动态" />
</div>
</van-pull-refresh>
</div>
</template>
<style scoped>
.borrow-return-dynamic-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #f0f4f8 100%);
padding: 16px;
padding-bottom: 32px;
}
.dynamic-time {
padding: 10px 16px;
font-size: 13px;
color: #666;
background: rgba(255, 255, 255, 0.8);
border-radius: 6px 6px 0 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
font-weight: 500;
letter-spacing: 0.3px;
}
.timeline-container {
position: relative;
margin-top: 8px;
min-height: 90vh;
padding-left: 32px;
}
.timeline-line {
position: absolute;
left: 16px;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(
to bottom,
rgba(25, 137, 250, 0.2) 0%,
rgba(25, 137, 250, 0.6) 30%,
rgba(25, 137, 250, 0.8) 50%,
rgba(25, 137, 250, 0.6) 70%,
rgba(25, 137, 250, 0.2) 100%
);
border-radius: 2px;
box-shadow: 0 0 10px rgba(25, 137, 250, 0.15);
}
.timeline-item {
position: relative;
margin-bottom: 20px;
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.timeline-item:hover {
transform: translateY(-2px);
}
.timeline-dot {
position: absolute;
left: -32px;
top: 0;
display: flex;
flex-direction: column;
align-items: center;
width: 32px;
z-index: 2;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background-color: #1989fa;
border: 3px solid white;
box-shadow:
0 0 0 3px #1989fa,
0 0 15px rgba(25, 137, 250, 0.4);
transition: all 0.3s ease;
position: relative;
}
.dot::after {
content: "";
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
border-radius: 50%;
background: inherit;
filter: blur(5px);
opacity: 0.4;
z-index: -1;
}
.dot-type-0 {
background-color: #1989fa;
box-shadow:
0 0 0 3px #1989fa,
0 0 15px rgba(25, 137, 250, 0.4);
}
.dot-type-1 {
background-color: #07c160;
box-shadow:
0 0 0 3px #07c160,
0 0 15px rgba(7, 193, 96, 0.4);
}
.timeline-time {
margin-top: 8px;
font-size: 12px;
color: #666;
text-align: center;
line-height: 1.4;
white-space: nowrap;
}
.dynamic-card {
border-radius: 10px;
overflow: hidden;
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.08),
0 2px 6px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.dynamic-card:hover {
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.12),
0 4px 12px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
transform: translateY(-4px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: linear-gradient(to right, rgba(25, 137, 250, 0.03), rgba(7, 193, 96, 0.03));
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.dynamic-type {
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
position: relative;
}
.type-0 {
color: #1989fa;
}
.type-1 {
color: #07c160;
}
.audit-status {
font-size: 12px;
padding: 3px 10px;
border-radius: 16px;
font-weight: 600;
letter-spacing: 0.3px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.audit-status:hover {
transform: scale(1.05);
}
.status-1 {
background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%);
color: #f57c00;
border: 1px solid rgba(245, 124, 0, 0.2);
}
.status-2 {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
color: #07c160;
border: 1px solid rgba(7, 193, 96, 0.2);
}
.status-3 {
background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
color: #ee0a24;
border: 1px solid rgba(238, 10, 36, 0.2);
}
.goods-info {
display: flex;
padding: 16px;
gap: 12px;
align-items: center;
}
.goods-image {
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
border: 2px solid white;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
cursor: pointer;
}
.goods-image:hover {
transform: scale(1.05);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
}
.goods-image-placeholder {
width: 70px;
height: 70px;
flex-shrink: 0;
background: linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed rgba(0, 0, 0, 0.1);
}
.placeholder-icon {
color: #999;
font-size: 14px;
font-weight: 500;
}
.goods-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.goods-name {
font-size: 16px;
font-weight: 600;
color: #333;
line-height: 1.4;
letter-spacing: 0.2px;
}
.goods-price {
font-size: 18px;
font-weight: 700;
color: #ee0a24;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.order-info {
display: flex;
gap: 12px;
font-size: 13px;
color: #666;
background: rgba(0, 0, 0, 0.02);
padding: 8px 10px;
border-radius: 6px;
margin-top: 4px;
}
.order-info .order-name,
.order-info .order-mobile {
display: flex;
align-items: center;
gap: 6px;
}
.cabinet-info {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #666;
background: rgba(25, 137, 250, 0.04);
padding: 8px 10px;
border-radius: 6px;
margin-top: 4px;
}
.cabinet-name {
font-weight: 500;
color: #333;
}
.cell-no {
color: #1989fa;
font-weight: 600;
background: rgba(25, 137, 250, 0.1);
padding: 2px 8px;
border-radius: 12px;
font-size: 13px;
}
.image-section {
padding: 0 16px 16px;
display: flex;
gap: 16px;
}
.image-group {
flex: 1;
}
.image-label {
font-size: 13px;
color: #666;
margin-bottom: 8px;
font-weight: 500;
padding-left: 8px;
border-left: 3px solid #1989fa;
}
.image-list {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.return-image,
.audit-image {
border-radius: 6px;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
cursor: pointer;
}
.return-image:hover,
.audit-image:hover {
transform: translateY(-3px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.15);
}
.audit-info {
padding: 0 16px 16px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
}
.audit-person,
.audit-remark {
display: flex;
gap: 8px;
align-items: flex-start;
background: rgba(0, 0, 0, 0.02);
padding: 10px;
border-radius: 8px;
}
.label {
color: #666;
min-width: 70px;
font-weight: 500;
}
.value {
color: #333;
flex: 1;
line-height: 1.5;
}
.load-more {
text-align: center;
padding: 20px 0;
}
.load-more .van-button {
background: linear-gradient(135deg, #1989fa 0%, #36b5ff 100%);
color: white;
border: none;
border-radius: 25px;
padding: 12px 24px;
font-weight: 600;
font-size: 15px;
box-shadow: 0 4px 15px rgba(25, 137, 250, 0.3);
transition: all 0.3s ease;
}
.load-more .van-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(25, 137, 250, 0.4);
}
.load-more .van-button:active {
transform: translateY(0);
}
.no-more {
text-align: center;
padding: 24px 0;
color: #999;
font-size: 14px;
font-style: italic;
}
.empty-state {
padding: 80px 0;
}
/* 响应式调整 */
@media (max-width: 400px) {
.borrow-return-dynamic-page {
padding: 12px;
}
.timeline-container {
padding-left: 30px;
}
.timeline-dot {
left: -30px;
width: 30px;
}
.goods-info {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.goods-image,
.goods-image-placeholder {
width: 100%;
height: auto;
aspect-ratio: 1;
}
.image-section {
flex-direction: column;
gap: 16px;
}
.order-info {
flex-direction: column;
gap: 8px;
}
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.timeline-item {
animation: fadeIn 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
/* 大屏幕优化 */
@media (min-width: 768px) {
.borrow-return-dynamic-page {
max-width: 800px;
margin: 0 auto;
padding-top: 24px;
}
.dynamic-card {
border-radius: 16px;
}
}
/* 美化分隔线 */
.van-divider {
margin: 0 16px;
border-color: rgba(0, 0, 0, 0.06);
}
/* 美化下拉刷新区域 */
.van-pull-refresh {
min-height: calc(100vh - 32px);
}
</style>

View File

@ -30,34 +30,34 @@ export const systemRoutes: RouteRecordRaw[] = [
/** 业务页面 */
export const routes: RouteRecordRaw[] = [
{
path: '/approval/submit',
component: () => import('@/pages/approval/submit.vue'),
path: "/approval/submit",
component: () => import("@/pages/approval/submit.vue"),
meta: { requiresAuth: true }
},
{
path: '/approval/handle/:approvalId',
component: () => import('@/pages/approval/handle.vue'),
path: "/approval/handle/:approvalId",
component: () => import("@/pages/approval/handle.vue"),
meta: { requiresAuth: true }
},
{
path: '/approval/handleApply/:approvalId',
component: () => import('@/pages/approval/handleApply.vue'),
path: "/approval/handleApply/:approvalId",
component: () => import("@/pages/approval/handleApply.vue"),
meta: { requiresAuth: true }
},
{
path: '/order-success',
name: 'OrderSuccess',
component: () => import('@/pages/order/Success.vue'),
path: "/order-success",
name: "OrderSuccess",
component: () => import("@/pages/order/Success.vue"),
meta: {
requiresAuth: true
}
},
{
path: '/order-list',
name: 'OrderList',
component: () => import('@/pages/order/components/OrderList.vue'),
path: "/order-list",
name: "OrderList",
component: () => import("@/pages/order/components/OrderList.vue"),
meta: {
title: '订单列表',
title: "订单列表",
layout: {
navBar: {
showNavBar: true,
@ -68,11 +68,11 @@ export const routes: RouteRecordRaw[] = [
}
},
{
path: '/order/:id',
name: 'OrderDetail',
component: () => import('@/pages/order/index.vue'),
path: "/order/:id",
name: "OrderDetail",
component: () => import("@/pages/order/index.vue"),
meta: {
title: '订单详情',
title: "订单详情",
layout: {
navBar: {
showNavBar: true,
@ -83,11 +83,30 @@ export const routes: RouteRecordRaw[] = [
}
},
{
path: '/rental-list',
name: 'RentalList',
component: () => import('@/pages/rental/index.vue'),
path: "/order/borrow-return-dynamic",
name: "BorrowReturnDynamic",
component: () => import("@/pages/order/borrow-return-dynamic.vue"),
meta: {
title: '我的柜子',
title: "商品动态",
layout: {
navBar: {
showNavBar: true,
showLeftArrow: true
},
tabbar: {
showTabbar: true,
icon: "manager-o"
}
},
requiresAuth: true
}
},
{
path: "/rental-list",
name: "RentalList",
component: () => import("@/pages/rental/index.vue"),
meta: {
title: "我的柜子",
layout: {
navBar: {
showNavBar: true,
@ -106,11 +125,11 @@ export const routes: RouteRecordRaw[] = [
}
},
{
path: '/cabinet',
component: () => import('@/pages/cabinet/index.vue'),
path: "/cabinet",
component: () => import("@/pages/cabinet/index.vue"),
name: "Cabinet",
meta: {
title: '柜机管理',
title: "柜机管理",
keepAlive: true,
layout: {
navBar: {
@ -125,11 +144,11 @@ export const routes: RouteRecordRaw[] = [
}
},
{
path: '/approval/list',
component: () => import('@/pages/approval/list.vue'),
path: "/approval/list",
component: () => import("@/pages/approval/list.vue"),
name: "Approval",
meta: {
title: '审批中心',
title: "审批中心",
keepAlive: true,
layout: {
navBar: {
@ -144,11 +163,11 @@ export const routes: RouteRecordRaw[] = [
}
},
{
path: '/approvalAsset/list',
component: () => import('@/pages/approvalAsset/list.vue'),
path: "/approvalAsset/list",
component: () => import("@/pages/approvalAsset/list.vue"),
name: "ApprovalAsset",
meta: {
title: '耗材核销',
title: "耗材核销",
keepAlive: false,
layout: {
navBar: {

View File

@ -30,6 +30,7 @@ declare module 'vue' {
VanNavBar: typeof import('vant/es')['NavBar']
VanPicker: typeof import('vant/es')['Picker']
VanPopup: typeof import('vant/es')['Popup']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanRow: typeof import('vant/es')['Row']
VanSearch: typeof import('vant/es')['Search']
VanSidebar: typeof import('vant/es')['Sidebar']

View File

@ -37,8 +37,9 @@ export default defineConfig(({ mode }) => {
open: true,
// 反向代理
proxy: {
"/api/v1": {
target: "https://apifoxmock.com/m1/2930465-2145633-default",
"/api": {
target: "https://wxshop.ab98.cn/shop-api",
debug: true,
// 是否为 WebSocket
ws: false,
// 是否允许跨域