feat: 初始化订单审批系统项目- 创建项目基础结构和配置文件

- 添加全局样式和工具函数
- 实现订单状态和审批状态的格式化及颜色处理- 添加金额和日期格式化函数
- 实现防抖和节流函数
- 添加微信小程序环境检查和文件预览URL生成函数
- 实现复制到剪贴板功能
- 添加项目路由配置
- 实现HTTP客户端和拦截器
- 添加订单、审批记录等类型定义
master
ch 2025-08-27 18:12:34 +08:00
commit 781d598ae7
28 changed files with 9778 additions and 0 deletions

View File

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(npm install)",
"Bash(npm run dev:*)"
],
"deny": [],
"ask": []
}
}

3
.env 100644
View File

@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_TITLE=订单审批系统
VITE_FILE_BASE_URL=http://localhost:8080

3
.env.development 100644
View File

@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_TITLE=订单审批系统
VITE_FILE_BASE_URL=http://localhost:8080

3
.env.production 100644
View File

@ -0,0 +1,3 @@
VITE_API_BASE_URL=https://your-api-domain.com
VITE_APP_TITLE=订单审批系统
VITE_FILE_BASE_URL=https://your-api-domain.com

203
README.md 100644
View File

@ -0,0 +1,203 @@
# 微信小程序H5审批系统
基于Vue 3 + TypeScript + Vant 4开发的移动端H5应用主要用于在微信小程序WebView中运行的订单审批系统。
## 项目特性
- 🚀 Vue 3 + TypeScript + Vite 开发
- 📱 Vant 4 移动端组件库
- 🎯 专为微信小程序WebView优化
- 📋 订单列表查看和搜索
- 📄 订单详情展示
- ✅ 审批操作功能
- 📊 审批历史记录
- 📎 附件预览功能
## 技术栈
- **前端框架**: Vue 3
- **开发语言**: TypeScript
- **构建工具**: Vite
- **UI组件库**: Vant 4
- **路由管理**: Vue Router 4
- **状态管理**: Pinia
- **HTTP客户端**: Axios
- **样式预处理**: Sass
## 目录结构
```
oms_h5/
├── public/ # 静态资源
├── src/
│ ├── api/ # API接口
│ ├── components/ # 公共组件
│ ├── views/ # 页面组件
│ │ ├── List/ # 列表页面
│ │ └── Detail/ # 详情页面
│ ├── store/ # 状态管理
│ ├── utils/ # 工具函数
│ ├── types/ # TypeScript类型定义
│ ├── styles/ # 全局样式
│ └── router/ # 路由配置
├── docs/ # 文档和设计稿
│ └── ui-mockups/ # UI设计图
├── package.json
└── vite.config.ts
```
## 开发环境搭建
### 环境要求
- Node.js >= 16
- npm >= 7 或 yarn >= 1.22
### 安装依赖
```bash
npm install
# 或
yarn install
```
### 启动开发服务器
```bash
npm run dev
# 或
yarn dev
```
### 构建生产版本
```bash
npm run build
# 或
yarn build
```
### 预览构建结果
```bash
npm run preview
# 或
yarn preview
```
## 环境配置
项目支持多环境配置,通过`.env`文件管理:
- `.env` - 通用环境变量
- `.env.development` - 开发环境
- `.env.production` - 生产环境
主要配置项:
```bash
VITE_API_BASE_URL=http://localhost:8080 # API基础URL
VITE_APP_TITLE=订单审批系统 # 应用标题
VITE_FILE_BASE_URL=http://localhost:8080 # 文件服务URL
```
## 主要功能
### 1. 订单列表
- ✅ 订单列表展示
- ✅ 下拉刷新和上拉加载
- ✅ 搜索功能
- ✅ 状态筛选
- ✅ 订单状态标识
### 2. 订单详情
- ✅ 订单基本信息展示
- ✅ 产品信息列表(软件/硬件/维保)
- ✅ 合同附件展示和预览
- ✅ 审批历史时间线
### 3. 审批操作
- ✅ 审批通过/驳回
- ✅ 审批意见输入
- ✅ 操作结果反馈
### 4. 微信小程序适配
- ✅ WebView环境检测
- ✅ 移动端样式适配
- ✅ 触摸手势支持
## API接口
### 订单列表接口
```
POST /project/order/list
```
### 订单详情接口
```
GET /project/order/h5/approve/:id
```
### 审批操作接口
```
POST /project/order/order/approve
```
## 开发规范
### 代码风格
项目使用ESLint + Prettier进行代码规范检查和格式化。
```bash
npm run lint # 代码检查
npm run type-check # 类型检查
```
### Git提交规范
建议使用Angular提交信息规范
```
feat: 新增功能
fix: 修复问题
docs: 文档更新
style: 代码格式调整
refactor: 代码重构
test: 测试相关
chore: 构建/工具链更新
```
## 部署说明
### 生产构建
```bash
npm run build
```
构建完成后,`dist`目录包含所有静态资源文件。
### 服务器配置
由于是SPA应用需要配置服务器将所有路由指向`index.html`。
#### Nginx配置示例
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
### 微信小程序配置
1. 在微信小程序管理后台配置业务域名
2. 确保HTTPS协议部署
3. 配置webview组件的src属性
## 浏览器兼容性
- Chrome >= 64
- Firefox >= 78
- Safari >= 12
- 微信内置浏览器
- 各主流移动端浏览器
## 许可证
MIT License

87
auto-imports.d.ts vendored 100644
View File

@ -0,0 +1,87 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

27
components.d.ts vendored 100644
View File

@ -0,0 +1,27 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
VanButton: typeof import('vant/es')['Button']
VanEmpty: typeof import('vant/es')['Empty']
VanField: typeof import('vant/es')['Field']
VanIcon: typeof import('vant/es')['Icon']
VanList: typeof import('vant/es')['List']
VanLoading: typeof import('vant/es')['Loading']
VanNavBar: typeof import('vant/es')['NavBar']
VanPopup: typeof import('vant/es')['Popup']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanSearch: typeof import('vant/es')['Search']
VanStep: typeof import('vant/es')['Step']
VanSteps: typeof import('vant/es')['Steps']
VanTab: typeof import('vant/es')['Tab']
VanTabs: typeof import('vant/es')['Tabs']
}
}

View File

@ -0,0 +1,72 @@
# UI设计图存放目录
此目录用于存放项目相关的UI设计图和原型图。
## 目录结构
```
ui-mockups/
├── README.md # 本文件
├── wireframes/ # 线框图
│ ├── list-page.png # 列表页面线框图
│ └── detail-page.png # 详情页面线框图
├── designs/ # 设计稿
│ ├── list-page.png # 列表页面设计稿
│ └── detail-page.png # 详情页面设计稿
├── prototypes/ # 交互原型
└── specifications/ # 设计规范
└── style-guide.md # 样式指南
```
## 设计要求
### 1. 移动端适配
- 屏幕宽度375px - 414px
- 适配iPhone和Android主流设备
- 支持横竖屏切换
### 2. 微信小程序WebView适配
- 适配微信小程序导航栏高度
- 考虑安全区域(刘海屏等)
- 遵循微信小程序设计规范
### 3. 页面设计规范
#### 列表页面
- 顶部搜索栏
- 筛选条件(状态筛选)
- 工单卡片列表
- 下拉刷新、上拉加载更多
- 空状态提示
#### 详情页面
- 工单基本信息展示
- 产品信息列表
- 附件展示区域
- 审批历史时间线
- 底部审批操作按钮
### 4. 交互设计
- 页面切换动画
- 加载状态提示
- 操作反馈(成功/失败提示)
- 确认弹窗设计
### 5. 视觉设计
- 主色调:#1890ff蓝色
- 辅助色:#52c41a成功绿、#f5222d错误红
- 背景色:#f5f5f5
- 文字颜色:#333333主要、#666666次要、#999999辅助
## 文件命名规范
- 使用英文命名,单词间用连字符分隔
- 包含页面/组件名称和版本号
- 示例:`order-list-v1.0.png`、`approval-dialog-v2.1.png`
## 版本管理
每次设计更新时请:
1. 更新版本号
2. 在文件名中体现版本
3. 保留历史版本以便回溯

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

30
index.html 100644
View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
<meta name="format-detection" content="telephone=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>订单审批系统</title>
<script>
// 防止页面缩放
document.addEventListener('touchstart', function (event) {
if (event.touches.length > 1) {
event.preventDefault();
}
});
// 微信小程序webview适配
if (window.__wxjs_environment === 'miniprogram') {
// 微信小程序环境
console.log('Running in WeChat MiniProgram WebView');
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4357
package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

37
package.json 100644
View File

@ -0,0 +1,37 @@
{
"name": "oms-h5",
"version": "1.0.0",
"type": "module",
"description": "微信小程序H5审批系统",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"pinia": "^2.1.6",
"vant": "^4.6.6",
"axios": "^1.5.0",
"@vant/touch-emulator": "^1.4.0"
},
"devDependencies": {
"@types/node": "^20.5.9",
"@vitejs/plugin-vue": "^4.3.4",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/tsconfig": "^0.4.0",
"eslint": "^8.47.0",
"eslint-plugin-vue": "^9.17.0",
"prettier": "^3.0.2",
"sass": "^1.66.1",
"typescript": "~5.1.6",
"vite": "^4.4.9",
"vue-tsc": "^1.8.8",
"unplugin-auto-import": "^0.16.6",
"unplugin-vue-components": "^0.25.2"
}
}

16
src/App.vue 100644
View File

@ -0,0 +1,16 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup lang="ts">
// App
</script>
<style lang="scss">
#app {
min-height: 100vh;
background-color: var(--van-background-color);
}
</style>

46
src/api/order.ts 100644
View File

@ -0,0 +1,46 @@
import http from '@/utils/http'
import type { ApiResponse, Order, OrderDetailResponse, ListParams, ApprovalParams } from '@/types'
import type { AxiosResponse } from 'axios'
/**
*
*/
export const getOrderList = (params: ListParams): Promise<AxiosResponse<ApiResponse<{
total: number
rows: Order[]
}>>> => {
// 创建FormData对象
const formData = new FormData()
// 添加参数到FormData
if (params.approve) formData.append('approve', params.approve)
formData.append('page', params.page.toString())
formData.append('pageSize', params.pageSize.toString())
if (params.keyword) formData.append('keyword', params.keyword)
return http.post('/project/order/list', formData)
}
/**
*
*/
export const getOrderDetail = (id: string | number): Promise<AxiosResponse<ApiResponse<OrderDetailResponse>>> => {
return http.get(`/project/order/h5/approve/${id}`)
}
/**
*
*/
export const submitApproval = (params: any): Promise<AxiosResponse<ApiResponse<any>>> => {
// 创建FormData对象
const formData = new FormData()
// 将所有参数添加到FormData中
Object.keys(params).forEach(key => {
if (params[key] !== undefined && params[key] !== null) {
formData.append(key, params[key].toString())
}
})
return http.post('/project/order/order/approve', formData)
}

16
src/env.d.ts vendored 100644
View File

@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

19
src/main.ts 100644
View File

@ -0,0 +1,19 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
// Vant样式
import 'vant/lib/index.css'
// 触摸模拟器 (开发环境使用)
import '@vant/touch-emulator'
// 全局样式
import '@/styles/index.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/list'
},
{
path: '/list',
name: 'OrderList',
component: () => import('@/views/List/index.vue'),
meta: {
title: '订单列表'
}
},
{
path: '/detail/:id',
name: 'OrderDetail',
component: () => import('@/views/Detail/index.vue'),
meta: {
title: '订单详情'
}
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
return { top: 0 }
}
})
// 全局路由守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
if (to.meta?.title) {
document.title = to.meta.title as string
}
next()
})
export default router

159
src/store/order.ts 100644
View File

@ -0,0 +1,159 @@
import { defineStore } from 'pinia'
import type { Order, OrderDetailResponse, ListParams } from '@/types'
import { getOrderList, getOrderDetail } from '@/api/order'
interface OrderState {
// 列表相关
orderList: Order[]
loading: boolean
finished: boolean
currentPage: number
pageSize: number
total: number
keyword: string
// 详情相关
currentOrder: any | null // 临时改为any来避免类型问题
detailLoading: boolean
}
export const useOrderStore = defineStore('order', {
state: (): OrderState => ({
orderList: [],
loading: false,
finished: false,
currentPage: 1,
pageSize: 20,
total: 0,
keyword: '',
currentOrder: null,
detailLoading: false
}),
getters: {
// 获取当前订单基本信息
currentOrderInfo: (state) => state.currentOrder?.projectOrderInfo,
// 获取审批历史
approvalHistory: (state) => state.currentOrder?.approveLog || [],
// 获取当前用户信息
currentUser: (state) => state.currentOrder?.user,
// 检查是否还有更多数据
hasMore: (state) => state.orderList.length < state.total
},
actions: {
/**
*
*/
async loadOrderList(refresh = false) {
if (this.loading) return
if (refresh) {
this.currentPage = 1
this.finished = false
this.orderList = []
}
this.loading = true
try {
const params: ListParams = {
approve:'approve',
page: this.currentPage,
pageSize: this.pageSize,
keyword: this.keyword || undefined
}
const response = await getOrderList(params)
const { total, rows } = response.data
if (refresh) {
this.orderList = rows
} else {
this.orderList.push(...rows)
}
this.total = total
this.currentPage++
// 判断是否已加载完所有数据
if (this.orderList.length >= total) {
this.finished = true
}
return response
} catch (error) {
console.error('加载订单列表失败:', error)
throw error
} finally {
this.loading = false
}
},
/**
*
*/
async searchOrders(keyword: string) {
this.keyword = keyword
await this.loadOrderList(true)
},
/**
*
*/
async fetchOrderDetail(id: string | number) {
this.detailLoading = true
console.log('开始获取订单详情ID:', id)
try {
const response = await getOrderDetail(id)
console.log('API响应:', response)
console.log('响应数据:', response.data)
console.log('实际数据:', response.data.data)
// 直接获取数据对象
const orderData = response.data.data
console.log('订单数据对象:', orderData)
// 确保数据存在再赋值
if (orderData) {
this.currentOrder = orderData
console.log('赋值后的存储数据:', this.currentOrder)
console.log('项目订单信息:', this.currentOrder.projectOrderInfo)
} else {
console.error('订单数据为空')
}
return response
} catch (error) {
console.error('获取订单详情失败:', error)
throw error
} finally {
this.detailLoading = false
}
},
/**
*
*/
clearCurrentOrder() {
this.currentOrder = null
},
/**
*
*/
resetListState() {
this.orderList = []
this.currentPage = 1
this.finished = false
this.loading = false
this.keyword = ''
this.total = 0
}
}
})

View File

@ -0,0 +1,294 @@
// CSS
:root {
//
--primary-color: #1890ff;
--primary-color-dark: #096dd9;
--primary-color-light: #e6f7ff;
//
--success-color: #52c41a;
--warning-color: #faad14;
--error-color: #f5222d;
--info-color: #1890ff;
//
--text-color-primary: #333333;
--text-color-secondary: #666666;
--text-color-tertiary: #999999;
--text-color-disabled: #cccccc;
--background-color-primary: #ffffff;
--background-color-secondary: #f5f5f5;
--background-color-tertiary: #fafafa;
--border-color: #d9d9d9;
--divider-color: #f0f0f0;
//
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-xxl: 24px;
//
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
//
--shadow-light: 0 1px 3px rgba(0, 0, 0, 0.12);
--shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.15);
--shadow-heavy: 0 8px 24px rgba(0, 0, 0, 0.12);
}
//
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-color-primary);
background-color: var(--background-color-secondary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
//
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--background-color-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-color-tertiary);
}
//
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.overflow-hidden {
overflow: hidden;
}
.text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.text-primary {
color: var(--primary-color) !important;
}
.text-success {
color: var(--success-color) !important;
}
.text-warning {
color: var(--warning-color) !important;
}
.text-error {
color: var(--error-color) !important;
}
.text-secondary {
color: var(--text-color-secondary) !important;
}
.text-tertiary {
color: var(--text-color-tertiary) !important;
}
//
.status-tag {
display: inline-block;
padding: 2px 8px;
font-size: 12px;
border-radius: var(--border-radius-sm);
font-weight: 500;
&.pending {
background-color: #fff7e6;
color: var(--warning-color);
border: 1px solid #ffd591;
}
&.approved {
background-color: #f6ffed;
color: var(--success-color);
border: 1px solid #b7eb8f;
}
&.rejected {
background-color: #fff2f0;
color: var(--error-color);
border: 1px solid #ffb3b3;
}
}
//
.page-container {
min-height: 100vh;
background-color: var(--background-color-secondary);
}
.page-content {
padding: var(--spacing-lg);
}
//
.card {
background: var(--background-color-primary);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-light);
margin-bottom: var(--spacing-lg);
overflow: hidden;
}
.card-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--divider-color);
font-weight: 500;
}
.card-body {
padding: var(--spacing-lg);
}
//
.list-item {
padding: var(--spacing-lg);
background: var(--background-color-primary);
border-bottom: 1px solid var(--divider-color);
&:last-child {
border-bottom: none;
}
&:active {
background-color: var(--background-color-tertiary);
}
}
//
.empty-state {
padding: 60px var(--spacing-lg);
text-align: center;
color: var(--text-color-tertiary);
.empty-icon {
font-size: 64px;
color: var(--text-color-disabled);
margin-bottom: var(--spacing-lg);
}
.empty-text {
font-size: 16px;
margin-bottom: var(--spacing-sm);
}
.empty-desc {
font-size: 14px;
}
}
//
.loading-container {
padding: var(--spacing-xl);
text-align: center;
}
// Vant
.van-nav-bar {
background: var(--background-color-primary);
.van-nav-bar__title {
font-weight: 500;
}
}
.van-search {
padding: var(--spacing-lg) var(--spacing-lg) 0;
background: transparent;
.van-search__content {
background: var(--background-color-primary);
border: 1px solid var(--border-color);
}
}
.van-pull-refresh {
background: var(--background-color-secondary);
}
.van-list {
background: var(--background-color-secondary);
}
//
@media (max-width: 375px) {
.page-content {
padding: var(--spacing-md);
}
.card-header,
.card-body {
padding: var(--spacing-md);
}
.list-item {
padding: var(--spacing-md);
}
}

190
src/types/index.ts 100644
View File

@ -0,0 +1,190 @@
// 通用API响应类型
export interface ApiResponse<T = any> {
code: number
msg: string | null
data: T
total?: number
}
// 订单状态类型
export type OrderStatus = '0' | '1' | '2' // 待审批、已审批、已拒绝
// 审批状态类型
export type ApprovalStatus = 1 | 2 | 3 // 待审批、驳回、通过
// 订单信息类型
export interface Order {
id: number
projectId: number
projectCode: string
projectName: string
versionCode: string
industryType: string
bgProperty: string
province: string
orderCode: string
customerName: string
customerCode: string
customerPhone: string
customerUserName: string
shipmentAmount: number
orderStatus: OrderStatus
duty: string
dutyName: string
agentName: string
agentCode: string
businessPerson: string
businessEmail: string
businessPhone: string
currencyType: string
partnerCode: string
partnerName: string
projectPartnerName: string
actualPurchaseAmount: number
deliveryTime: string
estimatedOrderTime: string
orderEndTime: string
approveTime: string
discountFold: number
supplier: string
createTime: string
updateTime: string
remark: string
}
// 审批记录类型
export interface ApprovalRecord {
id?: number
todoId: string
businessKey: string
processKey: string
processName: string
taskName: string
approveUserName: string
allApproveUserName: string
nextAllApproveUserName: string
approveUser: string
applyUserName: string
applyTime: string
extendField1: string
extendField2?: string
taskId: string
processInstanceId: string
approveOpinion?: string
approveStatus?: ApprovalStatus
approveTime: string
formKey?: string
roleName: string
recoveryType?: number
variables?: any
}
// 附件文件类型
export interface AttachmentFile {
id: number
orderId: number
fileName: string
uploadUser: string
uploadUserName: string
uploadTime: string
filePath: string
fileType: string
fileSort: string
fileVersionCode: string
}
// 产品信息类型
export interface ProductInfo {
id: number
projectId: number
productBomCode: string
productName: string
model: string
productCode?: string
productDesc: string
quantity: number
cataloguePrice: number
catalogueAllPrice: number
price: number
allPrice: number
allPriceDisCount?: number
guidanceDiscount: number
vendorCode?: string
discount: number
type: string
value?: string
remark: string
}
// 用户信息类型
export interface UserInfo {
userId: number
userName: string
loginName: string
userType: string
email: string
phonenumber: string
sex: string
avatar: string
status: string
delFlag: string
loginIp: string
loginDate: string
pwdUpdateDate: string
admin: boolean
}
// 订单详情响应类型
export interface OrderDetailResponse {
todo?: any
approveLog: ApprovalRecord[]
projectOrderInfo: Order & {
contractFileList: AttachmentFile[]
configFileList?: AttachmentFile[]
contractTableData: Record<string, AttachmentFile[]>
softwareProjectProductInfoList: ProductInfo[]
hardwareProjectProductInfoList: ProductInfo[]
maintenanceProjectProductInfoList: ProductInfo[]
}
user: UserInfo
}
// 列表查询参数
export interface ListParams {
approve?: string
page: number
pageSize: number
keyword?: string
}
// 审批操作参数
export interface ApprovalParams {
allApproveUserName?: string
applyTime?: string
applyUserName?: string
approveOpinion?: string
approveStatus?: ApprovalStatus
approveTime?: string
approveUser?: string
approveUserName?: string
businessKey?: string
createBy?: string
createTime?: string
extendField1?: string
extendField2?: string
formKey?: string
id?: number
nextAllApproveUserName?: string
processInstanceId?: string
processKey?: string
processName?: string
recoveryType?: number
remark?: string
roleName?: string
taskId?: string
taskName?: string
todoId?: string
updateBy?: string
updateTime?: string
[property: string]: any
}

113
src/utils/http.ts 100644
View File

@ -0,0 +1,113 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiResponse } from '@/types'
import { showToast, showFailToast } from 'vant'
class HttpClient {
private instance: AxiosInstance
constructor(baseURL: string = '/api') {
this.instance = axios.create({
baseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
this.setupInterceptors()
}
private setupInterceptors(): void {
// 请求拦截器
this.instance.interceptors.request.use(
(config) => {
// 在这里可以添加token等认证信息
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 如果数据是FormData删除Content-Type让浏览器自动设置
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
this.instance.interceptors.response.use(
(response: AxiosResponse) => {
// 直接返回完整响应对象
return response
},
(error) => {
let message = '网络错误'
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
message = '未授权,请重新登录'
// 这里可以处理登录跳转逻辑
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求地址不存在'
break
case 500:
message = '服务器内部错误'
break
default:
message = data?.msg || `请求失败 (${status})`
}
} else if (error.request) {
message = '网络连接失败'
}
showFailToast(message)
return Promise.reject(error)
}
)
}
public async get<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.get(url, config)
}
public async post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.post(url, data, config)
}
public async put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.put(url, data, config)
}
public async delete<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.delete(url, config)
}
}
export const http = new HttpClient()
export default http

211
src/utils/index.ts 100644
View File

@ -0,0 +1,211 @@
import type { OrderStatus, ApprovalStatus } from '@/types'
/**
*
*/
export const formatOrderStatus = (status: OrderStatus): string => {
const statusMap = {
'0': '待审批',
'1': '已审批',
'2': '已拒绝'
}
return statusMap[status] || '未知状态'
}
/**
*
*/
export const getOrderStatusColor = (status: OrderStatus): string => {
const colorMap = {
'0': '#fa8c16', // 橙色 - 待审批
'1': '#52c41a', // 绿色 - 已审批
'2': '#f5222d' // 红色 - 已拒绝
}
return colorMap[status] || '#666666'
}
/**
*
*/
export const formatApprovalStatus = (status?: ApprovalStatus): string => {
if (status === undefined || status === null) return '待审批'
const statusMap = {
1: '待审批',
2: '已驳回',
3: '已通过'
}
return statusMap[status] || '待审批'
}
/**
*
*/
export const getApprovalStatusColor = (status?: ApprovalStatus): string => {
if (status === undefined || status === null) return '#fa8c16'
const colorMap = {
1: '#fa8c16', // 橙色 - 待审批
2: '#f5222d', // 红色 - 已驳回
3: '#52c41a' // 绿色 - 已通过
}
return colorMap[status] || '#fa8c16'
}
/**
*
*/
export const formatAmount = (amount: number): string => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2
}).format(amount)
}
/**
*
*/
export const formatDate = (date: string | Date, format: string = 'YYYY-MM-DD'): string => {
if (!date) return ''
const d = new Date(date)
if (isNaN(d.getTime())) return ''
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', year.toString())
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
*
*/
export const formatRelativeTime = (date: string | Date): string => {
if (!date) return ''
const d = new Date(date)
if (isNaN(d.getTime())) return ''
const now = new Date()
const diff = now.getTime() - d.getTime()
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return '刚刚'
} else if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`
} else if (diff < 3 * day) {
return `${Math.floor(diff / day)}天前`
} else {
return formatDate(date, 'YYYY-MM-DD')
}
}
/**
*
*/
export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
/**
*
*/
export const throttle = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout | null = null
let previous = 0
return (...args: Parameters<T>) => {
const now = Date.now()
const remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
func(...args)
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now()
timeout = null
func(...args)
}, remaining)
}
}
}
/**
*
*/
export const isWeixinMiniProgram = (): boolean => {
return (window as any).__wxjs_environment === 'miniprogram'
}
/**
* URL
*/
export const getFilePreviewUrl = (filePath: string): string => {
if (!filePath) return ''
// 如果已经是完整URL直接返回
if (filePath.startsWith('http')) {
return filePath
}
// 拼接基础URL
const baseUrl = import.meta.env.VITE_FILE_BASE_URL || '/api'
return `${baseUrl}${filePath}`
}
/**
*
*/
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text)
return true
} else {
// 降级方案
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
return true
}
} catch (error) {
console.error('复制失败:', error)
return false
}
}

View File

@ -0,0 +1,999 @@
<template>
<div class="order-detail-page">
<!-- 导航栏 -->
<van-nav-bar
title="订单详情"
left-arrow
@click-left="goBack"
/>
<van-loading v-if="detailLoading" class="loading-container" size="24px">
加载中...
</van-loading>
<div v-else-if="currentOrderInfo" class="page-content">
<!-- 项目信息标题 -->
<div class="project-header">
<div class="project-title">
{{ currentOrderInfo.projectName }}REV.{{ currentOrderInfo.versionCode }}
</div>
<div class="project-status">
待审批
</div>
</div>
<!-- Tab标签页 -->
<van-tabs v-model:active="activeTab" sticky>
<!-- 订单信息 -->
<van-tab title="订单信息" name="order">
<div class="tab-content">
<div class="card">
<div class="card-header">
<span>基本信息</span>
</div>
<div class="card-body">
<div class="info-grid">
<div class="info-item">
<span class="label">项目名称</span>
<span class="value">{{ currentOrderInfo.projectName || '' }}</span>
</div>
<div class="info-item">
<span class="label">版本号</span>
<span class="value">{{ currentOrderInfo.versionCode || '' }}</span>
</div>
<div class="info-item">
<span class="label">项目编号</span>
<span class="value">{{ currentOrderInfo.projectCode || '' }}</span>
</div>
<div class="info-item">
<span class="label">最终客户</span>
<span class="value">{{ currentOrderInfo.customerName || '' }}</span>
</div>
<div class="info-item">
<span class="label">BG</span>
<span class="value">{{ currentOrderInfo.bgProperty || '' }}</span>
</div>
<div class="info-item">
<span class="label">行业</span>
<span class="value">{{ currentOrderInfo.industryType || '' }}</span>
</div>
<div class="info-item">
<span class="label">代表处</span>
<span class="value">{{ currentOrderInfo.agentName || '' }}</span>
</div>
<div class="info-item">
<span class="label">进货商接口人</span>
<span class="value">{{ currentOrderInfo.businessPerson || '' }}</span>
</div>
<div class="info-item">
<span class="label">Email</span>
<span class="value">{{ currentOrderInfo.businessEmail || '' }}</span>
</div>
<div class="info-item">
<span class="label">联系方式</span>
<span class="value">{{ currentOrderInfo.businessPhone || '' }}</span>
</div>
<div class="info-item">
<span class="label">合同编号</span>
<span class="value">{{ currentOrderInfo.orderCode || '' }}</span>
</div>
<div class="info-item">
<span class="label">执行单有效截止时间</span>
<span class="value">{{ currentOrderInfo.orderEndTime? formatDate(currentOrderInfo.orderEndTime, 'YYYY-MM-DD') : '' }}</span>
</div>
<div class="info-item">
<span class="label">币种</span>
<span class="value">{{ currentOrderInfo.currencyType || '' }}</span>
</div>
<div class="info-item">
<span class="label">总代进货金额</span>
<span class="value">{{ currentOrderInfo.actualPurchaseAmount ? formatAmount(currentOrderInfo.actualPurchaseAmount) : '' }}</span>
</div>
<div class="info-item">
<span class="label">总代出货金额</span>
<span class="value">{{ currentOrderInfo.shipmentAmount ? formatAmount(currentOrderInfo.shipmentAmount) : '' }}</span>
</div>
<div class="info-item">
<span class="label">要求到货时间</span>
<span class="value">{{ currentOrderInfo.deliveryTime ? formatDate(currentOrderInfo.deliveryTime) : '' }}</span>
</div>
<div class="info-item">
<span class="label">公司直发</span>
<span class="value">{{ currentOrderInfo.companyDelivery}}</span>
</div>
<div class="info-item">
<span class="label">下单通路</span>
<span class="value">{{ currentOrderInfo.orderChannel}}</span>
</div><div class="info-item">
<span class="label">供货商</span>
<span class="value">{{ currentOrderInfo.supplier}}</span>
</div><div class="info-item">
<span class="label">进货商</span>
<span class="value">{{ currentOrderInfo.partnerName}}</span>
</div><div class="info-item">
<span class="label">进货商类型</span>
<span class="value">{{ currentOrderInfo.level}}</span>
</div><div class="info-item">
<span class="label">进货商联系人</span>
<span class="value">{{ currentOrderInfo.partnerUserName}}</span>
</div><div class="info-item">
<span class="label">Email</span>
<span class="value">{{ currentOrderInfo.partnerEmail}}</span>
</div><div class="info-item">
<span class="label">联系方式</span>
<span class="value">{{ currentOrderInfo.partnerPhone}}</span>
</div><div class="info-item">
<span class="label">收货地址</span>
<span class="value">{{ currentOrderInfo.notifierAddress}}</span>
</div><div class="info-item">
<span class="label">收货人</span>
<span class="value">{{ currentOrderInfo.notifier}}</span>
</div><div class="info-item">
<span class="label">Email</span>
<span class="value">{{ currentOrderInfo.notifierEmail}}</span>
</div><div class="info-item">
<span class="label">联系方式</span>
<span class="value">{{ currentOrderInfo.notifierPhone}}</span>
</div><div class="info-item">
<span class="label">其他特别说明</span>
<span class="value">{{ currentOrderInfo.remark}}</span>
</div>
</div>
</div>
</div>
</div>
</van-tab>
<!-- 产品信息 -->
<van-tab title="产品信息" name="product">
<div class="tab-content">
<div v-if="hasProductInfo">
<!-- 软件产品 -->
<div v-if="currentOrderInfo.softwareProjectProductInfoList?.length">
<div v-for="(product, index) in currentOrderInfo.softwareProjectProductInfoList" :key="product.id" class="product-card">
<div class="product-header">
<span class="product-index">{{ index + 1 }}</span>
<div class="product-main-info">
<div class="product-code-price">
<span class="product-code">{{ product.productBomCode }}</span>
<span class="product-total-price">{{ formatAmount(product.allPrice) }}</span>
</div>
</div>
</div>
<div class="product-details">
<div class="product-row">
<span class="product-label">产品型号</span>
<span class="product-value">{{ product.model }}</span>
</div>
<div class="product-row">
<span class="product-label">描述</span>
<span class="product-value">{{ product.productDesc }}</span>
</div>
<div class="product-row">
<span class="product-label">数量</span>
<span class="product-value">{{ product.quantity }}</span>
</div>
<div class="product-row">
<span class="product-label">单价</span>
<span class="product-value">{{ formatAmount(product.price) }}</span>
</div>
</div>
</div>
</div>
<!-- 硬件产品 -->
<div v-if="currentOrderInfo.hardwareProjectProductInfoList?.length">
<div v-for="(product, index) in currentOrderInfo.hardwareProjectProductInfoList" :key="product.id" class="product-card">
<div class="product-header">
<span class="product-index">{{ index + 1 + (currentOrderInfo.softwareProjectProductInfoList?.length || 0) }}</span>
<div class="product-main-info">
<div class="product-code-price">
<span class="product-code">{{ product.productBomCode }}</span>
<span class="product-total-price">{{ formatAmount(product.allPrice) }}</span>
</div>
</div>
</div>
<div class="product-details">
<div class="product-row">
<span class="product-label">产品型号</span>
<span class="product-value">{{ product.model }}</span>
</div>
<div class="product-row">
<span class="product-label">描述</span>
<span class="product-value">{{ product.productDesc }}</span>
</div>
<div class="product-row">
<span class="product-label">数量</span>
<span class="product-value">{{ product.quantity }}</span>
</div>
<div class="product-row">
<span class="product-label">单价</span>
<span class="product-value">{{ formatAmount(product.price) }}</span>
</div>
</div>
</div>
</div>
<!-- 维保产品 -->
<div v-if="currentOrderInfo.maintenanceProjectProductInfoList?.length">
<div v-for="(product, index) in currentOrderInfo.maintenanceProjectProductInfoList" :key="product.id" class="product-card">
<div class="product-header">
<span class="product-index">{{ index + 1 + (currentOrderInfo.softwareProjectProductInfoList?.length || 0) + (currentOrderInfo.hardwareProjectProductInfoList?.length || 0) }}</span>
<div class="product-main-info">
<div class="product-code-price">
<span class="product-code">{{ product.productBomCode }}</span>
<span class="product-total-price">{{ formatAmount(product.allPrice) }}</span>
</div>
</div>
</div>
<div class="product-details">
<div class="product-row">
<span class="product-label">产品型号</span>
<span class="product-value">{{ product.model }}</span>
</div>
<div class="product-row">
<span class="product-label">描述</span>
<span class="product-value">{{ product.productDesc }}</span>
</div>
<div class="product-row">
<span class="product-label">数量</span>
<span class="product-value">{{ product.quantity }}</span>
</div>
<div class="product-row">
<span class="product-label">单价</span>
<span class="product-value">{{ formatAmount(product.price) }}</span>
</div>
</div>
</div>
</div>
<!-- 配置组总计 -->
<div class="product-summary">
<div class="summary-row">
<span class="summary-label">配置组总计</span>
<span class="summary-value">{{ getTotalProductCount() }}</span>
</div>
<div class="summary-row">
<span class="summary-label">总价</span>
<span class="summary-value total-amount">{{ formatAmount(getTotalAmount()) }}</span>
</div>
<div class="summary-row">
<span class="summary-label">现金折扣率</span>
<span class="summary-value">{{ getDiscountRate() }}</span>
</div>
<div class="summary-row final-total">
<span class="summary-label">折后总价</span>
<span class="summary-value final-amount">{{ formatAmount(getFinalTotalAmount()) }}</span>
</div>
</div>
</div>
<!-- 无产品信息时的提示 -->
<div v-else class="empty-state">
<van-empty description="暂无产品信息"/>
</div>
</div>
</van-tab>
<!-- 合同附件 -->
<van-tab title="合同附件" name="attachment">
<div class="tab-content">
<div class="card" v-if="currentOrderInfo.contractFileList?.length">
<div class="card-body">
<div class="file-list">
<div v-for="file in currentOrderInfo.contractFileList" :key="file.id" class="file-item"
@click="previewFile(file)">
<van-icon name="description"/>
<div class="file-info">
<div class="file-name">{{ file.fileName }}</div>
<div class="file-meta">{{ file.uploadUserName }} · {{ formatDate(file.uploadTime) }}</div>
</div>
<van-icon name="arrow"/>
</div>
</div>
</div>
</div>
<!-- 无附件时的提示 -->
<div v-else class="empty-state">
<van-empty description="暂无合同附件"/>
</div>
</div>
</van-tab>
<!-- 审批历史 -->
<van-tab title="审批历史" name="approval">
<div class="tab-content">
<div class="card" v-if="approvalHistory.length">
<div class="card-body">
<van-steps direction="vertical" :active="approvalHistory.length">
<van-step v-for="(record, index) in approvalHistory" :key="record.todoId || index">
<template #inactive-icon>
<van-icon
:name="getStepIcon(record.approveStatus)"
:color="getApprovalStatusColor(record.approveStatus)"
/>
</template>
<template #active-icon>
<van-icon
:name="getStepIcon(record.approveStatus)"
:color="getApprovalStatusColor(record.approveStatus)"
/>
</template>
<div class="approval-item">
<div class="approval-user">提交人{{ record.approveUserName }}</div>
<div v-if="record.nextAllApproveUserName" class="approval-next-user">
接受人{{ record.nextAllApproveUserName }}
</div>
<div class="approval-time">{{ formatDate(record.approveTime, 'YYYY-MM-DD HH:mm') }}</div>
<div v-if="record.approveOpinion" class="approval-opinion">
审批意见{{ record.approveOpinion }}
</div>
</div>
</van-step>
</van-steps>
</div>
</div>
<!-- 无审批历史时的提示 -->
<div v-else class="empty-state">
<van-empty description="暂无审批历史"/>
</div>
</div>
</van-tab>
</van-tabs>
</div>
<!-- 审批操作按钮 -->
<div v-if="showApprovalButtons" class="approval-actions">
<van-button
type="default"
size="large"
@click="showApprovalDialog(0)"
:loading="submitting"
>
驳回
</van-button>
<van-button
type="primary"
size="large"
@click="showApprovalDialog(1)"
:loading="submitting"
>
通过
</van-button>
</div>
<!-- 审批意见弹窗 -->
<van-popup
v-model:show="approvalDialogVisible"
position="bottom"
round
:style="{ height: '40%' }"
>
<div class="approval-dialog">
<div class="dialog-header">
<span>审批意见</span>
<van-icon name="cross" @click="approvalDialogVisible = false"/>
</div>
<div class="dialog-body">
<van-field
v-model="approvalOpinion"
type="textarea"
:placeholder="currentApprovalStatus === 0 ? '请输入驳回原因' : '请输入审批意见'"
rows="4"
autosize
maxlength="500"
show-word-limit
/>
</div>
<div class="dialog-footer">
<van-button
type="primary"
block
@click="submitApproval"
:loading="submitting"
>
确认{{ currentApprovalStatus === 0 ? '驳回' : '通过' }}
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {storeToRefs} from 'pinia'
import {showToast, showSuccessToast} from 'vant'
import {useOrderStore} from '@/store/order'
import {submitApproval as submitApprovalApi} from '@/api/order'
import {
formatOrderStatus,
formatAmount,
formatDate,
formatApprovalStatus,
getApprovalStatusColor,
getFilePreviewUrl
} from '@/utils'
import type {OrderStatus, ApprovalStatus, AttachmentFile} from '@/types'
const route = useRoute()
const router = useRouter()
const orderStore = useOrderStore()
const {currentOrder, currentOrderInfo, approvalHistory, detailLoading} = storeToRefs(orderStore)
//
const approvalDialogVisible = ref(false)
const approvalOpinion = ref('')
const currentApprovalStatus = ref<ApprovalStatus>(3)
const submitting = ref(false)
// Tab
const activeTab = ref('order')
//
const hasProductInfo = computed(() => {
if (!currentOrderInfo.value) return false
return (
currentOrderInfo.value.softwareProjectProductInfoList?.length ||
currentOrderInfo.value.hardwareProjectProductInfoList?.length ||
currentOrderInfo.value.maintenanceProjectProductInfoList?.length
)
})
const showApprovalButtons = computed(() => {
//
return true
})
//
const getStatusClass = (status: OrderStatus) => {
const classMap = {
'0': 'pending',
'1': 'approved',
'2': 'rejected'
}
return classMap[status] || 'pending'
}
//
const getStepIcon = (status?: ApprovalStatus) => {
if (status === undefined || status === null) return 'clock'
const iconMap = {
1: 'clock',
2: 'close',
3: 'success'
}
return iconMap[status] || 'clock'
}
//
const getTotalProductCount = () => {
if (!currentOrderInfo.value) return 0
const softwareCount = currentOrderInfo.value.softwareProjectProductInfoList?.length || 0
const hardwareCount = currentOrderInfo.value.hardwareProjectProductInfoList?.length || 0
const maintenanceCount = currentOrderInfo.value.maintenanceProjectProductInfoList?.length || 0
return softwareCount + hardwareCount + maintenanceCount
}
//
const getTotalAmount = () => {
if (!currentOrderInfo.value) return 0
let total = 0
//
if (currentOrderInfo.value.softwareProjectProductInfoList) {
total += currentOrderInfo.value.softwareProjectProductInfoList.reduce((sum, product) => sum + (product.allPrice || 0), 0)
}
//
if (currentOrderInfo.value.hardwareProjectProductInfoList) {
total += currentOrderInfo.value.hardwareProjectProductInfoList.reduce((sum, product) => sum + (product.allPrice || 0), 0)
}
//
if (currentOrderInfo.value.maintenanceProjectProductInfoList) {
total += currentOrderInfo.value.maintenanceProjectProductInfoList.reduce((sum, product) => sum + (product.allPrice || 0), 0)
}
return total
}
//
const getDiscountRate = () => {
if (!currentOrderInfo.value || !currentOrderInfo.value.discountFold) {
return '100%'
}
return (currentOrderInfo.value.discountFold * 100).toFixed(1) + '%'
}
//
const getFinalTotalAmount = () => {
const totalAmount = getTotalAmount()
const discount = currentOrderInfo.value?.discountFold || 1
return totalAmount * discount
}
//
const goBack = () => {
router.back()
}
//
const previewFile = (file: AttachmentFile) => {
if (!file.filePath) {
showToast('文件路径不存在')
return
}
const url = getFilePreviewUrl(file.filePath)
window.open(url, '_blank')
}
//
const showApprovalDialog = (status: ApprovalStatus) => {
currentApprovalStatus.value = status
approvalOpinion.value = ''
approvalDialogVisible.value = true
}
//
const submitApproval = async () => {
if (!currentOrder.value || !currentOrder.value.todo) {
showToast('审批信息不完整')
return
}
const opinion = approvalOpinion.value.trim()
if (currentApprovalStatus.value === 0 && !opinion) {
showToast('请输入驳回原因')
return
}
submitting.value = true
try {
const params = {
...currentOrder.value.todo, // todo
approveOpinion: opinion || undefined , //
variables:{
approveBtn:currentApprovalStatus.value,
comment:opinion
}
}
console.log('提交审批参数:', params)
await submitApprovalApi(params)
showSuccessToast(currentApprovalStatus.value === 0 ? '驳回成功' : '审批通过')
approvalDialogVisible.value = false
//
await orderStore.fetchOrderDetail(route.params.id as string)
} catch (error) {
console.error('提交审批失败:', error)
showToast('提交审批失败')
} finally {
submitting.value = false
}
}
onMounted(async () => {
const id = route.params.id as string
if (id) {
console.log('详情页面加载订单ID:', id)
try {
const result = await orderStore.fetchOrderDetail(id)
console.log('获取订单详情结果:', result)
console.log('当前订单信息:', currentOrder.value)
console.log('当前订单基本信息:', currentOrderInfo.value)
} catch (error) {
console.error('获取订单详情失败:', error)
showToast('获取订单详情失败')
}
}
})
</script>
<style lang="scss" scoped>
.order-detail-page {
min-height: 100vh;
background-color: var(--background-color-secondary);
padding-bottom: 80px; //
}
.page-content {
// paddingtabs
}
.project-header {
background: var(--background-color-primary);
padding: var(--spacing-lg);
border-bottom: 1px solid var(--divider-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.project-title {
font-size: 18px;
font-weight: 600;
color: var(--text-color-primary);
flex: 1;
word-break: break-all;
}
.project-status {
background: #FFF7E6;
color: #D48806;
padding: 4px 12px;
border-radius: var(--border-radius-sm);
font-size: 12px;
font-weight: 500;
border: 1px solid #FFD591;
}
.tab-content {
padding: var(--spacing-lg);
}
.loading-container {
padding: 60px var(--spacing-lg);
text-align: center;
}
.empty-state {
padding: 60px var(--spacing-lg);
}
.info-grid {
display: grid;
gap: var(--spacing-md);
}
.info-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
.label {
color: var(--text-color-secondary);
font-size: 14px;
min-width: 100px;
flex-shrink: 0;
}
.value {
color: var(--text-color-primary);
font-size: 14px;
text-align: right;
word-break: break-all;
&.amount {
color: var(--error-color);
font-weight: 500;
}
}
}
.product-section {
&:not(:last-child) {
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--divider-color);
}
}
.section-title {
font-size: 16px;
font-weight: 500;
color: var(--text-color-primary);
margin-bottom: var(--spacing-md);
}
.product-item {
background: var(--background-color-tertiary);
padding: var(--spacing-md);
border-radius: var(--border-radius-sm);
margin-bottom: var(--spacing-md);
&:last-child {
margin-bottom: 0;
}
.product-name {
font-size: 14px;
font-weight: 500;
color: var(--text-color-primary);
margin-bottom: var(--spacing-xs);
}
.product-desc {
font-size: 12px;
color: var(--text-color-secondary);
margin-bottom: var(--spacing-sm);
line-height: 1.4;
}
.product-info {
display: flex;
gap: var(--spacing-md);
font-size: 12px;
color: var(--text-color-tertiary);
}
}
.file-list {
.file-item {
display: flex;
align-items: center;
padding: var(--spacing-md);
background: var(--background-color-tertiary);
border-radius: var(--border-radius-sm);
margin-bottom: var(--spacing-sm);
cursor: pointer;
&:last-child {
margin-bottom: 0;
}
&:active {
background: var(--border-color);
}
.van-icon:first-child {
color: var(--primary-color);
margin-right: var(--spacing-md);
}
.file-info {
flex: 1;
.file-name {
font-size: 14px;
color: var(--text-color-primary);
margin-bottom: var(--spacing-xs);
}
.file-meta {
font-size: 12px;
color: var(--text-color-tertiary);
}
}
.van-icon:last-child {
color: var(--text-color-tertiary);
}
}
}
.approval-item {
.approval-user,
.approval-time,
.approval-next-user {
font-size: 12px;
color: var(--text-color-secondary);
margin-bottom: var(--spacing-xs);
}
.approval-next-user {
color: var(--primary-color);
font-weight: 500;
}
.approval-opinion {
font-size: 12px;
color: var(--text-color-primary);
background: var(--background-color-tertiary);
padding: var(--spacing-sm);
border-radius: var(--border-radius-sm);
line-height: 1.4;
}
}
.approval-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: var(--spacing-lg);
background: var(--background-color-primary);
border-top: 1px solid var(--divider-color);
display: flex;
gap: var(--spacing-lg);
.van-button {
flex: 1;
}
}
.approval-dialog {
height: 100%;
display: flex;
flex-direction: column;
.dialog-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--divider-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: 500;
.van-icon {
cursor: pointer;
}
}
.dialog-body {
flex: 1;
padding: var(--spacing-lg);
}
.dialog-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--divider-color);
}
}
:deep(.van-steps--vertical) {
padding-left: 0;
}
:deep(.van-step__content) {
padding-bottom: var(--spacing-lg);
}
//
.product-card {
background: var(--background-color-primary);
border-radius: var(--border-radius-md);
margin-bottom: var(--spacing-md);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:last-child {
margin-bottom: 0;
}
}
.product-header {
background: var(--background-color-secondary);
padding: var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.product-index {
background: var(--primary-color);
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.product-main-info {
flex: 1;
}
.product-code-price {
display: flex;
justify-content: space-between;
align-items: center;
.product-code {
font-size: 16px;
font-weight: 600;
color: var(--text-color-primary);
}
.product-total-price {
font-size: 18px;
font-weight: 700;
}
}
.product-details {
padding: var(--spacing-md);
}
.product-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-sm);
&:last-child {
margin-bottom: 0;
}
}
.product-label {
color: var(--text-color-secondary);
font-size: 14px;
min-width: 80px;
flex-shrink: 0;
}
.product-value {
color: var(--text-color-primary);
font-size: 14px;
text-align: right;
flex: 1;
word-break: break-all;
}
//
.product-summary {
background: var(--background-color-primary);
border-radius: var(--border-radius-md);
padding: var(--spacing-lg);
margin-top: var(--spacing-lg);
border: 2px solid var(--primary-color);
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
&:last-child {
margin-bottom: 0;
}
}
.summary-label {
font-size: 16px;
font-weight: 500;
color: var(--text-color-primary);
}
.summary-value {
font-size: 16px;
font-weight: 600;
color: var(--text-color-primary);
&.total-amount {
font-size: 18px;
font-weight: 700;
}
&.final-amount {
font-size: 20px;
font-weight: 700;
color: var(--primary-color);
}
}
.final-total {
border-top: 2px solid var(--divider-color);
padding-top: var(--spacing-md);
margin-top: var(--spacing-md);
.summary-label {
font-size: 18px;
font-weight: 600;
color: var(--primary-color);
}
}
</style>

View File

@ -0,0 +1,198 @@
<template>
<div class="order-list-page">
<!-- 搜索栏 -->
<van-search
v-model="searchKeyword"
placeholder="搜索订单编号、客户名称"
@search="handleSearch"
@clear="handleClear"
/>
<!-- 下拉刷新 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 订单列表 -->
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div v-for="order in orderList" :key="order.id" class="order-item" @click="goToDetail(order.id)">
<div class="order-header">
<div class="order-code">{{ order.orderCode }}</div>
<div class="status-tag" :class="getStatusClass(order.orderStatus)">
{{ formatOrderStatus(order.orderStatus) }}
</div>
</div>
<div class="order-info">
<div class="info-row">
<span class="label">项目名称</span>
<span class="value">{{ order.projectName }}</span>
</div>
<div class="info-row">
<span class="label">客户名称</span>
<span class="value">{{ order.customerName }}</span>
</div>
<div class="info-row">
<span class="label">订单金额</span>
<span class="value amount">{{ formatAmount(order.shipmentAmount) }}</span>
</div>
<div class="info-row">
<span class="label">创建时间</span>
<span class="value">{{ formatDate(order.createTime, 'YYYY-MM-DD HH:mm') }}</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && orderList.length === 0" class="empty-state">
<van-empty description="暂无订单数据" />
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useOrderStore } from '@/store/order'
import { formatOrderStatus, formatAmount, formatDate } from '@/utils'
import type { OrderStatus } from '@/types'
const router = useRouter()
const orderStore = useOrderStore()
const { orderList, loading, finished } = storeToRefs(orderStore)
//
const searchKeyword = ref('')
const refreshing = ref(false)
//
const getStatusClass = (status: OrderStatus) => {
const classMap = {
'0': 'pending',
'1': 'approved',
'2': 'rejected'
}
return classMap[status] || 'pending'
}
//
const onLoad = async () => {
try {
await orderStore.loadOrderList()
} catch (error) {
console.error('加载订单列表失败:', error)
}
}
//
const onRefresh = async () => {
try {
await orderStore.loadOrderList(true)
refreshing.value = false
} catch (error) {
refreshing.value = false
console.error('刷新失败:', error)
}
}
//
const handleSearch = async () => {
try {
await orderStore.searchOrders(searchKeyword.value.trim())
} catch (error) {
console.error('搜索失败:', error)
}
}
//
const handleClear = async () => {
searchKeyword.value = ''
try {
await orderStore.searchOrders('')
} catch (error) {
console.error('清空搜索失败:', error)
}
}
//
const goToDetail = (id: number) => {
router.push(`/detail/${id}`)
}
onMounted(() => {
//
orderStore.resetListState()
onLoad()
})
</script>
<style lang="scss" scoped>
.order-list-page {
min-height: 100vh;
background-color: var(--background-color-secondary);
}
.order-item {
background: var(--background-color-primary);
margin-bottom: var(--spacing-sm);
padding: var(--spacing-lg);
cursor: pointer;
transition: background-color 0.2s;
&:active {
background-color: var(--background-color-tertiary);
}
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.order-code {
font-size: 16px;
font-weight: 500;
color: var(--text-color-primary);
}
.order-info {
.info-row {
display: flex;
margin-bottom: var(--spacing-sm);
&:last-child {
margin-bottom: 0;
}
.label {
width: 80px;
color: var(--text-color-secondary);
font-size: 14px;
flex-shrink: 0;
}
.value {
flex: 1;
color: var(--text-color-primary);
font-size: 14px;
word-break: break-all;
&.amount {
color: var(--error-color);
font-weight: 500;
}
}
}
}
.empty-state {
padding: 60px var(--spacing-lg);
}
</style>

13
tsconfig.json 100644
View File

@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
}
}

65
vite.config.ts 100644
View File

@ -0,0 +1,65 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [VantResolver()]
}),
Components({
resolvers: [VantResolver()]
})
],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
host: '0.0.0.0',
port: 3000,
open: true,
proxy: {
'/api': {
target: 'http://192.168.2.134:28080',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, ''),
configure: (proxy, options) => {
// proxy.on('proxyReq', (proxyReq, req, res) => {
// // 设置请求头
// proxyReq.setHeader('Access-Control-Allow-Origin', '*')
// proxyReq.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,PATCH,OPTIONS')
// proxyReq.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With')
// })
proxy.on('proxyReq', (proxyReq, req, res) => {
proxyReq.setHeader('Origin', options.target);
proxyReq.setHeader('Referer', options.target);
});
proxy.on('proxyRes', (proxyRes, req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
});
}
}
}
},
build: {
target: 'es2015',
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
rollupOptions: {
output: {
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js',
assetFileNames: '[ext]/[name]-[hash].[ext]'
}
}
}
})

2563
概要设计文档.md 100644

File diff suppressed because it is too large Load Diff