chore(mobile-h5): add existing H5 app sources to the monorepo

Track the weighbridge mobile-h5 codebase (excluding node_modules, dist, and local .env) so subsequent adaptation tasks can evolve it in-repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-03 22:28:53 +08:00
1 parent f8f9c0ceff
commit a4b13fa2bc
91 files changed
+15791

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
.DS_Store
*.log
.env.local
.env.*.local
.auto-import.json
src/types/*.d.ts
.env
+115
View File
@@ -0,0 +1,115 @@
# 移动端开发说明
## 已完成功能
### 1. 登录页面 (`/login`)
- ✅ 用户名/密码登录
- ✅ 使用背景图片 `sx1.jpg`
- ✅ Logo 显示 `logo-sx.png`
- ✅ 登录成功后跳转到仪表盘
### 2. 仪表盘 (`/dashboard`)
- ✅ 今日入库/出库统计(车辆数)
- ✅ 功能菜单导航(卡片式)
- ✅ 用户信息显示(头像 + 用户名)
- ✅ 退出登录功能
- ✅ 管理员可见用户管理入口
### 3. 计划管理 (`/plans`)
- ✅ 计划列表(卡片式展示)
- ✅ 搜索功能(往来单位/计划号)
- ✅ 新增计划
- ✅ 编辑计划
- ✅ 查看关联数据(出入库单、地磅单)
- ✅ 生成出入库单(跳转到出入库页面)
- ✅ 删除计划
- ✅ 执行状态显示(待入库/入库中/已入库等)
### 4. 出入库管理 (`/inventory`)
- ✅ 出入库记录列表(卡片式展示)
- ✅ 搜索功能(往来单位/产品)
- ✅ 新增记录
- ✅ 编辑记录
- ✅ 查看关联数据(计划、地磅单)
- ✅ 生成地磅单(跳转到地磅单页面)
- ✅ 删除记录
- ✅ 支持从计划管理跳转并自动填充数据
### 5. 地磅单管理 (`/weighing`)
- ✅ 地磅单列表(卡片式展示)
- ✅ 搜索功能(车牌号/货物名称)
- ✅ 新增地磅单
- ✅ 编辑地磅单
- ✅ 毛重/皮重/净重计算(自动)
- ✅ 毛重/皮重时间自动填充
- ✅ 作废功能
- ✅ 删除功能
- ✅ 查看关联数据(计划、出入库单)
- ✅ 支持从出入库管理跳转并自动填充数据
### 6. 基础资料 (`/master-data`)
- ✅ 标签页切换(往来单位/货物)
- ✅ 往来单位管理
- 列表展示
- 新增/编辑/删除
- 搜索功能
- ✅ 货物管理
- 列表展示
- 新增/编辑/删除
- 搜索功能
### 7. 用户管理 (`/user`)
- ✅ 用户列表(卡片式展示)
- ✅ 新增用户
- ✅ 编辑用户(修改密码、角色)
- ✅ 删除用户(admin 用户不可删除)
- ✅ 角色显示(管理员/操作员)
## 技术实现
### 组件使用
- **列表**: `van-list` + 自定义卡片组件
- **表单**: `van-form` + `van-field`
- **选择器**: `van-picker`(公司、货物)
- **日期选择**: 原生 `input[type="date"]`
- **弹窗**: `van-popup`(底部弹出,高度 90%)
- **按钮**: `van-button`
- **标签**: `van-tag`
- **搜索**: `van-search`
- **反馈**: `Toast`、`Dialog`、`Notify`
- **加载**: `van-loading`
- **空状态**: `van-empty`
### 数据流
- API 调用与 Web 端完全一致
- Store 状态管理与 Web 端共享
- 路由跳转支持参数传递
- 表单验证与 Web 端一致
### 移动端优化
- 卡片式布局,信息层次清晰
- 底部弹窗表单,符合移动端操作习惯
- 触摸友好的按钮尺寸
- 下拉加载更多数据
- 原生日期选择器,体验更好
## 功能对比
| 功能 | Web 端 | 移动端 | 状态 |
|------|--------|--------|------|
| 登录 | ✅ | ✅ | 一致 |
| 仪表盘 | ✅ | ✅ | 一致 |
| 计划管理 | ✅ | ✅ | 一致 |
| 出入库管理 | ✅ | ✅ | 一致 |
| 地磅单管理 | ✅ | ✅ | 一致 |
| 基础资料 | ✅ | ✅ | 一致 |
| 用户管理 | ✅ | ✅ | 一致 |
## 下一步
项目已完整实现所有功能,可以:
1. 运行 `npm install` 安装依赖
2. 运行 `npm run dev` 启动开发服务器
3. 测试各项功能
4. 根据实际使用情况优化 UI/UX
+289
View File
@@ -0,0 +1,289 @@
# Mobile-H5 与 Web 端功能对比报告
## 总体概览
| 界面 | Mobile-H5 | Web端 | 功能一致性 | 备注 |
|------|-----------|-------|-----------|------|
| 登录 | ✅ | ✅ | ✅ 基本一致 | UI适配移动端 |
| 仪表盘 | ✅ | ✅ | ⚠️ 部分差异 | 缺少图表展示 |
| 计划管理 | ✅ | ✅ | ⚠️ 部分差异 | 缺少导出功能、查看关联数据 |
| 出入库管理 | ✅ | ✅ | ⚠️ 部分差异 | 缺少导出功能、查看关联数据 |
| 地磅单管理 | ✅ | ✅ | ⚠️ 部分差异 | 缺少导出功能、查看关联数据 |
| 基础资料 | ✅ | ✅ | ⚠️ 部分差异 | 缺少弹窗编辑(使用详情页) |
| 用户管理 | ✅ | ✅ | ⚠️ 部分差异 | 缺少弹窗编辑(使用详情页) |
---
## 详细对比
### 1. 登录界面 ✅
**功能一致性:基本一致**
**Mobile-H5:**
- ✅ 用户名/密码输入
- ✅ 登录按钮
- ✅ 登录成功跳转
- ✅ 错误提示
**Web端:**
- ✅ 用户名/密码输入
- ✅ 记住密码选项
- ✅ 登录按钮
- ✅ 登录成功跳转
- ✅ 错误提示
- ✅ 主题切换
- ✅ 语言切换
**差异:**
- Web端有"记住密码"功能
- Web端有主题和语言切换功能(移动端不需要)
---
### 2. 仪表盘 ⚠️
**功能一致性:部分差异**
**Mobile-H5:**
- ✅ 今日入库/出库统计(车数)
- ✅ 菜单导航(计划管理、出入库管理、地磅单管理、基础资料、用户管理)
- ✅ 用户信息显示
- ✅ 退出登录
**Web端:**
- ✅ 今日入库/出库统计(车数)
- ✅ 今日入库量图表(按货物,t)
- ✅ 今日出库量图表(按货物,t)
**差异:**
- ❌ Mobile-H5 **缺少**图表展示功能
- ❌ Mobile-H5 **缺少**按货物统计的图表
- ✅ Mobile-H5 有菜单导航(Web端通过侧边栏导航)
**建议:**
- 考虑在移动端添加简化的图表展示(可选,因为移动端屏幕较小)
---
### 3. 计划管理 ⚠️
**功能一致性:部分差异**
**Mobile-H5:**
- ✅ 列表展示(卡片式)
- ✅ 搜索功能(往来单位/计划号)
- ✅ 新增计划
- ✅ 编辑计划
- ✅ 删除计划
- ✅ 生成出入库单
- ✅ 分页加载
**Web端:**
- ✅ 列表展示(表格)
- ✅ 搜索功能(往来单位/计划号)
- ✅ 新增计划(弹窗)
- ✅ 编辑计划(弹窗)
- ✅ 删除计划
- ✅ 生成出入库单
- ✅ 查看关联数据(弹窗显示关联的出入库单和地磅单)
- ✅ 导出Excel
- ✅ 分页
**差异:**
- ❌ Mobile-H5 **缺少**导出Excel功能
- ❌ Mobile-H5 **缺少**查看关联数据功能(Web端可以查看计划关联的出入库单和地磅单)
- ✅ Mobile-H5 使用详情页编辑(Web端使用弹窗)
- ✅ Mobile-H5 使用卡片式展示(Web端使用表格)
**建议:**
- 添加导出功能(可以导出为CSV或Excel)
- 添加查看关联数据功能(可以在详情页或弹窗中显示)
---
### 4. 出入库管理 ⚠️
**功能一致性:部分差异**
**Mobile-H5:**
- ✅ 列表展示(卡片式)
- ✅ 搜索功能(往来单位/产品)
- ✅ 新增记录
- ✅ 编辑记录
- ✅ 删除记录
- ✅ 生成地磅单
- ✅ 分页加载
**Web端:**
- ✅ 列表展示(表格)
- ✅ 搜索功能(往来单位/产品)
- ✅ 新增记录(弹窗)
- ✅ 编辑记录(弹窗)
- ✅ 删除记录
- ✅ 生成地磅单
- ✅ 查看关联数据(弹窗显示关联的计划和地磅单)
- ✅ 导出Excel
- ✅ 分页
- ✅ 显示更多字段(净重、毛重、皮重、扣杂等)
**差异:**
- ❌ Mobile-H5 **缺少**导出Excel功能
- ❌ Mobile-H5 **缺少**查看关联数据功能(Web端可以查看出入库单关联的计划和地磅单)
- ❌ Mobile-H5 **缺少**显示净重、毛重、皮重、扣杂等字段(这些字段在地磅单中)
- ✅ Mobile-H5 使用详情页编辑(Web端使用弹窗)
- ✅ Mobile-H5 使用卡片式展示(Web端使用表格)
**建议:**
- 添加导出功能
- 添加查看关联数据功能
- 考虑在详情页显示更多字段
---
### 5. 地磅单管理 ⚠️
**功能一致性:部分差异**
**Mobile-H5:**
- ✅ 列表展示(卡片式)
- ✅ 搜索功能(车牌号/货物名称)
- ✅ 新增地磅单
- ✅ 编辑地磅单
- ✅ 删除地磅单
- ✅ 作废地磅单
- ✅ 分页加载
- ✅ 显示作废状态
**Web端:**
- ✅ 列表展示(表格)
- ✅ 搜索功能(车牌号/货物名称)
- ✅ 新增地磅单(弹窗)
- ✅ 编辑地磅单(弹窗)
- ✅ 删除地磅单
- ✅ 作废地磅单
- ✅ 查看关联数据(弹窗显示关联的计划和出入库单)
- ✅ 导出Excel
- ✅ 分页
- ✅ 显示更多字段(计划单号、出入库单号、本次量、净重、毛重、皮重、扣杂等)
**差异:**
- ❌ Mobile-H5 **缺少**导出Excel功能
- ❌ Mobile-H5 **缺少**查看关联数据功能(Web端可以查看地磅单关联的计划和出入库单)
- ❌ Mobile-H5 **缺少**显示计划单号、出入库单号、本次量等字段
- ✅ Mobile-H5 使用详情页编辑(Web端使用弹窗)
- ✅ Mobile-H5 使用卡片式展示(Web端使用表格)
**建议:**
- 添加导出功能
- 添加查看关联数据功能
- 在列表或详情页显示更多字段
---
### 6. 基础资料 ⚠️
**功能一致性:部分差异**
**Mobile-H5:**
- ✅ 往来单位管理(Tab切换)
- ✅ 货物管理(Tab切换)
- ✅ 搜索功能
- ✅ 新增(跳转到详情页)
- ✅ 编辑(跳转到详情页)
- ✅ 删除
- ✅ 分页加载
**Web端:**
- ✅ 往来单位管理(Tab切换)
- ✅ 货物管理(Tab切换)
- ✅ 搜索功能
- ✅ 新增(弹窗)
- ✅ 编辑(弹窗)
- ✅ 删除
- ✅ 分页
**差异:**
- ✅ Mobile-H5 使用详情页编辑(Web端使用弹窗)
- ✅ Mobile-H5 使用卡片式展示(Web端使用表格)
**功能基本一致,只是交互方式不同(弹窗 vs 详情页)**
---
### 7. 用户管理 ⚠️
**功能一致性:部分差异**
**Mobile-H5:**
- ✅ 列表展示(卡片式)
- ✅ 新增用户(跳转到详情页)
- ✅ 编辑用户(跳转到详情页)
- ✅ 删除用户
- ✅ 禁止删除admin用户
- ✅ 分页加载
**Web端:**
- ✅ 列表展示(表格)
- ✅ 新增用户(弹窗)
- ✅ 编辑用户(弹窗)
- ✅ 删除用户
- ✅ 禁止删除admin用户
- ✅ 分页
**差异:**
- ✅ Mobile-H5 使用详情页编辑(Web端使用弹窗)
- ✅ Mobile-H5 使用卡片式展示(Web端使用表格)
**功能基本一致,只是交互方式不同(弹窗 vs 详情页)**
---
## 总结
### 功能缺失清单
1. **仪表盘**
- ❌ 缺少图表展示(按货物统计的入库/出库量)
2. **计划管理**
- ❌ 缺少导出Excel功能
- ❌ 缺少查看关联数据功能
3. **出入库管理**
- ❌ 缺少导出Excel功能
- ❌ 缺少查看关联数据功能
- ❌ 缺少显示净重、毛重、皮重、扣杂等字段
4. **地磅单管理**
- ❌ 缺少导出Excel功能
- ❌ 缺少查看关联数据功能
- ❌ 缺少显示计划单号、出入库单号、本次量等字段
### 交互方式差异(可接受)
- Mobile-H5 使用详情页进行新增/编辑,Web端使用弹窗
- Mobile-H5 使用卡片式列表,Web端使用表格
- 这些差异是合理的,因为移动端和PC端的交互习惯不同
### 建议优先级
**高优先级:**
1. 添加查看关联数据功能(计划、出入库、地磅单之间的关联关系)
2. 添加导出功能(Excel/CSV)
**中优先级:**
3. 在详情页或列表中添加更多字段显示
4. 考虑添加简化的图表展示(可选)
**低优先级:**
5. 保持当前的交互方式差异(详情页 vs 弹窗)是合理的
---
## 备注
- Mobile-H5 的详情页功能需要单独检查,本报告主要对比列表页功能
- 所有界面都支持基本的CRUD操作,核心功能一致
- 移动端适配良好,交互体验符合移动端习惯
+148
View File
@@ -0,0 +1,148 @@
# 分页加载实现说明
## ✅ 已实现分页加载的页面
所有数据列表页面都已实现**滚动分页加载**功能:
1. **计划管理** (`/plans`) ✅
2. **出入库管理** (`/inventory`) ✅
3. **地磅单管理** (`/weighing`) ✅
4. **基础资料 - 往来单位** (`/master-data`) ✅
5. **基础资料 - 货物** (`/master-data`) ✅
6. **用户管理** (`/user`) ✅
## 实现原理
### 1. 数据结构
每个列表页面都维护两个数据数组:
- `allData`: 存储从 API 获取的完整数据
- `tableData`: 当前显示的数据(分页后的数据)
### 2. 分页参数
- `currentPage`: 当前页码(从 1 开始)
- `pageSize`: 每页数据量(默认 20 条)
### 3. 加载逻辑
```typescript
const loadData = async (reset = false) => {
// 重置时清空数据
if (reset) {
currentPage.value = 1
allData.value = []
tableData.value = []
finished.value = false
}
loading.value = true
try {
// 第一页或重置时,重新获取所有数据
if (reset || currentPage.value === 1) {
const data = await api.list(params)
allData.value = data
}
// 分页切片
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
const newData = allData.value.slice(start, end)
// 追加或替换数据
if (reset || currentPage.value === 1) {
tableData.value = newData
} else {
tableData.value.push(...newData)
}
// 判断是否加载完成
if (tableData.value.length >= allData.value.length) {
finished.value = true
} else {
currentPage.value++
}
} catch (error) {
Toast.fail('加载数据失败')
finished.value = true
} finally {
loading.value = false
}
}
```
### 4. 滚动加载
使用 Vant 的 `van-list` 组件实现滚动加载:
```vue
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<!-- 列表内容 -->
</van-list>
```
```typescript
const onLoad = () => {
if (!finished.value) {
loadData() // 加载下一页
}
}
```
## 使用场景
### 初始加载
```typescript
onMounted(() => {
loadData(true) // 重置并加载第一页
})
```
### 搜索时
```typescript
const handleSearch = () => {
loadData(true) // 重置并重新加载
}
```
### 增删改后
```typescript
// 新增/编辑/删除后
await api.create(data)
Toast.success('操作成功')
loadData(true) // 重置并重新加载
```
## 用户体验
1. **首次加载**: 显示第一页数据(20 条)
2. **滚动到底部**: 自动加载下一页数据
3. **加载中**: 显示加载状态
4. **加载完成**: 显示"没有更多了"提示
5. **搜索**: 重置分页,重新加载数据
6. **增删改**: 重置分页,刷新列表
## 性能优化
- **前端分页**: 首次获取所有数据,后续分页在前端完成
- **按需加载**: 每次只加载 20 条数据,减少 DOM 渲染压力
- **数据缓存**: `allData` 缓存完整数据,避免重复请求
## 注意事项
1. **搜索会重置分页**: 搜索时会重新获取数据并重置分页
2. **增删改会刷新**: 增删改操作后会重置分页并刷新列表
3. **数据量限制**: 如果数据量非常大(>1000 条),建议后端实现分页接口
## 未来优化
如果数据量很大,可以考虑:
1. 后端分页接口(需要后端支持)
2. 虚拟滚动(使用 `vue-virtual-scroller`)
3. 数据懒加载(只加载可见区域的数据)
+242
View File
@@ -0,0 +1,242 @@
# 移动端 H5 项目
基于 Vue 3 + Vite + Vant 的纯 H5 移动端应用,功能与 Web 端(art-design-pro)完全一致,仅展示形式适配移动端。
## 技术栈
- **框架**: Vue 3
- **构建工具**: Vite
- **UI 组件库**: Vant 4(自动引入)
- **状态管理**: Pinia
- **路由**: Vue Router
- **HTTP 客户端**: Axios
- **样式**: SCSS
- **日期处理**: dayjs
## 项目结构
```
mobile-h5/
├── src/
│ ├── api/ # API 接口(与 Web 端共享)
│ ├── assets/ # 静态资源
│ │ ├── img/ # 图片资源(logo、头像、背景图等)
│ │ └── styles/ # 样式文件
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理(与 Web 端共享)
│ │ └── modules/ # Store 模块
│ ├── utils/ # 工具函数(与 Web 端共享)
│ │ └── http/ # HTTP 请求封装
│ ├── views/ # 页面
│ │ ├── login/ # 登录页 ✅
│ │ ├── dashboard/ # 仪表盘 ✅
│ │ ├── plans/ # 计划管理 ✅
│ │ ├── inventory/ # 出入库管理 ✅
│ │ ├── weighing/ # 地磅单管理 ✅
│ │ ├── master-data/ # 基础资料 ✅
│ │ └── user/ # 用户管理 ✅
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── index.html
├── package.json
├── vite.config.ts
└── tsconfig.json
```
## 资源文件
已复制的资源文件:
- `logo-sx.png` - Logo
- `favicon.ico` - 网站图标
- `avatar11.png` - 默认头像
- `sx1.jpg` - 登录页背景图
## 安装依赖
```bash
cd mobile-h5
npm install
```
## 开发
```bash
npm run dev
```
访问 http://localhost:5174
## 构建
```bash
npm run build
```
构建产物在 `dist` 目录。
## 功能模块
### ✅ 已完成
1. **登录页面**
- 用户名/密码登录
- 使用背景图片 `sx1.jpg`
- Logo 显示
2. **仪表盘**
- 今日入库/出库统计
- 功能菜单导航
- 用户信息显示
3. **计划管理**
- 计划列表(卡片式展示)
- 新增/编辑计划
- 查看关联数据
- 生成出入库单
- 删除计划
- 搜索功能
4. **出入库管理**
- 出入库记录列表(卡片式展示)
- 新增/编辑记录
- 查看关联数据
- 生成地磅单
- 删除记录
- 搜索功能
5. **地磅单管理**
- 地磅单列表(卡片式展示)
- 新增/编辑地磅单
- 毛重/皮重/净重计算
- 自动填充时间
- 作废功能
- 删除功能
- 搜索功能
6. **基础资料**
- 往来单位管理(标签页)
- 货物管理(标签页)
- 新增/编辑/删除功能
- 搜索功能
7. **用户管理**
- 用户列表(卡片式展示)
- 新增/编辑用户
- 删除用户
- 角色管理(管理员/操作员)
## 移动端适配特点
### 布局方式
- 使用卡片式布局替代表格
- 列表使用 `van-list` 组件支持下拉加载
- 表单使用底部弹窗(`van-popup`)展示
### 组件使用
- **列表**: `van-list` + 卡片式布局
- **表单**: `van-form` + `van-field`
- **选择器**: `van-picker`(公司、货物选择)
- **日期选择**: 原生 `input type="date"`(移动端原生体验更好)
- **弹窗**: `van-popup`(底部弹出)
- **按钮**: `van-button`
- **标签**: `van-tag`
- **搜索**: `van-search`
- **反馈**: `Toast`、`Dialog`、`Notify`
### 交互优化
- 点击卡片查看详情
- 底部弹窗编辑表单
- 下拉刷新和上拉加载
- 触摸友好的按钮尺寸
## 与 Web 端的差异
| 功能 | Web 端 | 移动端 |
|------|--------|--------|
| UI 框架 | Element Plus | Vant 4 |
| 列表展示 | 表格(el-table) | 卡片列表 |
| 表单弹窗 | 居中对话框 | 底部弹窗 |
| 日期选择 | el-date-picker | 原生 input[type="date"] |
| 消息提示 | ElMessage | Toast |
| 确认对话框 | ElMessageBox | Dialog |
## 开发说明
### 组件自动引入
所有 Vant 组件已配置自动引入,可直接使用:
```vue
<template>
<!-- 直接使用,无需 import -->
<van-button type="primary">按钮</van-button>
<van-field v-model="value" label="输入框" />
<van-popup v-model:show="show">弹窗</van-popup>
</template>
<script setup lang="ts">
// 不需要 import 组件
const show = ref(false)
const value = ref('')
</script>
```
### API 调用
API 文件已从 Web 端复制,可直接使用:
```typescript
import { UserService } from '@/api/usersApi'
import { weighingApi } from '@/api/weighingApi'
import { planApi } from '@/api/plansApi'
```
### 状态管理
Store 文件已从 Web 端复制,可直接使用:
```typescript
import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()
const userInfo = userStore.getUserInfo
```
### 路由跳转
```typescript
import { useRouter } from 'vue-router'
const router = useRouter()
router.push('/plans')
```
## 注意事项
1. **功能一致性**: 所有功能逻辑与 Web 端保持一致
2. **数据格式**: API 接口、数据格式与 Web 端完全相同
3. **权限控制**: 用户角色判断与 Web 端一致
4. **移动端优化**: 仅展示形式适配移动端,功能逻辑不变
## 环境变量
创建 `.env` 文件(参考 `.env.example`):
```env
VITE_API_BASE_URL=http://localhost:8000
```
## 浏览器支持
- iOS Safari 12+
- Android Chrome 80+
- 微信内置浏览器
- 其他现代移动浏览器
## 后续优化建议
1. 添加下拉刷新功能
2. 优化长列表性能(虚拟滚动)
3. 添加离线缓存
4. 优化图片加载
5. 添加骨架屏加载效果
+143
View File
@@ -0,0 +1,143 @@
# 移动端重构完成总结
## ✅ 已完成的工作
### 1. 弹窗改为跳转新页面
所有模块的新增、编辑、查看功能都已从弹窗改为跳转到独立页面:
#### 计划管理 (`/plans`)
- ✅ 创建详情页面:`/plans/detail.vue`
- ✅ 列表页面改为跳转
- ✅ 路由配置完成
#### 出入库管理 (`/inventory`)
- ✅ 创建详情页面:`/inventory/detail.vue`
- ✅ 列表页面改为跳转
- ✅ 路由配置完成
- ✅ 支持从计划管理跳转并自动填充数据
#### 地磅单管理 (`/weighing`)
- ✅ 创建详情页面:`/weighing/detail.vue`
- ✅ 列表页面改为跳转
- ✅ 路由配置完成
- ✅ 支持从出入库管理跳转并自动填充数据
- ✅ 毛重/皮重/净重自动计算
- ✅ 时间自动填充
#### 基础资料 (`/master-data`)
- ✅ 创建公司详情页面:`/master-data/company-detail.vue`
- ✅ 创建货物详情页面:`/master-data/cargo-detail.vue`
- ✅ 列表页面改为跳转
- ✅ 路由配置完成
#### 用户管理 (`/user`)
- ✅ 创建详情页面:`/user/detail.vue`
- ✅ 列表页面改为跳转
- ✅ 路由配置完成
### 2. 搜索功能与Web端一致
所有搜索框的占位符和功能都已与Web端保持一致:
- ✅ **计划管理**:`搜索往来单位 / 计划号`
- ✅ **出入库管理**:`搜索往来单位 / 产品`
- ✅ **地磅单管理**:`搜索车牌号 / 货物名称`
- ✅ **基础资料-公司**:`搜索公司名称 / 备注`
- ✅ **基础资料-货物**:`搜索货物名称 / 备注`
### 3. 按钮和Tag配色与Web端一致
- ✅ 创建了 `vant-theme.scss` 主题配置文件
- ✅ 配置了与Web端一致的颜色:
- primary: `#52B788` (绿色)
- success: `#13deb9` (青绿色)
- warning: `#ffae1f` (橙色)
- danger: `#ff4d4f` (红色)
- info: `#52B788` (绿色)
- ✅ 统一了Tag类型映射(与Web端一致)
### 4. API完全复用
- ✅ 所有API文件已从Web端复制
- ✅ 使用相同的API接口和数据结构
- ✅ 功能逻辑与Web端完全一致
### 5. 表单字段与Web端一致
所有详情页面的表单字段都与Web端保持一致:
- ✅ **计划管理**:计划号、业务类型、往来单位、产品、计划开始时间、计划结束时间、计划量、备注
- ✅ **出入库管理**:日期、往来单位、业务类型、产品、数量、车牌号、计划号、备注
- ✅ **地磅单管理**:车牌号、货物名称、业务类型、供货单位、收货单位、毛重(kg)、毛重时间、皮重(kg)、皮重时间、扣杂(kg)、净重(kg)、计划号、备注
- ✅ **基础资料-公司**:公司名称、备注
- ✅ **基础资料-货物**:货物名称、备注
- ✅ **用户管理**:用户名、密码、角色
## 路由配置
所有新增的路由已添加到 `router/index.ts`:
```typescript
// 计划管理
/plans/detail - 新增
/plans/detail/:id - 编辑/查看
// 出入库管理
/inventory/detail - 新增
/inventory/detail/:id - 编辑/查看
// 地磅单管理
/weighing/detail - 新增
/weighing/detail/:id - 编辑/查看
// 基础资料
/master-data/company/detail - 新增公司
/master-data/company/detail/:id - 编辑/查看公司
/master-data/cargo/detail - 新增货物
/master-data/cargo/detail/:id - 编辑/查看货物
// 用户管理
/user/detail - 新增
/user/detail/:id - 编辑/查看
```
## 页面模式
所有详情页面支持三种模式(通过路由参数控制):
- **新增模式**:`/module/detail` - 创建新记录
- **编辑模式**:`/module/detail/:id?mode=edit` - 编辑现有记录
- **查看模式**:`/module/detail/:id?mode=view` - 只读查看
## 通用样式
创建了 `detail-page.scss` 通用样式文件,所有详情页面共享相同的样式:
- 页面头部(返回按钮 + 标题)
- 表单容器
- 表单项样式
- 按钮样式
## 功能完整性
所有功能与Web端保持一致:
1. ✅ 列表展示(卡片式,支持滚动分页)
2. ✅ 搜索功能(与Web端一致)
3. ✅ 新增功能(跳转到详情页面)
4. ✅ 编辑功能(跳转到详情页面)
5. ✅ 查看功能(跳转到详情页面,只读模式)
6. ✅ 删除功能(确认对话框)
7. ✅ 数据关联(计划-出入库-地磅单的关联关系)
8. ✅ 自动填充(从关联页面跳转时自动填充数据)
## 下一步
项目重构已完成,可以:
1. 运行 `npm install` 安装依赖
2. 运行 `npm run dev` 启动开发服务器
3. 测试所有功能,确保与Web端一致
4. 根据实际使用情况优化UI/UX
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>地磅系统 - 移动端</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2893
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+56
View File
@@ -0,0 +1,56 @@
<template>
<router-view />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { UserService } from '@/api/usersApi'
import { ApiStatus } from '@/utils/http/status'
const userStore = useUserStore()
onMounted(() => {
// 初始化用户状态
if (userStore.isLogin && userStore.accessToken) {
getUserInfo()
}
})
// 获取用户信息
const getUserInfo = async () => {
try {
const userRes = await UserService.getUserInfo()
if (userRes.code === ApiStatus.success) {
userStore.setUserInfo(userRes.data)
} else if (userRes.code === 401) {
userStore.logOut()
}
} catch (error) {
// 静默处理错误
if (userStore.isLogin) {
userStore.logOut()
}
}
}
</script>
<style lang="scss">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f7fa;
}
#app {
min-height: 100vh;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
import request from '@/utils/http'
import { PaginationResult, BaseResult } from '@/types/axios'
import { ArticleType, ArticleCategoryType } from './model/articleModel'
// 文章
export class ArticleService {
// 获取文章列表
static getArticleList(params: any) {
const { page, size, searchVal, year } = params
return request.get<PaginationResult<ArticleType[]>>({
url: `/api/articles/${page}/${size}?title=${searchVal}&year=${year}`
})
}
// 获取文章类型
static getArticleTypes(params: object) {
return request.get<BaseResult<ArticleCategoryType[]>>({
url: '/api/articles/types',
params
})
}
// 获取文章详情
static getArticleDetail(id: number) {
return request.get<BaseResult<ArticleType>>({
url: `/api/articles/${id}`
})
}
// 新增文章
static addArticle(params: any) {
return request.post<BaseResult>({
url: '/api/articles/',
data: params
})
}
// 编辑文章
static editArticle(id: number, params: any) {
return request.put<BaseResult>({
url: `/api/articles/${id}`,
data: params
})
}
}
+45
View File
@@ -0,0 +1,45 @@
import api from '@/utils/http'
// ==================== 仪表盘 API ====================
export interface DashboardCargoStat {
label: string
planned: number
actual: number
}
export interface DashboardPartnerCargoStat {
label: string
planned: number
actual: number
}
export const dashboardApi = {
// 获取今日入库量按货物统计
getInboundByCargo: async (): Promise<DashboardCargoStat[]> => {
return api.get<DashboardCargoStat[]>({
url: '/dashboard/inbound/by-cargo'
})
},
// 获取今日入库量按往来单位+货物统计
getInboundByPartnerCargo: async (): Promise<DashboardPartnerCargoStat[]> => {
return api.get<DashboardPartnerCargoStat[]>({
url: '/dashboard/inbound/by-partner-cargo'
})
},
// 获取今日出库量按货物统计
getOutboundByCargo: async (): Promise<DashboardCargoStat[]> => {
return api.get<DashboardCargoStat[]>({
url: '/dashboard/outbound/by-cargo'
})
},
// 获取今日出库量按往来单位+货物统计
getOutboundByPartnerCargo: async (): Promise<DashboardPartnerCargoStat[]> => {
return api.get<DashboardPartnerCargoStat[]>({
url: '/dashboard/outbound/by-partner-cargo'
})
}
}
+112
View File
@@ -0,0 +1,112 @@
import api from '@/utils/http'
import type { Plan } from './plansApi'
import type { WeighingRecord } from './weighingApi'
// ==================== 出入库管理 API ====================
export interface InventoryRecord {
id: number
date: string
partnerCompany: string
businessType: '入库' | '出库'
product: string
quantity: number
planNo?: string
batchNo?: string
vehicleNo: string
status?: string
remarks?: string
purchasePlanId?: number
isDeleted: number
createdAt: string
grossWeight?: number // 毛重(kg)- 汇总自关联地磅单
tareWeight?: number // 皮重(kg)- 汇总自关联地磅单
deduction?: number // 扣杂(kg)- 汇总自关联地磅单
netWeight?: number // 净重(kg)- 汇总自关联地磅单
grossWeightTime?: string // 毛重时间 - 来自关联地磅单
tareWeightTime?: string // 皮重时间 - 来自关联地磅单
hasWeighingSlip?: boolean // 是否已有关联的地磅单
}
export interface InventoryCreate {
date: string
partnerCompany: string
businessType: '入库' | '出库'
product: string
quantity: number
planNo?: string
batchNo?: string
vehicleNo: string
status?: string
remarks?: string
purchasePlanId?: number
}
export interface InventoryUpdate {
date?: string
partnerCompany?: string
businessType?: '入库' | '出库'
product?: string
quantity?: number
planNo?: string
batchNo?: string
vehicleNo?: string
status?: string
remarks?: string | null
purchasePlanId?: number
}
export interface InventoryRelatedData {
plan: Plan | null
weighing: WeighingRecord | null
}
export const inventoryApi = {
list: async (params?: {
q?: string
dateFrom?: string
dateTo?: string
status?: string
businessType?: '入库' | '出库'
}): Promise<InventoryRecord[]> => {
return api.get<InventoryRecord[]>({
url: '/inventory',
params,
timeout: 60000 // 列表可能数据较多,60 秒超时避免被取消
})
},
get: async (id: number): Promise<InventoryRecord> => {
return api.get<InventoryRecord>({
url: `/inventory/${id}`
})
},
getRelated: async (id: number): Promise<InventoryRelatedData> => {
return api.get<InventoryRelatedData>({
url: `/inventory/${id}/related`
})
},
create: async (payload: InventoryCreate): Promise<InventoryRecord> => {
return api.post<InventoryRecord>({
url: '/inventory',
data: payload
})
},
update: async (id: number, payload: InventoryUpdate): Promise<InventoryRecord> => {
return api.patch<InventoryRecord>({
url: `/inventory/${id}`,
data: payload
})
},
delete: async (id: number): Promise<void> => {
return api.del({
url: `/inventory/${id}`
})
},
createLinkForPlan: async (purchasePlanId: number, remarks?: string): Promise<InventoryRecord> => {
return api.post<InventoryRecord>({
url: '/inventory/create-link-for-plan',
data: { purchasePlanId, remarks }
})
}
}
+149
View File
@@ -0,0 +1,149 @@
import api from '@/utils/http'
// ==================== 公司(往来单位)API ====================
export interface Company {
id: number
name: string
remarks?: string | null
}
export interface CompanyCreate {
name: string
remarks?: string | null
}
export interface CompanyUpdate {
name?: string
remarks?: string | null
}
export const companyApi = {
list: async (q?: string): Promise<Company[]> => {
return api.get<Company[]>({
url: '/master-data/companies',
params: q ? { q } : undefined
})
},
get: async (id: number): Promise<Company> => {
return api.get<Company>({
url: `/master-data/companies/${id}`
})
},
create: async (payload: CompanyCreate): Promise<Company> => {
return api.post<Company>({
url: '/master-data/companies',
data: payload
})
},
update: async (id: number, payload: CompanyUpdate): Promise<Company> => {
return api.put<Company>({
url: `/master-data/companies/${id}`,
data: payload
})
},
delete: async (id: number): Promise<void> => {
return api.del({
url: `/master-data/companies/${id}`
})
}
}
// ==================== 货物(产品)API ====================
export interface Cargo {
id: number
name: string
remarks?: string | null
}
export interface CargoCreate {
name: string
remarks?: string | null
}
export interface CargoUpdate {
name?: string
remarks?: string | null
}
export const cargoApi = {
list: async (q?: string): Promise<Cargo[]> => {
return api.get<Cargo[]>({
url: '/master-data/cargos',
params: q ? { q } : undefined
})
},
get: async (id: number): Promise<Cargo> => {
return api.get<Cargo>({
url: `/master-data/cargos/${id}`
})
},
create: async (payload: CargoCreate): Promise<Cargo> => {
return api.post<Cargo>({
url: '/master-data/cargos',
data: payload
})
},
update: async (id: number, payload: CargoUpdate): Promise<Cargo> => {
return api.put<Cargo>({
url: `/master-data/cargos/${id}`,
data: payload
})
},
delete: async (id: number): Promise<void> => {
return api.del({
url: `/master-data/cargos/${id}`
})
}
}
// ==================== 车牌号 API ====================
export interface Vehicle {
id: number
vehicleNo: string
remarks?: string | null
}
export interface VehicleCreate {
vehicleNo: string
remarks?: string | null
}
export interface VehicleUpdate {
vehicleNo?: string
remarks?: string | null
}
export const vehicleApi = {
list: async (q?: string): Promise<Vehicle[]> => {
return api.get<Vehicle[]>({
url: '/master-data/vehicles',
params: q ? { q } : undefined
})
},
get: async (id: number): Promise<Vehicle> => {
return api.get<Vehicle>({
url: `/master-data/vehicles/${id}`
})
},
create: async (payload: VehicleCreate): Promise<Vehicle> => {
return api.post<Vehicle>({
url: '/master-data/vehicles',
data: payload
})
},
update: async (id: number, payload: VehicleUpdate): Promise<Vehicle> => {
return api.put<Vehicle>({
url: `/master-data/vehicles/${id}`,
data: payload
})
},
delete: async (id: number): Promise<void> => {
return api.del({
url: `/master-data/vehicles/${id}`
})
}
}
+60
View File
@@ -0,0 +1,60 @@
import { MenuListType } from '@/types/menu'
import { processRoute } from '@/utils/menu'
import { showLoadingToast, closeToast } from 'vant'
// 简单的路由数据(移动端不需要复杂的路由配置)
const simpleRoutes: any[] = [
{
path: '/dashboard',
name: 'Dashboard',
meta: { title: '工作台', icon: '', isHide: false }
},
{
path: '/plans',
name: 'Plans',
meta: { title: '计划管理', icon: '', isHide: false }
},
{
path: '/inventory',
name: 'Inventory',
meta: { title: '出入库管理', icon: '', isHide: false }
},
{
path: '/weighing',
name: 'Weighing',
meta: { title: '地磅单管理', icon: '', isHide: false }
},
{
path: '/master-data',
name: 'MasterData',
meta: { title: '基础资料', icon: '', isHide: false }
}
]
// 菜单接口
export const menuService = {
// 获取菜单列表,模拟网络请求
getMenuList(
delay: number = 300
): Promise<{ menuList: MenuListType[]; closeLoading: () => void }> {
// 获取到的菜单数据
const menuList = simpleRoutes
// 处理后的菜单数据
const processedMenuList: MenuListType[] = menuList.map((route: any) => processRoute(route))
showLoadingToast({
message: '加载中...',
forbidClick: true,
duration: 0
})
return new Promise((resolve) => {
setTimeout(() => {
resolve({
menuList: processedMenuList,
closeLoading: () => closeToast()
})
}, delay)
})
}
}
+18
View File
@@ -0,0 +1,18 @@
export interface ArticleType {
id?: number
blog_class: string
title: string
count?: number
html_content: string
create_time: string // 或者使用 Date 类型
home_img: string
brief: string
type_name?: string
}
export interface ArticleCategoryType {
id: number
name: string
icon: string
count: number
}
+121
View File
@@ -0,0 +1,121 @@
import api from '@/utils/http'
import type { InventoryRecord } from './inventoryApi'
import type { WeighingRecord } from './weighingApi'
// ==================== 计划管理 API ====================
export interface Plan {
id: number
planNo?: string
planDate: string
startDate?: string
endDate?: string
businessType: '入库' | '出库'
product: string
specification?: string
plannedQuantity: number
vehicleNo?: string
customerName?: string
remarks?: string
executionStatus?: string
executedAt?: string
lastWeighingSlipId?: number
executedQuantity?: number
isDeleted: number
createdAt: string
/** 生成链接交三方填写时对应的出入库单 id */
fillLinkInventoryId?: number
}
export interface PlanCreate {
planNo?: string
planDate: string
startDate?: string
endDate?: string
businessType: '入库' | '出库'
product: string
specification?: string
plannedQuantity: number
vehicleNo?: string
customerName?: string
remarks?: string
executionStatus?: string
}
export interface PlanUpdate {
planNo?: string
planDate?: string
startDate?: string
endDate?: string
businessType?: '入库' | '出库'
product?: string
specification?: string
plannedQuantity?: number
vehicleNo?: string
customerName?: string
remarks?: string
executionStatus?: string
executedAt?: string
lastWeighingSlipId?: number
executedQuantity?: number
}
export interface PlanRelatedData {
inventories: InventoryRecord[]
weighings: WeighingRecord[]
}
export interface ThirdPartyPlanPageData {
plan: Plan
today_inventories: InventoryRecord[]
/** 本月历史出入库单(不含今天),按日期倒序 */
month_inventories: InventoryRecord[]
}
export const planApi = {
list: async (params?: {
q?: string
businessType?: '入库' | '出库'
executionStatus?: string
createdFrom?: string
createdTo?: string
}): Promise<Plan[]> => {
return api.get<Plan[]>({
url: '/plans',
params
})
},
get: async (id: number): Promise<Plan> => {
return api.get<Plan>({
url: `/plans/${id}`
})
},
getRelated: async (id: number): Promise<PlanRelatedData> => {
return api.get<PlanRelatedData>({
url: `/plans/${id}/related`
})
},
getThirdPartyPage: async (id: number): Promise<ThirdPartyPlanPageData> => {
return api.get<ThirdPartyPlanPageData>({
url: `/plans/${id}/third-party-page`
})
},
create: async (payload: PlanCreate): Promise<Plan> => {
return api.post<Plan>({
url: '/plans',
data: payload
})
},
update: async (id: number, payload: PlanUpdate): Promise<Plan> => {
return api.patch<Plan>({
url: `/plans/${id}`,
data: payload
})
},
delete: async (id: number): Promise<void> => {
return api.del({
url: `/plans/${id}`
})
}
}
+126
View File
@@ -0,0 +1,126 @@
import api from '@/utils/http'
import type { BaseResult } from '@/types/axios'
import { ApiStatus } from '@/utils/http/status'
// ==================== 用户管理 API ====================
export interface User {
id: number
username: string
role: 'admin' | 'operator'
createdAt?: string
}
export interface UserCreate {
username: string
password: string
role: 'admin' | 'operator'
remark?: string
}
export interface UserUpdate {
username?: string
password?: string
role?: 'admin' | 'operator'
remark?: string
}
// 登录响应
export interface LoginResponse {
accessToken: string
refreshToken?: string
}
// 用户信息(用于登录后获取)
export interface UserInfo {
id: number
username: string
name?: string
avatar?: string
email?: string
role?: 'admin' | 'operator'
}
// 用户管理 API(CRUD)
export const userApi = {
list: async (): Promise<User[]> => {
return api.get<User[]>({
url: '/users'
})
},
get: async (id: number): Promise<User> => {
return api.get<User>({
url: `/users/${id}`
})
},
create: async (payload: UserCreate): Promise<User> => {
return api.post<User>({
url: '/users',
data: payload
})
},
update: async (id: number, payload: UserUpdate): Promise<User> => {
return api.put<User>({
url: `/users/${id}`,
data: payload
})
},
delete: async (id: number): Promise<void> => {
return api.del({
url: `/users/${id}`
})
}
}
// 用户服务(登录、获取用户信息等)
export const UserService = {
// 登录
login: async (username: string, password: string): Promise<BaseResult<LoginResponse & { user?: UserInfo }>> => {
try {
const response = await api.post<LoginResponse & { user?: UserInfo }>({
url: '/auth/login',
data: {
username,
password
}
})
// 后端直接返回 LoginResponse,需要包装成 BaseResult 格式
return {
code: ApiStatus.success,
message: '登录成功',
data: response
}
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || '登录失败'
return {
code: error.response?.status || 500,
message: errorMessage,
data: null as any
}
}
},
// 获取用户信息
getUserInfo: async (): Promise<BaseResult<UserInfo>> => {
try {
const response = await api.get<UserInfo>({
url: '/auth/userinfo'
})
// 后端直接返回 UserInfo,需要包装成 BaseResult 格式
return {
code: ApiStatus.success,
message: '获取成功',
data: response
}
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || '获取用户信息失败'
return {
code: error.response?.status || 500,
message: errorMessage,
data: null as any
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
import api from '@/utils/http'
import type { Plan } from './plansApi'
import type { InventoryRecord } from './inventoryApi'
// ==================== 地磅单管理 API ====================
export interface WeighingRecord {
id: number
vehicleNo: string
planNo?: string
cargoName: string
businessType: '入库' | '出库'
batchNo?: string
supplier: string
consignee: string
grossWeight: number
grossWeightTime: string
tareWeight: number
tareWeightTime: string
deduction?: number
netWeight: number
plannedQuantity?: number
executedQuantity?: number
purchasePlanId?: number
inboundOutboundId?: number
weighingPoint?: string
remarks?: string
currentQuantity?: number
isVoided: number
createdAt: string
inventoryDate?: string // 出入库单日期
}
export interface WeighingCreate {
vehicleNo: string
planNo?: string
cargoName: string
businessType: '入库' | '出库'
batchNo?: string
supplier: string
consignee: string
grossWeight: number
grossWeightTime: string
tareWeight: number
tareWeightTime: string
deduction?: number
netWeight: number
plannedQuantity?: number
executedQuantity?: number
purchasePlanId?: number
inboundOutboundId?: number
weighingPoint?: string
remarks?: string
currentQuantity?: number
}
export interface WeighingUpdate {
vehicleNo?: string
planNo?: string
cargoName?: string
businessType?: '入库' | '出库'
batchNo?: string
supplier?: string
consignee?: string
grossWeight?: number
grossWeightTime?: string
tareWeight?: number
tareWeightTime?: string
deduction?: number
netWeight?: number
plannedQuantity?: number
executedQuantity?: number
purchasePlanId?: number
inboundOutboundId?: number
weighingPoint?: string
remarks?: string
currentQuantity?: number
isVoided?: number
}
export interface WeighingRelatedData {
plan: Plan | null
inventory: InventoryRecord | null
}
export const weighingApi = {
list: async (params?: {
dateFrom?: string
dateTo?: string
vehicleNo?: string
cargoName?: string
businessType?: '入库' | '出库'
}): Promise<WeighingRecord[]> => {
return api.get<WeighingRecord[]>({
url: '/weighing',
params
})
},
get: async (id: number): Promise<WeighingRecord> => {
return api.get<WeighingRecord>({
url: `/weighing/${id}`
})
},
getRelated: async (id: number): Promise<WeighingRelatedData> => {
return api.get<WeighingRelatedData>({
url: `/weighing/${id}/related`
})
},
create: async (payload: WeighingCreate): Promise<WeighingRecord> => {
return api.post<WeighingRecord>({
url: '/weighing',
data: payload
})
},
update: async (id: number, payload: WeighingUpdate): Promise<WeighingRecord> => {
return api.patch<WeighingRecord>({
url: `/weighing/${id}`,
data: payload
})
},
void: async (id: number): Promise<void> => {
return api.post({
url: `/weighing/${id}/void`
})
},
delete: async (id: number): Promise<void> => {
return api.del({
url: `/weighing/${id}`
})
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,104 @@
// 详情页面通用样式
.page-content {
min-height: 100vh;
background: #f5f7fa;
}
.page-header {
display: flex;
align-items: center;
padding: 16px;
background-color: #fff;
border-bottom: 1px solid #eee;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 16px;
&__back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 12px;
}
.back-arrow {
width: 16px;
height: 16px;
border-left: 2px solid #333;
border-bottom: 2px solid #333;
transform: rotate(45deg);
}
&__title {
font-size: 18px;
font-weight: bold;
color: #333;
}
}
.form-container {
padding: 16px;
}
.form-section {
background-color: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
font-size: 14px;
color: #333;
margin-bottom: 8px;
display: block;
font-weight: 500;
.required {
color: #ee0a24;
margin-left: 4px;
}
}
.form-input {
display: flex;
align-items: center;
background-color: #f7f7f7;
border-radius: 8px;
padding: 12px 16px;
font-size: 14px;
color: #333;
min-height: 44px;
.placeholder {
color: #999;
}
.arrow-icon {
margin-left: auto;
color: #999;
}
}
.radio-wrapper {
padding: 12px 0;
}
.form-actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
padding: 0 16px;
}
+26
View File
@@ -0,0 +1,26 @@
// 全局样式
@import './variables.scss';
// 重置样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f7fa;
}
// Vant 样式覆盖
.van-button {
border-radius: 8px;
}
.van-cell {
border-radius: 8px;
}
+438
View File
@@ -0,0 +1,438 @@
// Vant 主题配置 - 与 Web 端保持一致
// Web 端颜色:
// primary: #52B788 (绿色)
// success: #13deb9 (青绿色)
// warning: #ffae1f (橙色)
// danger: #ff4d4f (红色)
// info: #52B788 (绿色)
:root {
// Vant 4 主题色 CSS 变量
--van-primary-color: #52B788 !important;
--van-success-color: #13deb9 !important;
--van-warning-color: #ffae1f !important;
--van-danger-color: #ff4d4f !important;
--van-info-color: #52B788 !important;
// 确保所有使用 primary-color 的地方都使用我们的绿色
--van-button-primary-background-color: #52B788 !important;
--van-button-primary-border-color: #52B788 !important;
--van-tag-primary-color: #52B788 !important;
--van-tag-primary-background-color: rgba(82, 183, 136, 0.1) !important;
--van-tag-primary-border-color: #52B788 !important;
}
// 覆盖 Vant Button 样式 - 使用 !important 确保优先级
.van-button--primary {
background-color: #52B788 !important;
border-color: #52B788 !important;
color: #fff !important;
&:hover {
background-color: #45a075 !important;
border-color: #45a075 !important;
}
&:active {
background-color: #45a075 !important;
border-color: #45a075 !important;
}
&.van-button--loading {
background-color: #52B788 !important;
border-color: #52B788 !important;
}
&.van-button--disabled {
background-color: #a5d6a7 !important;
border-color: #a5d6a7 !important;
opacity: 0.6;
}
}
.van-button--success {
background-color: #13deb9;
border-color: #13deb9;
&:active {
background-color: #10c5a3;
border-color: #10c5a3;
}
}
.van-button--warning {
background-color: #ffae1f;
border-color: #ffae1f;
&:active {
background-color: #e69d1c;
border-color: #e69d1c;
}
}
.van-button--danger {
background-color: #ff4d4f;
border-color: #ff4d4f;
&:active {
background-color: #e63e40;
border-color: #e63e40;
}
}
// 覆盖 Vant Tag 样式
.van-tag--primary {
background-color: rgba(82, 183, 136, 0.1);
color: #52B788;
border-color: #52B788;
}
.van-tag--success {
background-color: rgba(19, 222, 185, 0.1);
color: #13deb9;
border-color: #13deb9;
}
.van-tag--warning {
background-color: rgba(255, 174, 31, 0.1);
color: #ffae1f;
border-color: #ffae1f;
}
.van-tag--danger {
background-color: rgba(255, 77, 79, 0.1);
color: #ff4d4f;
border-color: #ff4d4f;
}
.van-tag--info {
background-color: rgba(82, 183, 136, 0.1);
color: #52B788;
border-color: #52B788;
}
// Plain 模式的 Button
.van-button--primary.van-button--plain {
background-color: transparent !important;
color: #52B788 !important;
border-color: #52B788 !important;
&:hover {
background-color: rgba(82, 183, 136, 0.1) !important;
}
&:active {
background-color: rgba(82, 183, 136, 0.2) !important;
}
}
// Plain 模式的 Tag
.van-tag--primary.van-tag--plain {
background-color: transparent !important;
color: #52B788 !important;
border-color: #52B788 !important;
}
.van-tag--success.van-tag--plain {
background-color: transparent;
color: #13deb9;
border-color: #13deb9;
}
.van-tag--warning.van-tag--plain {
background-color: transparent;
color: #ffae1f;
border-color: #ffae1f;
}
.van-tag--danger.van-tag--plain {
background-color: transparent;
color: #ff4d4f;
border-color: #ff4d4f;
}
.van-tag--info.van-tag--plain {
background-color: transparent;
color: #52B788;
border-color: #52B788;
}
// 覆盖 Vant Tabs 样式
.van-tabs {
.van-tabs__line {
background-color: #52B788 !important;
}
.van-tab--active {
color: #52B788 !important;
}
}
// 覆盖 Vant NavBar 样式
.van-nav-bar {
.van-nav-bar__title {
color: #52B788;
}
.van-nav-bar__arrow {
color: #52B788;
}
}
// 覆盖 Vant Tabbar 样式
.van-tabbar-item--active {
color: #52B788 !important;
}
// 覆盖 Vant Switch 样式
.van-switch--on {
background-color: #52B788 !important;
}
// 覆盖 Vant Checkbox 样式
.van-checkbox__icon--checked {
background-color: #52B788 !important;
border-color: #52B788 !important;
}
// 覆盖 Vant Radio 样式
.van-radio__icon--checked {
.van-icon {
color: #52B788 !important;
}
}
// 覆盖 Vant Stepper 样式
.van-stepper__plus {
background-color: #52B788 !important;
border-color: #52B788 !important;
}
.van-stepper__minus {
border-color: #52B788 !important;
color: #52B788 !important;
}
// 覆盖 Vant Loading 样式
.van-loading__spinner {
color: #52B788 !important;
}
// 覆盖 Vant Progress 样式
.van-progress__portion {
background-color: #52B788 !important;
}
// 覆盖 Vant Circle 样式
.van-circle__text {
color: #52B788 !important;
}
// 覆盖 Vant NoticeBar 样式
.van-notice-bar {
background-color: rgba(82, 183, 136, 0.1) !important;
color: #52B788 !important;
}
// 覆盖 Vant Cell 样式(链接颜色)
.van-cell--clickable:active {
background-color: rgba(82, 183, 136, 0.05) !important;
}
// 覆盖 Vant Field 样式(聚焦时的边框颜色)
.van-field__control:focus {
border-color: #52B788 !important;
}
// 覆盖 Vant Search 样式
.van-search__action {
color: #52B788 !important;
}
// 覆盖 Vant Picker 样式
.van-picker__confirm {
color: #52B788 !important;
}
// 覆盖 Vant Calendar 样式
.van-calendar__selected-day {
background-color: #52B788 !important;
color: #fff !important;
}
// 覆盖 Vant DatetimePicker 样式
.van-datetime-picker__confirm {
color: #52B788 !important;
}
// 覆盖 Vant Rate 样式
.van-rate__icon--full {
color: #52B788 !important;
}
// 覆盖 Vant Slider 样式
.van-slider__button {
background-color: #52B788 !important;
border-color: #52B788 !important;
}
.van-slider__bar {
background-color: #52B788 !important;
}
// 覆盖 Vant Uploader 样式
.van-uploader__upload {
border-color: #52B788 !important;
}
.van-uploader__preview-delete {
background-color: #52B788 !important;
}
// 覆盖 Vant ActionSheet 样式
.van-action-sheet__item--active {
color: #52B788 !important;
}
// 覆盖 Vant Popup 样式(关闭按钮等)
.van-popup__close-icon {
color: #52B788 !important;
}
// 覆盖 Vant Dialog 样式
.van-dialog__confirm {
color: #52B788 !important;
}
// 覆盖 Vant Toast 样式
.van-toast--success {
background-color: #52B788 !important;
}
// 覆盖 Vant Notify 样式
.van-notify--primary {
background-color: #52B788 !important;
}
// 覆盖 Vant ImagePreview 样式
.van-image-preview__index {
color: #52B788 !important;
}
// 覆盖 Vant Skeleton 样式
.van-skeleton__content {
background-color: rgba(82, 183, 136, 0.1) !important;
}
// 覆盖 Vant PullRefresh 样式
.van-pull-refresh__head {
color: #52B788 !important;
}
// 覆盖 Vant List 样式
.van-list__finished-text {
color: #52B788 !important;
}
// 覆盖 Vant Empty 样式
.van-empty__description {
color: #52B788 !important;
}
// 覆盖 Vant Sticky 样式
.van-sticky {
background-color: rgba(82, 183, 136, 0.05) !important;
}
// 覆盖 Vant IndexBar 样式
.van-index-bar__index--active {
color: #52B788 !important;
}
// 覆盖 Vant Sidebar 样式
.van-sidebar-item--select {
color: #52B788 !important;
border-color: #52B788 !important;
}
// 覆盖 Vant TreeSelect 样式
.van-tree-select__item--active {
color: #52B788 !important;
}
// 覆盖 Vant ContactCard 样式
.van-contact-card {
border-color: #52B788 !important;
}
// 覆盖 Vant Coupon 样式
.van-coupon__tag {
background-color: #52B788 !important;
}
// 覆盖 Vant GoodsAction 样式
.van-goods-action-button--first {
background-color: #52B788 !important;
}
// 覆盖 Vant SubmitBar 样式
.van-submit-bar__button {
background-color: #52B788 !important;
}
// 覆盖 Vant SwipeCell 样式
.van-swipe-cell__right {
background-color: #52B788 !important;
}
// 覆盖 Vant Card 样式(如果有主题色相关)
.van-card__tag {
background-color: #52B788 !important;
}
// 覆盖 Vant Divider 样式
.van-divider {
border-color: rgba(82, 183, 136, 0.2) !important;
}
// 覆盖 Vant Grid 样式
.van-grid-item__icon {
color: #52B788 !important;
}
// 覆盖 Vant Panel 样式
.van-panel__header {
color: #52B788 !important;
}
// 覆盖 Vant Collapse 样式
.van-collapse-item__title--expanded {
color: #52B788 !important;
}
// 覆盖 Vant Steps 样式
.van-step__circle {
background-color: #52B788 !important;
}
.van-step__line {
background-color: #52B788 !important;
}
// 覆盖 Vant CountDown 样式
.van-count-down {
color: #52B788 !important;
}
// 覆盖 Vant Badge 样式
.van-badge {
background-color: #52B788 !important;
}
// 覆盖 Vant Icon 样式(如果使用了主题色)
.van-icon {
&[class*="primary"] {
color: #52B788 !important;
}
}
@@ -0,0 +1,26 @@
// 主题色
$primary-color: #52B788;
$success-color: #13deb9;
$warning-color: #ffae1f;
$danger-color: #ff4d4f;
// 文字颜色
$text-primary: #111827;
$text-secondary: #6b7280;
$text-tertiary: #9ca3af;
// 背景色
$bg-primary: #ffffff;
$bg-secondary: #f5f7fa;
$bg-tertiary: #f8fafc;
// 边框色
$border-color: #e5e7eb;
// 间距
$spacing-xs: 8px;
$spacing-sm: 12px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
@@ -0,0 +1,192 @@
<template>
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">{{ title }}</span>
<div class="legend">
<span class="legend-item">
<span class="legend-color planned"></span>
计划量
</span>
<span class="legend-item">
<span class="legend-color actual"></span>
实际量
</span>
</div>
</div>
<div v-if="data.length === 0" class="chart-empty">
<van-empty description="今日无数据" />
</div>
<div v-else class="chart-container">
<div v-for="item in data" :key="item.label" class="chart-item">
<span class="chart-label">{{ item.label }}</span>
<div class="chart-bar-group">
<div class="chart-bar-wrapper">
<div
class="chart-bar planned"
:style="{
width: maxValue ? (item.planned / maxValue) * 100 + '%' : '0%',
minWidth: '4px'
}"
/>
</div>
<div class="chart-bar-wrapper">
<div
class="chart-bar actual"
:style="{
width: maxValue ? (item.actual / maxValue) * 100 + '%' : '0%',
minWidth: '4px'
}"
/>
</div>
</div>
<div class="chart-values">
<span class="value planned">{{ item.planned.toFixed(2) }}</span>
<span class="value actual">{{ item.actual.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface DualBarItem {
label: string
planned: number
actual: number
}
const props = defineProps<{
title: string
data: DualBarItem[]
}>()
const maxValue = computed(() => {
if (!props.data.length) return 0
const maxPlanned = Math.max(...props.data.map((item) => item.planned))
const maxActual = Math.max(...props.data.map((item) => item.actual))
return Math.max(maxPlanned, maxActual) || 0
})
</script>
<style lang="scss" scoped>
.chart-card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
}
.chart-header {
margin-bottom: 12px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #111827;
display: block;
margin-bottom: 8px;
}
.legend {
display: flex;
gap: 16px;
&-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
}
&-color {
width: 12px;
height: 12px;
border-radius: 2px;
&.planned {
background: #1677ff;
}
&.actual {
background: #52c41a;
}
}
}
.chart-empty {
padding: 20px 0;
}
.chart-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.chart-label {
min-width: 70px;
font-size: 13px;
color: #111827;
flex-shrink: 0;
}
.chart-bar-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.chart-bar-wrapper {
background: #f3f4f6;
border-radius: 4px;
overflow: hidden;
height: 14px;
}
.chart-bar {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
&.planned {
background: linear-gradient(90deg, #1677ff 0%, #4096ff 100%);
}
&.actual {
background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
}
}
.chart-values {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 80px;
text-align: right;
font-size: 12px;
flex-shrink: 0;
.value {
&.planned {
color: #1677ff;
}
&.actual {
color: #52c41a;
}
}
}
</style>
@@ -0,0 +1,112 @@
<template>
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">{{ title }}</span>
</div>
<div v-if="data.length === 0" class="chart-empty">
<van-empty description="今日无数据" />
</div>
<div v-else class="chart-container">
<div v-for="item in data" :key="item.label" class="chart-item">
<span class="chart-label">{{ item.label }}</span>
<div class="chart-bar-wrapper">
<div
class="chart-bar"
:style="{
width: maxValue ? (item.value / maxValue) * 100 + '%' : '0%',
minWidth: '4px'
}"
/>
</div>
<span class="chart-value">{{ item.value.toFixed(2) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface SimpleBarItem {
label: string
value: number
}
const props = defineProps<{
title: string
data: SimpleBarItem[]
}>()
const maxValue = computed(() =>
props.data.length ? Math.max(...props.data.map((item) => item.value)) : 0
)
</script>
<style lang="scss" scoped>
.chart-card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
}
.chart-header {
margin-bottom: 16px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.chart-empty {
padding: 20px 0;
}
.chart-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.chart-label {
min-width: 80px;
font-size: 14px;
color: #111827;
flex-shrink: 0;
}
.chart-bar-wrapper {
flex: 1;
background: #f3f4f6;
border-radius: 4px;
overflow: hidden;
height: 20px;
}
.chart-bar {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, #1677ff 0%, #4096ff 100%);
transition: width 0.3s ease;
}
.chart-value {
min-width: 60px;
text-align: right;
font-size: 14px;
font-weight: 500;
color: #111827;
flex-shrink: 0;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
import { useSettingStore } from '@/store/modules/setting'
import { festivalList } from '@/config/core/ceremony'
import mittBus from '@/utils/mittBus'
import { computed } from 'vue'
// 格式化日期为 YYYY-MM-DD
function formatDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 节日庆祝相关配置
export function useCeremony() {
const settingStore = useSettingStore()
// 节日礼花效果是否已加载
const holidayFireworksLoaded = computed(() => settingStore.holidayFireworksLoaded)
// 节日礼花是否显示
const isShowFireworks = computed(() => settingStore.isShowFireworks)
// 烟花间隔引用,用于清理
let fireworksInterval: number | null = null
let fireworksTimeout: number | null = null
// 判断当前日期是否是节日
const currentFestivalData = computed(() => {
const currentDate = formatDate(new Date())
return festivalList.find((item) => item.date === currentDate)
})
// 节日庆祝相关配置
const FESTIVAL_CONFIG = {
INITIAL_DELAY: 300, // 初始延迟时间,单位毫秒
FIREWORK_INTERVAL: 1000, // 烟花效果触发间隔,单位毫秒
TEXT_DELAY: 2000, // 文本显示延迟时间,单位毫秒
MAX_TRIGGERS: 6 // 最大触发次数
} as const
// 根据节日列表显示节日祝福
const openFestival = () => {
// 没有节日数据,不显示
if (!currentFestivalData.value) return
// 礼花效果结束,不显示
if (!isShowFireworks.value) return
let triggers = 0
fireworksTimeout = window.setTimeout(() => {
fireworksInterval = window.setInterval(() => {
mittBus.emit('triggerFireworks', currentFestivalData.value?.image)
triggers++
if (triggers >= FESTIVAL_CONFIG.MAX_TRIGGERS) {
if (fireworksInterval) {
clearInterval(fireworksInterval)
fireworksInterval = null
}
settingStore.setholidayFireworksLoaded(true)
// 主页显示节日文本
window.setTimeout(() => {
settingStore.setShowFestivalText(true)
setFestivalDate()
}, FESTIVAL_CONFIG.TEXT_DELAY)
}
}, FESTIVAL_CONFIG.FIREWORK_INTERVAL)
}, FESTIVAL_CONFIG.INITIAL_DELAY)
}
// 清理函数
const cleanup = () => {
if (fireworksInterval) {
clearInterval(fireworksInterval)
fireworksInterval = null
}
if (fireworksTimeout) {
clearTimeout(fireworksTimeout)
fireworksTimeout = null
}
settingStore.setShowFestivalText(false)
setFestivalDate()
}
// 设置节日日期
const setFestivalDate = () => {
settingStore.setFestivalDate(currentFestivalData.value?.date || '')
}
return {
openFestival,
cleanup,
holidayFireworksLoaded,
currentFestivalData,
isShowFireworks
}
}
+12
View File
@@ -0,0 +1,12 @@
// 通用组合函数(移动端简化版本)
export function useCommon() {
const refresh = () => {
// 刷新页面逻辑
window.location.reload()
}
return {
refresh
}
}
+12
View File
@@ -0,0 +1,12 @@
// 主题组合函数(移动端简化版本)
export function useTheme() {
const switchThemeStyles = (theme: string) => {
// 切换主题逻辑
document.documentElement.setAttribute('data-theme', theme)
}
return {
switchThemeStyles
}
}
+18
View File
@@ -0,0 +1,18 @@
// 节日礼花、文字滚动配置
// 节日列表配置
export const festivalList = [
{
date: '2025-01-01',
name: '元旦',
image: '',
scrollText: '新年快乐!祝您在2025年万事如意,事业腾飞,阖家幸福,好运连连!'
},
{
date: '2024-12-25',
name: '圣诞节',
image: '',
scrollText: 'Merry Christmas!祝您圣诞快乐,愿节日的欢乐与祝福如雪花般纷至沓来!'
}
]
+78
View File
@@ -0,0 +1,78 @@
import { MenuThemeEnum } from '@/enums/appEnum'
import { MenuThemeType } from '@/types/store'
// 系统配置
const config = {
// Element Plus 主题 (保持兼容性,虽然移动端可能不使用)
elementPlusTheme: {
primary: '#52B788'
},
// 菜单主题列表
themeList: [
{
theme: MenuThemeEnum.DESIGN,
background: '#FFFFFF',
systemNameColor: 'var(--art-text-gray-800)',
iconColor: '#6B6B6B',
textColor: '#29343D',
textActiveColor: '#52B788',
iconActiveColor: '#333333',
tabBarBackground: '#FAFBFC',
systemBackground: '#FAFBFC',
leftLineColor: '#EDEEF0',
rightLineColor: '#EDEEF0'
},
{
theme: MenuThemeEnum.DARK,
background: '#191A23',
systemNameColor: '#BABBBD',
iconColor: '#BABBBD',
textColor: '#BABBBD',
textActiveColor: '#FFFFFF',
iconActiveColor: '#FFFFFF',
tabBarBackground: '#FFFFFF',
systemBackground: '#F8F8F8',
leftLineColor: '#3F4257',
rightLineColor: '#EDEEF0'
},
{
theme: MenuThemeEnum.LIGHT,
background: '#ffffff',
systemNameColor: '#68758E',
iconColor: '#6B6B6B',
textColor: '#29343D',
textActiveColor: '#52B788',
iconActiveColor: '#333333',
tabBarBackground: '#FFFFFF',
systemBackground: '#F8F8F8',
leftLineColor: '#EDEEF0',
rightLineColor: '#EDEEF0'
}
] as MenuThemeType[],
// 暗黑菜单样式
darkMenuStyles: [
{
theme: MenuThemeEnum.DARK,
background: '#161618',
systemNameColor: '#DDDDDD',
iconColor: '#BABBBD',
textColor: 'rgba(255, 255, 255, 0.7)',
textActiveColor: '',
iconActiveColor: '#FFFFFF',
tabBarBackground: '#FFFFFF',
systemBackground: '#F8F8F8',
leftLineColor: '#3F4257',
rightLineColor: '#EDEEF0'
}
] as MenuThemeType[],
// 系统其他项默认配置
systemSetting: {
defaultMenuWidth: 252, // 菜单宽度
defaultCustomRadius: '0.75', // 自定义圆角
defaultTabStyle: 'tab-default' // 标签样式
}
}
// 冻结对象防止运行时修改
export default Object.freeze(config)
+36
View File
@@ -0,0 +1,36 @@
// 系统级别枚举
// 菜单类型
export enum MenuTypeEnum {
LEFT = 'left',
TOP = 'top',
TOP_LEFT = 'top-left',
DUAL_MENU = 'dual-menu'
}
// App theme enum
export enum SystemThemeEnum {
DARK = 'dark',
LIGHT = 'light',
AUTO = 'auto'
}
// Menu theme enum
export enum MenuThemeEnum {
DARK = 'dark',
LIGHT = 'light',
DESIGN = 'design'
}
// Language
export enum LanguageEnum {
ZH = 'zh',
EN = 'en'
}
// Container width
export enum ContainerWidthEnum {
FULL = '100%',
BOXED = '1200px'
}
+17
View File
@@ -0,0 +1,17 @@
/// <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_API_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 简单的国际化函数
* 目前返回原始字符串,后续可以扩展为完整的 i18n 实现
*/
export const $t = (key: string): string => {
// 简单的实现,直接返回 key
// 后续可以集成 vue-i18n 或其他 i18n 库
return key
}
+28
View File
@@ -0,0 +1,28 @@
import { createApp } from 'vue'
import App from './App.vue'
import { initStore } from './store'
import { initRouter } from './router'
import { initState } from './utils/storage'
import { Toast, Dialog, Notify } from 'vant'
import 'vant/lib/index.css'
import './assets/styles/vant-theme.scss'
import './assets/styles/index.scss'
const app = createApp(App)
// 初始化 Store
initStore(app)
// 初始化 Router
initRouter(app)
// 初始化本地存储状态(必须在 Store 和 Router 初始化之后)
initState()
// 注册 Vant 函数式组件
app.use(Toast)
app.use(Dialog)
app.use(Notify)
app.mount('#app')
+17
View File
@@ -0,0 +1,17 @@
// 升级日志(移动端简化版本)
import { ref } from 'vue'
export interface UpgradeLog {
title: string
content: string
requireReLogin: boolean
}
export const upgradeLogList = ref<UpgradeLog[]>([
{
title: '系统升级',
content: '<p>系统已升级到最新版本</p>',
requireReLogin: false
}
])
+23
View File
@@ -0,0 +1,23 @@
// ECharts 插件(移动端简化版本,如果不需要图表功能可以返回空对象)
// 注意:如果项目不需要 echarts,可以安装:npm install echarts
// 或者移除所有使用 echarts 的代码
let echartsInstance: any = null
// 动态导入 echarts(如果已安装)
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
echartsInstance = require('echarts')
} catch {
// echarts 未安装,返回一个空对象
echartsInstance = {
init: () => ({
setOption: () => {},
resize: () => {},
dispose: () => {}
})
}
}
export default echartsInstance
+192
View File
@@ -0,0 +1,192 @@
import type { App } from 'vue'
import { createRouter, createWebHashHistory, RouteRecordRaw, RouteLocationNormalized } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
import { formatMenuTitle } from '@/utils/menu'
/** 首页路径常量 */
export const HOME_PAGE = '/dashboard'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: HOME_PAGE
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', noAuth: true }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘' }
},
{
path: '/plans',
name: 'Plans',
component: () => import('@/views/plans/index.vue'),
meta: { title: '计划管理' }
},
{
path: '/plans/detail',
name: 'PlanDetail',
component: () => import('@/views/plans/detail.vue'),
meta: { title: '计划详情' }
},
{
path: '/plans/detail/:id',
name: 'PlanDetailEdit',
component: () => import('@/views/plans/detail.vue'),
meta: { title: '计划详情' }
},
{
path: '/inventory',
name: 'Inventory',
component: () => import('@/views/inventory/index.vue'),
meta: { title: '出入库管理' }
},
{
path: '/inventory/detail',
name: 'InventoryDetail',
component: () => import('@/views/inventory/detail.vue'),
meta: { title: '出入库详情' }
},
{
path: '/inventory/detail/:id',
name: 'InventoryDetailEdit',
component: () => import('@/views/inventory/detail.vue'),
meta: { title: '出入库详情' }
},
{
path: '/inventory/fill/:id',
name: 'InventoryFill',
component: () => import('@/views/inventory/fill.vue'),
meta: { title: '山峡出入库信息', noAuth: true }
},
{
path: '/inventory/plan/:id',
name: 'InventoryPlanThirdParty',
component: () => import('@/views/inventory/plan.vue'),
meta: { title: '计划出入库填写', noAuth: true }
},
{
path: '/weighing',
name: 'Weighing',
component: () => import('@/views/weighing/index.vue'),
meta: { title: '地磅单管理' }
},
{
path: '/weighing/detail',
name: 'WeighingDetail',
component: () => import('@/views/weighing/detail.vue'),
meta: { title: '地磅单详情' }
},
{
path: '/weighing/detail/:id',
name: 'WeighingDetailEdit',
component: () => import('@/views/weighing/detail.vue'),
meta: { title: '地磅单详情' }
},
{
path: '/master-data',
name: 'MasterData',
component: () => import('@/views/master-data/index.vue'),
meta: { title: '基础资料' }
},
{
path: '/master-data/company/detail',
name: 'CompanyDetail',
component: () => import('@/views/master-data/company-detail.vue'),
meta: { title: '公司详情' }
},
{
path: '/master-data/company/detail/:id',
name: 'CompanyDetailEdit',
component: () => import('@/views/master-data/company-detail.vue'),
meta: { title: '公司详情' }
},
{
path: '/master-data/cargo/detail',
name: 'CargoDetail',
component: () => import('@/views/master-data/cargo-detail.vue'),
meta: { title: '货物详情' }
},
{
path: '/master-data/cargo/detail/:id',
name: 'CargoDetailEdit',
component: () => import('@/views/master-data/cargo-detail.vue'),
meta: { title: '货物详情' }
},
{
path: '/master-data/vehicle/detail',
name: 'VehicleDetail',
component: () => import('@/views/master-data/vehicle-detail.vue'),
meta: { title: '车牌号详情' }
},
{
path: '/master-data/vehicle/detail/:id',
name: 'VehicleDetailEdit',
component: () => import('@/views/master-data/vehicle-detail.vue'),
meta: { title: '车牌号详情' }
}
]
export const router = createRouter({
history: createWebHashHistory(),
routes
})
/**
* 设置页面标题,根据路由元信息拼接标题
* @param to 当前路由对象
*/
export const setPageTitle = (to: RouteLocationNormalized): void => {
const { title } = to.meta
if (title) {
setTimeout(() => {
document.title = `${formatMenuTitle(String(title))} - 地磅系统`
}, 150)
}
}
// 路由守卫
router.beforeEach((to, _from, next) => {
const userStore = useUserStore()
// 设置页面标题
setPageTitle(to)
// 如果访问登录页且已登录,跳转到首页
if (to.path === '/login' && userStore.isLogin) {
next(HOME_PAGE)
return
}
// 检查登录状态
// 如果有 token 但 isLogin 为 false,尝试从 sessionStorage 恢复
if (!to.meta.noAuth) {
const hasToken = userStore.accessToken || sessionStorage.getItem('accessToken')
if (!userStore.isLogin && hasToken) {
// 如果有 token,恢复登录状态
userStore.setLoginStatus(true)
// 从 sessionStorage 恢复 token
if (!userStore.accessToken && hasToken) {
userStore.accessToken = hasToken as string
}
}
if (!userStore.isLogin && !hasToken) {
next('/login')
return
}
}
next()
})
export function initRouter(app: App<Element>) {
app.use(router)
}
+8
View File
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import { createPinia } from 'pinia'
export const store = createPinia()
export function initStore(app: App<Element>) {
app.use(store)
}
+28
View File
@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { MenuListType } from '@/types/menu'
interface MenuState {
menuList: MenuListType[]
menuWidth: string
}
export const useMenuStore = defineStore({
id: 'menuStore',
state: (): MenuState => ({
menuList: [],
menuWidth: ''
}),
getters: {
getMenuList(): MenuListType[] {
return this.menuList
}
},
actions: {
setMenuList(list: []) {
this.menuList = list
},
setMenuWidth(width: string) {
this.menuWidth = width
}
}
})
+267
View File
@@ -0,0 +1,267 @@
import { defineStore } from 'pinia'
import { MenuThemeType } from '@/types/store'
import AppConfig from '@/config'
import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from '@/enums/appEnum'
import { colourBlend, handleElementThemeColor } from '@/utils/colors'
import { getSysStorage } from '@/utils/storage'
import { useCeremony } from '@/composables/useCeremony'
const { defaultMenuWidth, defaultCustomRadius, defaultTabStyle } = AppConfig.systemSetting
export interface SettingState {
menuType: MenuTypeEnum // 菜单类型
menuOpenWidth: number // 菜单展开宽度
systemThemeType: SystemThemeEnum // 全局主题类型 light dark
systemThemeMode: SystemThemeEnum // 全局主题模式 light dark auto
menuThemeType: MenuThemeEnum // 菜单主题类型
systemThemeColor: string // 系统主题颜色
boxBorderMode: boolean // 盒子模式 border | shadow
uniqueOpened: boolean // 是否开启手风琴模式
showMenuButton: boolean // 是否显示菜单展开按钮
showRefreshButton: boolean // 是否显示页面刷新按钮
showCrumbs: boolean // 是否显示全局面包屑
autoClose: boolean // 设置后是否自动关闭窗口
showWorkTab: boolean // 是否显示多标签
showLanguage: boolean // 是否显示多语言选择
showNprogress: boolean // 是否显示顶部进度条
colorWeak: boolean // 是否显示顶部进度条
showSettingGuide: boolean // 是否显示设置引导
pageTransition: string // 页面切换动画
tabStyle: string // 标签页风格
menuOpen: boolean // 菜单是否展开
refresh: boolean
watermarkVisible: boolean // 水印是否显示
customRadius: string // 自定义圆角
holidayFireworksLoaded: boolean // 是否加载完礼花
showFestivalText: boolean // 是否显示节日文本
festivalDate: string // 节日日期
dualMenuShowText: boolean // 双列菜单是否显示文本
containerWidth: ContainerWidthEnum // 容器宽度
}
export const useSettingStore = defineStore({
id: 'settingStore',
state: (): SettingState => ({
menuType: MenuTypeEnum.TOP,
menuOpenWidth: defaultMenuWidth,
systemThemeType: SystemThemeEnum.LIGHT,
systemThemeMode: SystemThemeEnum.LIGHT,
menuThemeType: MenuThemeEnum.DESIGN,
boxBorderMode: true,
uniqueOpened: true,
systemThemeColor: AppConfig.elementPlusTheme.primary,
showMenuButton: true,
showRefreshButton: true,
showCrumbs: true,
autoClose: false,
showWorkTab: true,
showLanguage: true,
showNprogress: true,
colorWeak: false,
showSettingGuide: true,
pageTransition: 'slide-right',
tabStyle: defaultTabStyle,
menuOpen: true,
refresh: false,
watermarkVisible: false,
customRadius: defaultCustomRadius,
holidayFireworksLoaded: false,
showFestivalText: false,
festivalDate: '',
dualMenuShowText: false,
containerWidth: ContainerWidthEnum.FULL
}),
getters: {
getMenuTheme(): MenuThemeType {
const list = AppConfig.themeList.filter((item) => item.theme === this.menuThemeType)
if (this.isDark) {
return AppConfig.darkMenuStyles[0]
} else {
return list[0]
}
},
// 是否为暗黑模式
isDark(): boolean {
return this.systemThemeType === SystemThemeEnum.DARK
},
// 获取菜单展开宽度
getMenuOpenWidth(): string {
return this.menuOpenWidth + 'px' || defaultMenuWidth + 'px'
},
// 获取自定义圆角
getCustomRadius(): string {
return this.customRadius + 'rem' || defaultCustomRadius + 'rem'
},
// 节日礼花否显示
isShowFireworks(): boolean {
return this.festivalDate === useCeremony().currentFestivalData.value?.date ? false : true
}
},
actions: {
// 初始化state
initState() {
let sys = getSysStorage()
if (sys) {
sys = JSON.parse(sys)
const { setting } = sys.user
this.menuType = setting.menuType || MenuTypeEnum.TOP
this.menuOpenWidth = Number(setting.menuOpenWidth) || defaultMenuWidth
this.systemThemeType = setting.systemThemeType || SystemThemeEnum.LIGHT
this.systemThemeMode = setting.systemThemeMode || SystemThemeEnum.LIGHT
this.menuThemeType = setting.menuThemeType || MenuThemeEnum.DESIGN
this.containerWidth = setting.containerWidth || ContainerWidthEnum.FULL
this.systemThemeColor = setting.systemThemeColor || AppConfig.elementPlusTheme.primary
this.boxBorderMode = setting.boxBorderMode
this.uniqueOpened = setting.uniqueOpened
this.showMenuButton = setting.showMenuButton
this.showRefreshButton = setting.showRefreshButton
this.showCrumbs = setting.showCrumbs
this.autoClose = setting.autoClose
this.showWorkTab = setting.showWorkTab
this.showLanguage = setting.showLanguage
this.showNprogress = setting.showNprogress
this.colorWeak = setting.colorWeak
this.showSettingGuide = setting.showSettingGuide
this.pageTransition = setting.pageTransition
this.tabStyle = setting.tabStyle || defaultTabStyle
this.menuOpen = setting.menuOpen
this.watermarkVisible = setting.watermarkVisible
this.customRadius = setting.customRadius || defaultCustomRadius
this.holidayFireworksLoaded = setting.holidayFireworksLoaded
this.showFestivalText = setting.showFestivalText
this.festivalDate = setting.festivalDate
this.dualMenuShowText = setting.dualMenuShowText
this.setCustomRadius(this.customRadius)
setElementThemeColor(setting.systemThemeColor)
} else {
this.setCustomRadius(this.customRadius)
setElementThemeColor(AppConfig.elementPlusTheme.primary)
}
},
switchMenuLayouts(type: MenuTypeEnum) {
this.menuType = type
},
setMenuOpenWidth(width: number) {
this.menuOpenWidth = width
},
setGlopTheme(theme: SystemThemeEnum, themeMode: SystemThemeEnum) {
this.systemThemeType = theme
this.systemThemeMode = themeMode
},
switchMenuStyles(theme: MenuThemeEnum) {
this.menuThemeType = theme
},
setElementTheme(theme: string) {
this.systemThemeColor = theme
setElementThemeColor(theme)
},
// 设置盒子模式
setBorderMode() {
this.boxBorderMode = !this.boxBorderMode
},
// 设置容器宽度
setContainerWidth(width: ContainerWidthEnum) {
this.containerWidth = width
},
// 设置菜单是否为手风琴模式
setUniqueOpened() {
this.uniqueOpened = !this.uniqueOpened
},
// 显示侧边栏折叠按钮
setButton() {
this.showMenuButton = !this.showMenuButton
},
// 是否自动关闭个性化设置
setAutoClose() {
this.autoClose = !this.autoClose
},
// 是否显示页面刷新按钮
setShowRefreshButton() {
this.showRefreshButton = !this.showRefreshButton
},
// 是否显示面包屑导航
setCrumbs() {
this.showCrumbs = !this.showCrumbs
},
// 是否显示多标签
setWorkTab(show: boolean) {
this.showWorkTab = show
},
// 是否显示多语言选择
setLanguage() {
this.showLanguage = !this.showLanguage
},
// 是否显示顶部进度条
setNprogress() {
this.showNprogress = !this.showNprogress
},
// 色弱模式
setColorWeak() {
this.colorWeak = !this.colorWeak
},
// 隐藏设置引导
hideSettingGuide() {
this.showSettingGuide = false
},
// 显示设置引导
openSettingGuide() {
this.showSettingGuide = true
},
// 设置页面切换动画
setPageTransition(transition: string) {
this.pageTransition = transition
},
// 设置标签页风格
setTabStyle(style: string) {
this.tabStyle = style
},
// 设置菜单是否展开
setMenuOpen(open: boolean) {
this.menuOpen = open
},
// 刷新当前页
reload() {
this.refresh = !this.refresh
},
// 设置水印是否显示
setWatermarkVisible(visible: boolean) {
this.watermarkVisible = visible
},
// 设置自定义圆角
setCustomRadius(radius: string) {
this.customRadius = radius
document.documentElement.style.setProperty('--custom-radius', `${radius}rem`)
},
// 设置是否加载完礼花
setholidayFireworksLoaded(isLoad: boolean) {
this.holidayFireworksLoaded = isLoad
},
// 设置是否显示节日文本
setShowFestivalText(show: boolean) {
this.showFestivalText = show
},
// 设置节日日期
setFestivalDate(date: string) {
this.festivalDate = date
},
// 设置双列菜单是否显示文本
setDualMenuShowText(show: boolean) {
this.dualMenuShowText = show
}
}
})
function setElementThemeColor(color: string) {
const mixColor = '#ffffff'
const elStyle = document.documentElement.style
elStyle.setProperty('--el-color-primary', color)
handleElementThemeColor(color, useSettingStore().isDark)
// 生成更淡一点的颜色
for (let i = 1; i < 16; i++) {
const itemColor = colourBlend(color, mixColor, i / 16)
elStyle.setProperty(`--el-color-primary-custom-${i}`, itemColor)
}
}
+143
View File
@@ -0,0 +1,143 @@
import { defineStore } from 'pinia'
import { LanguageEnum } from '@/enums/appEnum'
import { router, setPageTitle } from '@/router'
import { UserInfo } from '@/types/store'
import { useSettingStore } from './setting'
import { useWorktabStore } from './worktab'
import { getSysStorage } from '@/utils/storage'
import { MenuListType } from '@/types/menu'
interface UserState {
language: LanguageEnum // 语言
isLogin: boolean // 是否登录
isLock: boolean // 是否锁屏
lockPassword: string // 锁屏密码
info: Partial<UserInfo> // 用户信息
searchHistory: MenuListType[] // 搜索历史
accessToken: string // 添加访问令牌
refreshToken: string // 添加刷新令牌
}
export const useUserStore = defineStore({
id: 'userStore',
state: (): UserState => ({
language: LanguageEnum.ZH,
isLogin: false,
isLock: false,
lockPassword: '',
info: {},
searchHistory: [],
accessToken: '',
refreshToken: ''
}),
getters: {
getUserInfo(): Partial<UserInfo> {
return this.info
},
getSettingState() {
return useSettingStore().$state
},
getWorktabState() {
return useWorktabStore().$state
}
},
actions: {
initState() {
let sys = getSysStorage()
if (sys) {
sys = JSON.parse(sys)
const { info, isLogin, language, searchHistory, isLock, lockPassword, refreshToken } =
sys.user
this.info = info || {}
this.isLogin = isLogin || false
this.isLock = isLock || false
this.language = language || LanguageEnum.ZH
this.searchHistory = searchHistory || []
this.lockPassword = lockPassword || ''
this.refreshToken = refreshToken || ''
this.accessToken = sessionStorage.getItem('accessToken') || ''
}
},
// 用户数据持久化存储
saveUserData() {
saveStoreStorage({
user: {
info: this.info,
isLogin: this.isLogin,
language: this.language,
isLock: this.isLock,
lockPassword: this.lockPassword,
searchHistory: this.searchHistory,
refreshToken: this.refreshToken,
worktab: this.getWorktabState,
setting: this.getSettingState
}
})
},
setUserInfo(info: UserInfo) {
this.info = info
},
setLoginStatus(isLogin: boolean) {
this.isLogin = isLogin
// 登录状态改变时立即保存
this.saveUserData()
},
setLanguage(lang: LanguageEnum) {
setPageTitle(router.currentRoute.value)
this.language = lang
},
setSearchHistory(list: Array<MenuListType>) {
this.searchHistory = list
},
setLockStatus(isLock: boolean) {
this.isLock = isLock
},
setLockPassword(password: string) {
this.lockPassword = password
},
setToken(accessToken: string, refreshToken?: string) {
this.accessToken = accessToken
if (refreshToken) {
this.refreshToken = refreshToken
}
sessionStorage.setItem('accessToken', accessToken)
this.saveUserData()
},
logOut() {
setTimeout(() => {
this.info = {}
this.isLogin = false
this.isLock = false
this.lockPassword = ''
this.accessToken = ''
this.refreshToken = ''
sessionStorage.removeItem('accessToken')
useWorktabStore().opened = []
this.saveUserData()
sessionStorage.removeItem('iframeRoutes')
router.push('/login')
}, 300)
}
}
})
function initVersion(version: string) {
const vs = localStorage.getItem('version')
if (!vs) {
localStorage.setItem('version', version)
}
}
// 数据持久化存储
function saveStoreStorage<T>(newData: T) {
const version = import.meta.env.VITE_VERSION
initVersion(version)
const vs = localStorage.getItem('version') || version
const storedData = JSON.parse(localStorage.getItem(`sys-v${vs}`) || '{}')
// 合并新数据与现有数据
const mergedData = { ...storedData, ...newData }
localStorage.setItem(`sys-v${vs}`, JSON.stringify(mergedData))
}
+190
View File
@@ -0,0 +1,190 @@
import { defineStore } from 'pinia'
import { WorkTabType } from '@/types/store'
import { HOME_PAGE } from '@/router/index'
import { router } from '@/router'
import { getSysStorage } from '@/utils/storage'
interface WorktabState {
opened: WorkTabType[]
current: Partial<WorkTabType>
keepAliveExclude: string[]
}
export const useWorktabStore = defineStore({
id: 'worktabStore',
state: (): WorktabState => ({
current: {},
opened: [],
keepAliveExclude: []
}),
actions: {
/**
* 初始化状态,从系统存储中加载工作台配置
*/
initState() {
const sysStorage = getSysStorage()
if (sysStorage) {
const sys = JSON.parse(sysStorage)
const { worktab } = sys.user
this.current = worktab.current || {}
this.opened = worktab.opened || []
this.checkFirstHomePage()
}
},
/**
* 打开或激活一个选项卡
* @param tab 目标选项卡信息
*/
openTab(tab: WorkTabType): void {
this.removeKeepAliveExclude(tab.name as string)
const index = this.opened.findIndex((item) => item.path === tab.path)
if (index === -1) {
this.opened.push({ ...tab })
} else {
const existingTab = this.opened[index]
if (!this.areQueriesEqual(existingTab.query, tab.query)) {
this.opened[index] = {
...existingTab,
query: tab.query,
// 可选:支持更多属性更新
title: tab.title || existingTab.title
}
}
}
this.current = this.opened[index === -1 ? this.opened.length - 1 : index]
},
// 辅助函数
areQueriesEqual(query1: any, query2: any): boolean {
return JSON.stringify(query1) === JSON.stringify(query2)
},
/**
* 关闭指定的选项卡,并处理激活状态和路由跳转
* @param path 要关闭的路由路径
*/
removeTab(path: string) {
const noCurrentTab = this.opened.find((tab) => tab.path === path)
const index = this.opened.findIndex((tab) => tab.path === path)
if (index === -1) return
this.opened.splice(index, 1)
// 若关闭后无选项卡,且关闭的不是首页,则跳转首页
if (!this.opened.length && path !== HOME_PAGE) {
router.push(HOME_PAGE)
return
}
// 若关闭的是当前激活标签,则标记其为缓存排除,并激活相邻标签
if (this.current.path === path) {
if (this.current.name) {
this.addKeepAliveExclude(this.current as WorkTabType)
}
const newIndex = index >= this.opened.length ? this.opened.length - 1 : index
this.current = this.opened[newIndex]
router.push(this.current.path as string)
} else {
if (noCurrentTab?.name) {
this.addKeepAliveExclude(noCurrentTab)
}
}
},
/**
* 关闭当前标签左侧(不包括首页)的所有选项卡
* @param path 当前选项卡的路由路径
*/
removeLeft(path: string) {
const index = this.opened.findIndex((tab) => tab.path === path)
if (index > 1) {
// 保留首页和当前标签
const tabsToRemove = this.opened.slice(1, index)
this.markTabsToRemove(tabsToRemove)
this.opened.splice(1, index - 1)
}
},
/**
* 关闭当前标签右侧的所有选项卡
* @param path 当前选项卡的路由路径
*/
removeRight(path: string) {
const index = this.opened.findIndex((tab) => tab.path === path)
if (index !== -1 && index < this.opened.length - 1) {
const tabsToRemove = this.opened.slice(index + 1)
this.markTabsToRemove(tabsToRemove)
this.opened.splice(index + 1)
}
},
/**
* 关闭除当前标签和首页外的所有选项卡
* @param path 当前选项卡的路由路径
*/
removeOthers(path: string) {
const tabsToRemove = this.opened.filter((tab) => tab.path !== path && tab.path !== HOME_PAGE)
this.markTabsToRemove(tabsToRemove)
this.opened = this.opened.filter((tab) => tab.path === path || tab.path === HOME_PAGE)
},
/**
* 关闭所有选项卡(当传入的 path 不是首页时关闭全部;首页时只保留首页)
* @param path 当前选项卡的路由路径
*/
removeAll(path: string) {
if (path !== HOME_PAGE) {
this.markTabsToRemove(this.opened)
this.current = {}
this.opened = []
router.push(HOME_PAGE)
} else {
const tabsToRemove = this.opened.filter((tab) => tab.path !== HOME_PAGE)
this.markTabsToRemove(tabsToRemove)
this.opened = this.opened.filter((tab) => tab.path === HOME_PAGE)
if (this.opened.length === 0) router.push(HOME_PAGE)
}
},
/**
* 检查第一个选项卡是否为首页,否则清空所有标签并跳转首页
*/
checkFirstHomePage() {
if (this.opened.length && this.opened[0].path !== HOME_PAGE) {
this.removeAll(HOME_PAGE)
}
},
/**
* 将指定选项卡添加到 keepAlive 排除列表中,只有当该选项卡的 keepAlive 为 true 时才进行添加
* @param tab 选项卡对象
*/
addKeepAliveExclude(tab: WorkTabType) {
if (tab.keepAlive && tab.name && !this.keepAliveExclude.includes(tab.name)) {
this.keepAliveExclude.push(tab.name)
}
},
/**
* 从 keepAlive 排除列表中移除指定组件名称
* @param name 路由组件名称
*/
removeKeepAliveExclude(name: string) {
this.keepAliveExclude = this.keepAliveExclude.filter((item) => item !== name)
},
/**
* 将传入的一组选项卡的组件名称标记为排除缓存
* @param tabs 需要标记的选项卡数组
*/
markTabsToRemove(tabs: WorkTabType[]) {
tabs.forEach((tab) => {
if (tab.name) {
this.addKeepAliveExclude(tab)
}
})
}
}
})
+15
View File
@@ -0,0 +1,15 @@
// API 响应基础类型
export interface BaseResult<T = any> {
code: number
message: string
data: T
}
// 分页结果类型
export interface PaginationResult<T = any> {
list: T[]
total: number
page: number
size: number
}
+24
View File
@@ -0,0 +1,24 @@
// 菜单项类型
export interface MenuItem {
id?: number | string // 添加 id 属性
path: string
name: string
component?: string | (() => Promise<any>)
meta?: {
title?: string
icon?: string
isHide?: boolean
isKeepAlive?: boolean
isAffix?: boolean
isLink?: string // 外部链接
link?: string // 兼容 link 属性
isIframe?: boolean
roles?: string[]
auth?: string[]
}
children?: MenuItem[]
}
// 菜单列表类型
export type MenuListType = MenuItem
+61
View File
@@ -0,0 +1,61 @@
import { MenuThemeEnum, SystemThemeEnum } from '@/enums/appEnum'
// 用户信息
export interface UserInfo {
id: number
name?: string // 改为可选,与 API 返回类型一致
username: string
avatar?: string // 改为可选,与 API 返回类型一致
email?: string // 改为可选,与 API 返回类型一致
}
// 系统主题样式(light | dark)
export interface SystemThemeType {
className: string // className
}
// 定义包含多个主题的类型
export type SystemThemeTypes = {
[key in Exclude<SystemThemeEnum, SystemThemeEnum.AUTO>]: SystemThemeType
}
// 菜单主题样式
export interface MenuThemeType {
theme: MenuThemeEnum // 主题名称
background: string // 背景色
systemNameColor: string // 系统标题颜色
textColor: string // 文字颜色
textActiveColor: string // 文字选中颜色
iconColor: string // 图标颜色
iconActiveColor: string // 图标选中颜色
tabBarBackground: string // 顶栏背景色
systemBackground: string // 系统背景色
leftLineColor: string // 左侧线条颜色
rightLineColor: string // 右侧线条颜色
img?: string // 图片
}
// 设置中心
export interface Setting {
theme: string // 主题
uniqueOpened: boolean // 是否开启手风琴模式
menuButton: boolean // 是否显示菜单展开按钮
showRefreshButton: boolean // 是否显示页面刷新按钮
showCrumbs: boolean // 是否显示全局面包屑
autoClose: boolean // 设置后是否自动关闭窗口
showWorkTab: boolean // 是否显示多标签
showLanguage: boolean // 是否显示多语言选择
showNprogress: boolean // 是否显示顶部进度条
themeModel: string // 主题模式
}
// 多标签
export interface WorkTabType {
title: string
path: string
name: string
keepAlive: boolean
params?: object
query?: object
}
+78
View File
@@ -0,0 +1,78 @@
import { showToast } from 'vant'
/**
* hex颜色转rgb颜色
* @param str 颜色值字符串
* @returns 返回处理后的颜色值
*/
export function hexToRgb(str: any) {
let hexs: any = ''
const reg = /^#?[0-9A-Fa-f]{6}$/
if (!reg.test(str)) {
showToast({ type: 'fail', message: '输入错误的hex' })
return
}
str = str.replace('#', '')
hexs = str.match(/../g)
for (let i = 0; i < 3; i++) hexs[i] = parseInt(hexs[i], 16)
return hexs
}
/**
* rgb颜色转Hex颜色
* @param r 代表红色
* @param g 代表绿色
* @param b 代表蓝色
* @returns 返回处理后的颜色值
*/
export function rgbToHex(r: any, g: any, b: any) {
const reg = /^\d{1,3}$/
if (!reg.test(r) || !reg.test(g) || !reg.test(b)) {
showToast({ type: 'fail', message: '输入错误的rgb颜色值' })
return
}
const hexs = [r.toString(16), g.toString(16), b.toString(16)]
for (let i = 0; i < 3; i++) if (hexs[i].length == 1) hexs[i] = `0${hexs[i]}`
return `#${hexs.join('')}`
}
/**
* 加深颜色值
* @param color 颜色值字符串
* @param level 加深的程度,限0-1之间
* @returns 返回处理后的颜色值
*/
export function getDarkColor(color: string, level: number) {
const reg = /^#?[0-9A-Fa-f]{6}$/
if (!reg.test(color)) {
showToast({ type: 'fail', message: '输入错误的hex颜色值' })
return
}
const rgb = hexToRgb(color)
for (let i = 0; i < 3; i++) rgb[i] = Math.round(20.5 * level + rgb[i] * (1 - level))
return rgbToHex(rgb[0], rgb[1], rgb[2])
}
/**
* 变浅颜色值
* @param color 颜色值字符串
* @param level 加深的程度,限0-1之间
* @returns 返回处理后的颜色值
*/
export function getLightColor(color: string, level: number) {
const reg = /^#?[0-9A-Fa-f]{6}$/
if (!reg.test(color)) {
showToast({ type: 'fail', message: '输入错误的hex颜色值' })
return
}
const rgb = hexToRgb(color)
for (let i = 0; i < 3; i++) {
rgb[i] = Math.round(255 * level + rgb[i] * (1 - level))
}
return rgbToHex(rgb[0], rgb[1], rgb[2])
}
+109
View File
@@ -0,0 +1,109 @@
export function getCssVariable(str: string) {
return getComputedStyle(document.documentElement).getPropertyValue(str)
}
// 将hex颜色转成rgb 例如(#F55442, 1)
export function hexToRgba(
hex: string,
opacity: number
): { red: number; green: number; blue: number; rgba: string } {
// 移除可能存在的 # 前缀并转换为大写
hex = hex.replace(/^#/, '').toUpperCase()
// 如果是缩写形式(如 FFF),转换为完整形式
if (hex.length === 3) {
hex = hex
.split('')
.map((char) => char.repeat(2))
.join('')
}
// 验证 hex 格式
if (!/^[0-9A-F]{6}$/.test(hex)) {
throw new Error('Invalid hex color format')
}
// 解析 RGB 值
const [red, green, blue] = hex.match(/\w\w/g)!.map((x) => parseInt(x, 16))
// 确保 opacity 在有效范围内
opacity = Math.max(0, Math.min(1, opacity))
// 构建 RGBA 字符串
const rgba = `rgba(${red}, ${green}, ${blue}, ${opacity.toFixed(2)})`
return { red, green, blue, rgba }
}
// 将rgb颜色转成hex 例如(24,12,255)
export function rgbToHex(r: any, g: any, b: any) {
const hex = '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
return hex
}
// 颜色混合
export function colourBlend(c1: string, c2: string, ratio: any) {
ratio = Math.max(Math.min(Number(ratio), 1), 0)
const r1 = parseInt(c1.substring(1, 3), 16)
const g1 = parseInt(c1.substring(3, 5), 16)
const b1 = parseInt(c1.substring(5, 7), 16)
const r2 = parseInt(c2.substring(1, 3), 16)
const g2 = parseInt(c2.substring(3, 5), 16)
const b2 = parseInt(c2.substring(5, 7), 16)
let r: any = Math.round(r1 * (1 - ratio) + r2 * ratio)
let g: any = Math.round(g1 * (1 - ratio) + g2 * ratio)
let b: any = Math.round(b1 * (1 - ratio) + b2 * ratio)
r = ('0' + (r || 0).toString(16)).slice(-2)
g = ('0' + (g || 0).toString(16)).slice(-2)
b = ('0' + (b || 0).toString(16)).slice(-2)
return '#' + r + g + b
}
export const getCssVar = (name: string) => {
return getComputedStyle(document.documentElement).getPropertyValue(name)
}
// 处理 Element Plus 主题颜色
export function handleElementThemeColor(theme: string, isDark: boolean = false): void {
document.documentElement.style.setProperty('--el-color-primary', theme)
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
`${getLightColor(theme, i / 10, isDark)}`
)
}
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-dark-${i}`,
`${getDarkColor(theme, i / 10)}`
)
}
}
// hex颜色转rgb颜色
export function hexToRgb(str: string): number[] {
str = str.replace('#', '')
const hexs = str.match(/../g)
if (!hexs) {
throw new Error('Invalid hex color')
}
return hexs.map((hex) => parseInt(hex, 16))
}
// 变浅颜色值
export function getLightColor(color: string, level: number, isDark: boolean = false): string {
if (isDark) {
return getDarkColor(color, level)
} else {
const rgb = hexToRgb(color)
const newRgb = rgb.map((value) => Math.floor((255 - value) * level + value))
return rgbToHex(newRgb[0], newRgb[1], newRgb[2])
}
}
// 变深颜色值
export function getDarkColor(color: string, level: number): string {
const rgb = hexToRgb(color)
const newRgb = rgb.map((value) => Math.floor(value * (1 - level)))
return rgbToHex(newRgb[0], newRgb[1], newRgb[2])
}
+21
View File
@@ -0,0 +1,21 @@
// ANSI 转义码生成网站 https://patorjk.com/software/taag/#p=display&f=Big&t=ABB%0A
const asciiArt = `
\x1b[32m✨ 你好!欢迎使用 Art Design Pro!
\x1b[0m
\x1b[36m💡 如果您觉得项目对您有帮助,请点击下面的链接为我点个 ★Star 支持一下!祝您使用愉快!
\x1b[0m
\x1b[33m🌟 GitHub: https://github.com/Daymychen/art-design-pro
\x1b[0m
\x1b[31m✨ 技术支持(QQ群): 821834289,如果你有任何问题,请加入QQ群,我们会在第一时间为你解答!
\x1b[0m
\x1b[36m _ _______ _________ ______ ________ ______ _____ ______ ____ _____ _______ _______ ___
\x1b[36m / \\ |_ __ \\ | _ _ | |_ _ \`.|_ __ |.' ____ \\ |_ _|.' ___ ||_ \\|_ _| |_ __ \\|_ __ \\ .' \`.
\x1b[36m / _ \\ | |__) ||_/ | | \\_| | | \`. \\ | |_ \\_|| (___ \\_| | | / .' \\_| | \\ | | | |__) | | |__) | / .-. \\
\x1b[36m / ___ \\ | __ / | | | | | | | _| _ _.____\`. | | | | ____ | |\\ \\| | | ___/ | __ / | | | |
\x1b[36m _/ / \\ \\_ _| | \\ \\_ _| |_ _| |_.' /_| |__/ || \\____) | _| |_\\ \`.___] |_| |_\\ |_ _| |_ _| | \\ \\_\\ \`-' /
\x1b[36m|____| |____||____| |___||_____| |______.'|________| \\______.'|_____|\\\`._____.'|_____|\____| |_____| |____| |___|\\\`.___.'
\x1b[0m
`
console.log(asciiArt)
+9
View File
@@ -0,0 +1,9 @@
ECharts 配置项参考地址
地址:https://echarts.apache.org/examples/zh/editor.html?c=line-simple
示例编辑:可以在这里方便快捷的配置
完整代码:配置后的所有代码
配置项:根据需求选择配置项配置
+56
View File
@@ -0,0 +1,56 @@
// 默认配置
const defaultOpstions = {
grid: {
left: '0',
right: '4%',
bottom: '0%',
top: '0',
containLabel: true
},
tooltip: {},
yAxis: {
axisLabel: {
show: true,
color: '#8c8c8c'
},
axisTick: {
show: false
},
axisLine: {
show: true,
lineStyle: {
color: '#e8e8e8',
width: 1,
type: 'solid'
}
}
},
xAxis: {
axisLine: {
show: true,
lineStyle: {
color: '#e8e8e8',
width: 1,
type: 'solid'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#e8e8e8',
width: 0,
type: 'solid'
}
},
axisLabel: {
show: true,
color: 'rgba(255,255,255, 0.6)'
},
axisTick: {
show: false
}
}
}
export { defaultOpstions }
+117
View File
@@ -0,0 +1,117 @@
// src/utils/echarts/useECharts.ts
import { unref, Ref, nextTick } from 'vue'
import echarts from '@/plugins/echarts'
import { defaultOpstions } from './defaultOpstions'
// ECharts 类型定义(如果 echarts 未安装)
type EChartsOption = any
type EChartsInstance = {
setOption: (option: any) => void
resize: () => void
dispose: () => void
}
export function useECharts(
elRef: Ref<HTMLDivElement>,
theme: 'light' | 'dark' | 'default' = 'light'
) {
let chartInstance: EChartsInstance | null = null
let resizeHandler: () => void
// 初始化echarts
function initCharts() {
const el = unref(elRef)
if (!el) {
return
}
chartInstance = echarts.init ? echarts.init(el, theme) : null
addResize()
}
// 配置
function setOptions(options: EChartsOption | any) {
// 默认配置
if (!options.grid) {
options.grid = defaultOpstions.grid
}
if (!options.tooltip) {
options.tooltip = defaultOpstions.tooltip
}
if (options.yAxis) {
const { axisLine, axisTick } = defaultOpstions.yAxis
if (!options.yAxis.axisLine) {
options.yAxis.axisLine = axisLine
}
if (!options.yAxis.axisTick) {
options.yAxis.axisTick = axisTick
}
}
if (options.xAxis) {
const { axisLine, splitLine, axisTick } = defaultOpstions.xAxis
if (!options.xAxis.axisLine) {
options.xAxis.axisLine = axisLine
}
if (!options.xAxis.splitLine) {
options.xAxis.splitLine = splitLine
}
if (!options.xAxis.axisTick) {
options.xAxis.axisTick = axisTick
}
}
if (unref(elRef).offsetHeight === 0) {
setTimeout(() => {
setOptions(options)
}, 30)
return
}
nextTick(() => {
setTimeout(() => {
if (!chartInstance) {
initCharts()
if (!chartInstance) return
}
chartInstance.setOption(options)
}, 30)
})
}
// 监听窗口大小变化
function addResize() {
resizeHandler = () => {
resize()
}
window.addEventListener('resize', resizeHandler)
}
// 移除监听
function removeResize() {
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
}
}
// 暴露 resize 方法
function resize() {
if (chartInstance) {
chartInstance.resize()
}
}
// 初始化时创建图表
initCharts()
return {
setOptions,
addResize,
removeResize,
resize,
echarts
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* 表情
* 用于在消息提示的时候显示对应的表情
*
* 用法
* ElMessage.success(`${EmojiText[200]} 图片上传成功`)
* ElMessage.error(`${EmojiText[400]} 图片上传失败`)
* ElMessage.error(`${EmojiText[500]} 图片上传失败`)
*/
// macos 用户 按 shift + 6 可以唤出更多表情……
const EmojiText: { [key: string]: string } = {
'0': 'O_O', // 空
'200': '^_^', // 成功
'400': 'T_T', // 错误请求
'500': 'X_X' // 服务器内部错误,无法完成请求
}
// const EmojiIcon = ['🟢', '🔴', '🟡 ', '🚀', '✨', '💡', '🛠️', '🔥', '🎉', '🌟', '🌈']
export default EmojiText
+174
View File
@@ -0,0 +1,174 @@
import axios, { InternalAxiosRequestConfig, AxiosRequestConfig, AxiosResponse } from 'axios'
import { showToast } from 'vant'
import { useUserStore } from '@/store/modules/user'
const getBaseURL = () => {
if (import.meta.env.DEV) {
return '/api'
}
return import.meta.env.VITE_API_URL || '/api'
}
const axiosInstance = axios.create({
timeout: 15000,
baseURL: getBaseURL(),
withCredentials: true,
transformRequest: [
(data) => {
if (data && typeof data === 'object' && !(data instanceof FormData)) {
return JSON.stringify(data)
}
return data
}
],
validateStatus: (status) => status >= 200 && status < 300,
headers: {
get: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
post: { 'Content-Type': 'application/json;charset=utf-8' }
},
transformResponse: [
(data, headers) => {
// 如果 data 是 undefined 或 null,直接返回
if (data === undefined || data === null) {
return data
}
const contentType = headers['content-type']
if (contentType && contentType.includes('application/json')) {
try {
// 如果 data 是字符串,尝试解析
if (typeof data === 'string') {
return JSON.parse(data)
}
// 如果已经是对象,直接返回
return data
} catch (error) {
console.error('JSON parse error:', error, 'data:', data)
return data
}
}
return data
}
]
})
// 请求拦截器
axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.accessToken) {
config.headers.Authorization = `Bearer ${userStore.accessToken}`
}
// 对于有 body 的请求(POST, PUT, PATCH),确保 Content-Type 正确
const method = config.method?.toUpperCase()
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
if (config.data && typeof config.data === 'object' && !(config.data instanceof FormData)) {
// 如果 data 是对象,确保 Content-Type 是 application/json
config.headers['Content-Type'] = 'application/json;charset=utf-8'
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
// 如果 response.data 是 undefined,直接返回
if (!response.data) {
return response.data
}
// 如果响应数据没有 code 字段,说明后端直接返回数据,直接返回
if (typeof response.data !== 'object' || !('code' in response.data)) {
return response.data
}
const { code, message } = response.data
// 成功响应
if (code === 200 || code === 0) {
return response.data
}
// 401 未授权,清除 token 并跳转登录
if (code === 401) {
const userStore = useUserStore()
userStore.logOut()
showToast({
type: 'fail',
message: message || '登录已过期,请重新登录'
})
setTimeout(() => {
window.location.href = '/login'
}, 1500)
return Promise.reject(new Error(message || 'Unauthorized'))
}
// 其他错误
showToast({
type: 'fail',
message: message || '请求失败'
})
return Promise.reject(new Error(message || 'Request failed'))
},
(error) => {
if (error.response) {
const { status, data } = error.response
if (status === 401) {
const userStore = useUserStore()
userStore.logOut()
showToast({
type: 'fail',
message: '登录已过期,请重新登录'
})
setTimeout(() => {
window.location.href = '/login'
}, 1500)
} else {
showToast({
type: 'fail',
message: data?.message || `请求失败: ${status}`
})
}
} else if (error.request) {
showToast({
type: 'fail',
message: '网络错误,请检查网络连接'
})
} else {
showToast({
type: 'fail',
message: error.message || '请求失败'
})
}
return Promise.reject(error)
}
)
// API 方法封装
const api = {
get<T = any>(config: AxiosRequestConfig): Promise<T> {
return axiosInstance.request({ ...config, method: 'GET' })
},
post<T = any>(config: AxiosRequestConfig): Promise<T> {
return axiosInstance.request({ ...config, method: 'POST' })
},
put<T = any>(config: AxiosRequestConfig): Promise<T> {
return axiosInstance.request({ ...config, method: 'PUT' })
},
patch<T = any>(config: AxiosRequestConfig): Promise<T> {
return axiosInstance.request({ ...config, method: 'PATCH' })
},
del<T = any>(config: AxiosRequestConfig): Promise<T> {
return axiosInstance.request({ ...config, method: 'DELETE' })
}
}
export default api
export { axiosInstance }
+3
View File
@@ -0,0 +1,3 @@
export enum ApiStatus {
success = 200
}
+66
View File
@@ -0,0 +1,66 @@
// 提取 iconfont 图标
export interface IconfontType {
className: string
unicode?: string
}
interface StyleSheetError {
sheet: CSSStyleSheet
error: unknown
}
function extractIconFromRule(rule: CSSRule): IconfontType | null {
if (!(rule instanceof CSSStyleRule)) return null
const { selectorText, style } = rule
if (!selectorText?.startsWith('.iconsys-') || !selectorText.includes('::before')) return null
const className = selectorText.substring(1).replace('::before', '')
const content = style.getPropertyValue('content')
if (!content) return null
const unicode = content.replace(/['"\\]/g, '')
return {
className,
unicode: unicode ? `&#x${getUnicode(unicode)};` : undefined
}
}
const processedErrors = new Set<StyleSheetError>()
export function extractIconClasses(): IconfontType[] {
const iconInfos: IconfontType[] = []
try {
Array.from(document.styleSheets).forEach((sheet) => {
try {
const rules = Array.from(sheet.cssRules || sheet.rules)
rules.forEach((rule) => {
const iconInfo = extractIconFromRule(rule)
if (iconInfo) {
iconInfos.push(iconInfo)
}
})
} catch (error) {
const styleSheetError: StyleSheetError = { sheet, error }
if (!processedErrors.has(styleSheetError)) {
console.warn('Cannot read cssRules from stylesheet:', {
error,
sheetHref: sheet.href
})
processedErrors.add(styleSheetError)
}
}
})
} catch (error) {
console.error('Failed to process stylesheets:', error)
}
return iconInfos
}
export function getUnicode(charCode: string): string {
if (!charCode) return ''
return charCode.charCodeAt(0).toString(16).padStart(4, '0')
}
+41
View File
@@ -0,0 +1,41 @@
import { MenuListType } from '@/types/menu'
import { router } from '@/router'
// 打开外部链接
export const openExternalLink = (link: string) => {
window.open(link, '_blank')
}
/**
* 菜单跳转
* @param item 菜单项
* @param jumpToFirst 是否跳转到第一个子菜单
* @returns
*/
export const handleMenuJump = (item: MenuListType, jumpToFirst: boolean = false) => {
// 处理外部链接
const link = item.meta?.link || item.meta?.isLink
const isIframe = item.meta?.isIframe
if (link && !isIframe) {
return openExternalLink(link)
}
// 如果不需要跳转到第一个子菜单,或者没有子菜单,直接跳转当前路径
if (!jumpToFirst || !item.children?.length) {
return router.push(item.path)
}
// 获取第一个可见的子菜单,如果没有则取第一个子菜单
const firstChild = item.children.find((child: MenuListType) => !child.meta?.isHide) || item.children[0]
// 如果第一个子菜单是外部链接则打开新窗口
const childLink = firstChild.meta?.link || firstChild.meta?.isLink
if (childLink) {
return openExternalLink(childLink)
}
// 跳转到子菜单路径
router.push(firstChild.path)
}
// ... existing code ...
+22
View File
@@ -0,0 +1,22 @@
export const WEB_LINKS = {
// Github 主页
GITHUB_HOME: 'https://github.com/Daymychen',
// 项目 Github 主页
GITHUB: 'https://github.com/Daymychen/art-design-pro',
// 个人博客
BLOG: 'https://www.lingchen.kim',
// 项目文档
DOCS: 'https://www.lingchen.kim/art-design-pro/docs',
// 项目社区
COMMUNITY: 'https://www.lingchen.kim/art-design-pro/docs/guide/community/communicate.html',
// 个人 Bilibili 主页
BILIBILI: 'https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0',
// 项目介绍
INTRODUCE: 'https://www.lingchen.kim/art-design-pro/docs/guide/introduce.html'
}
+55
View File
@@ -0,0 +1,55 @@
import { $t } from '@/language'
import { MenuListType } from '@/types/menu'
// 创建递归函数处理嵌套路由
/**
* 处理路由配置,转换为菜单数据结构
* @param route 路由配置对象
* @param parentPath 父级路径
* @returns 处理后的菜单项
*/
export const processRoute = (route: MenuListType, parentPath = ''): MenuListType => {
// 构建完整路径
const currentPath = route.path
? route.meta?.isIframe
? route.path // isIframe 为 true 时直接使用原始路径
: parentPath
? `${parentPath}/${route.path}`.replace(/\/+/g, '/') // 规范化路径,避免多余的斜杠
: route.path
: ''
return {
...(route.id && { id: route.id }), // 如果有 id 则包含
name: route.name,
path: currentPath,
component: route.component,
meta: route.meta ?? {}, // 使用空值合并运算符
children: Array.isArray(route.children)
? route.children.map((child: MenuListType) => processRoute(child, currentPath))
: []
}
}
/**
* 保存 iframe 路由到 sessionStorage 中
* @param list iframe 路由列表
*/
export const saveIframeRoutes = (list: MenuListType[]): void => {
if (list.length > 0) {
sessionStorage.setItem('iframeRoutes', JSON.stringify(list))
}
}
// 获取 iframe 路由
export const getIframeRoutes = () => {
return JSON.parse(sessionStorage.getItem('iframeRoutes') || '[]')
}
/**
* 格式化菜单标题
* @param title 菜单标题,可以是 i18n 的 key,也可以是字符串,比如:'用户列表'
* @returns 格式化后的菜单标题
*/
export const formatMenuTitle = (title: string) => {
return title.startsWith('menus.') ? $t(title) : title
}
+9
View File
@@ -0,0 +1,9 @@
/**
* 全局事件总线,用于全局事件的发布与订阅
* 用法:
* mittBus.on('event', callback)
* mittBus.emit('event', data)
*/
import mitt from 'mitt'
const mittBus = mitt()
export default mittBus
+167
View File
@@ -0,0 +1,167 @@
import { showToast } from 'vant'
import { router } from '@/router'
import { useUserStore } from '@/store/modules/user'
import { useSettingStore } from '@/store/modules/setting'
import { useWorktabStore } from '@/store/modules/worktab'
// 初始化本地数据
export function initState() {
if (validateStorageData() || isLocalStorageEmpty()) {
const userStore = useUserStore()
const worktabStore = useWorktabStore()
const settingStore = useSettingStore()
userStore.initState()
worktabStore.initState()
settingStore.initState()
}
}
// 获取系统存储数据
export function getSysStorage() {
const version = getSysVersion() || import.meta.env.VITE_VERSION
return localStorage.getItem(`sys-v${version}`) as any
}
// 获取系统版本
export function getSysVersion() {
return localStorage.getItem('version')
}
// 验证本地存储数据的类型
function validate(obj: any, schema: any, path: string = ''): boolean {
// 如果 obj 不存在或为 null,且不是根对象,则跳过验证(允许可选字段)
if (!obj && path !== '') {
return true
}
// 如果 obj 不是对象,验证失败
if (obj && typeof obj !== 'object') {
return false
}
return Object.keys(schema).every((key) => {
const fullPath = path ? `${path}.${key}` : key
const expectedType = schema[key]
const value = obj?.[key]
const actualType = typeof value
// 如果字段不存在或为 undefined,且是嵌套对象,允许跳过(可选字段)
if (value === undefined || value === null) {
if (typeof expectedType === 'object' && !Array.isArray(expectedType)) {
return true // 允许嵌套对象为 undefined
}
// 对于基本类型,如果值为 undefined,也允许(可选字段)
return true
}
if (typeof expectedType === 'object' && !Array.isArray(expectedType)) {
return validate(value, expectedType, fullPath)
} else if (actualType !== expectedType) {
console.error(
`检测到本地数据异常 path:[/utils/storage.ts] ${fullPath} 数据类型应为 ${expectedType},实际为 ${actualType}`
)
return false
}
return true
})
}
// 显示错误消息并处理登出逻辑
function handleError() {
showToast({
type: 'fail',
message: '系统检测到本地数据异常,请重新登录系统恢复使用!',
duration: 5000
})
logOut()
}
function logOut() {
setTimeout(() => {
localStorage.clear()
useUserStore().logOut()
router.push('/login')
initState()
}, 1000)
}
// 验证本地存储数据并处理异常
export function validateStorageData() {
if (location.href.includes('/login')) return true
const schema = {
user: {
info: 'object',
isLogin: 'boolean',
language: 'string',
worktab: {
// current 是 Partial 类型,所有字段都是可选的
current: {},
opened: 'object'
},
setting: {
systemThemeType: 'string',
systemThemeMode: 'string',
menuThemeType: 'string',
boxBorderMode: 'boolean',
uniqueOpened: 'boolean',
systemThemeColor: 'string',
showMenuButton: 'boolean',
showRefreshButton: 'boolean',
showCrumbs: 'boolean',
autoClose: 'boolean',
showWorkTab: 'boolean',
showLanguage: 'boolean',
showNprogress: 'boolean',
colorWeak: 'boolean',
showSettingGuide: 'boolean',
refresh: 'boolean'
}
}
}
try {
const data = getLocalStorageData()
// 模拟本地数据类型错误
// data.user.language = 2024
if (Object.keys(data).length === 0) {
logOut()
return false
}
if (!validate(data, schema)) {
throw new Error('本地存储数据结构异常')
}
return true
} catch {
handleError()
return false
}
}
// 获取本地存储数据
function getLocalStorageData() {
return JSON.parse(getSysStorage() || '{}')
}
// 本地存储是否为空
function isLocalStorageEmpty() {
return Object.keys(getLocalStorageData()).length === 0
}
// 将 vuex 中的数据保存到 localStorage 中(在即将离开页面(刷新或关闭)时执行)
export function saveUserData() {
const isiOS = /iPhone|iPad|iPod/i.test(navigator.userAgent)
const eventType = isiOS ? 'pagehide' : 'beforeunload'
window.addEventListener(eventType, () => {
if (getSysVersion()) {
useUserStore().saveUserData()
}
})
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 标签页容器高度以及顶部距离配置
* @param {Object} tab-default - 默认标签页样式配置
* @param {number} openTop - 标签页打开时的顶部填充高度
* @param {number} closeTop - 标签页关闭时的顶部填充高度
* @param {number} openHeight - 标签页打开时的总高度
* @param {number} closeHeight - 标签页关闭时的总高度
*/
export const TAB_CONFIG = {
'tab-default': {
openTop: 70,
closeTop: 60,
openHeight: 85,
closeHeight: 75
},
'tab-card': {
openTop: 122,
closeTop: 78,
openHeight: 139,
closeHeight: 95
},
'tab-google': {
openTop: 122,
closeTop: 78,
openHeight: 139,
closeHeight: 95
}
}
// 获取当前 tab 样式配置,设置默认值
export const getTabConfig = (style: string) => {
return TAB_CONFIG[style as keyof typeof TAB_CONFIG] || TAB_CONFIG['tab-card'] // 默认使用 tab-card 配置
}
+35
View File
@@ -0,0 +1,35 @@
import { useCommon } from '@/composables/useCommon'
import { useTheme } from '@/composables/useTheme'
import { SystemThemeEnum } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting'
const { LIGHT, DARK } = SystemThemeEnum
/**
* 主题切换动画
* @param e 鼠标点击事件
*/
export const themeAnimation = (e: any) => {
const x = e.clientX
const y = e.clientY
// 计算鼠标点击位置距离视窗的最大圆半径
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
// 设置CSS变量
document.documentElement.style.setProperty('--x', x + 'px')
document.documentElement.style.setProperty('--y', y + 'px')
document.documentElement.style.setProperty('--r', endRadius + 'px')
if (document.startViewTransition) {
document.startViewTransition(() => toggleTheme())
} else {
toggleTheme()
}
}
/**
* 切换主题
*/
const toggleTheme = () => {
useTheme().switchThemeStyles(useSettingStore().systemThemeType === LIGHT ? DARK : LIGHT)
useCommon().refresh()
}
+47
View File
@@ -0,0 +1,47 @@
import { upgradeLogList } from '@/mock/upgradeLog'
import { showNotify } from 'vant'
import { useUserStore } from '@/store/modules/user'
// 系统升级
export function systemUpgrade() {
const version = import.meta.env.VITE_VERSION
// 跳过版本为1.0.0的提示
if (version === '1.0.0') return
setTimeout(() => {
const { title: content, requireReLogin } = upgradeLogList.value[0]
// 系统升级公告
const message = [
`<p style="color: var(--art-gray-text-800) !important; padding-bottom: 5px;">`,
`系统已升级到 ${version} 版本,此次更新带来了以下改进:`,
`</p>`,
content,
requireReLogin
? `<p style="color: var(--main-color); padding-top: 5px;">升级完成,请重新登录后继续使用。</p>`
: ''
].join('')
const oldSysKey = Object.keys(localStorage).find(
(key) => key.startsWith('sys-v') && key !== `sys-v${version}`
)
if (oldSysKey) {
showNotify({
type: 'success',
message: message,
duration: 0
})
setTimeout(() => {
// 更新版本号
localStorage.setItem('version', version)
// 删除旧版本号
localStorage.removeItem(oldSysKey)
// 需要重新登录
if (requireReLogin) {
useUserStore().logOut()
}
}, 1000)
}
}, 1000)
}
+293
View File
@@ -0,0 +1,293 @@
/**
* 常用 JavaScript 函数片段
* author chen tao
* date 2020-10-6
*/
/* 数组相关函数 */
// 数组去重
export function noRepeat<T>(arr: T[]): T[] {
return [...new Set(arr)]
}
// 查找数组最大值
export function arrayMax(arr: number[]): number {
if (!arr.length) throw new Error('Array is empty')
return Math.max(...arr)
}
// 查找数组最小值
export function arrayMin(arr: number[]): number {
if (!arr.length) throw new Error('Array is empty')
return Math.min(...arr)
}
// 数组分割
export function chunk<T>(arr: T[], size: number = 1): T[][] {
if (size <= 0) return [arr.slice()]
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
arr.slice(i * size, i * size + size)
)
}
// 检查元素出现次数
export function countOccurrences<T>(arr: T[], value: T): number {
return arr.reduce((count, current) => (current === value ? count + 1 : count), 0)
}
// 扁平化数组
export function flatten<T>(arr: any[], depth: number = Infinity): T[] {
return arr.flat(depth)
}
// 返回两个数组的差集
export function difference<T>(arrA: T[], arrB: T[]): T[] {
const setB = new Set(arrB)
return arrA.filter((item) => !setB.has(item))
}
// 返回两个数组的交集
export function intersection<T>(arr1: T[], arr2: T[]): T[] {
const set2 = new Set(arr2)
return arr1.filter((item) => set2.has(item))
}
// 从右删除 n 个元素
export function dropRight<T>(arr: T[], n: number = 0): T[] {
return arr.slice(0, Math.max(0, arr.length - n))
}
// 返回间隔 nth 的元素
export function everyNth<T>(arr: T[], nth: number): T[] {
if (nth <= 0) return []
return arr.filter((_, i) => i % nth === nth - 1)
}
// 返回第 n 个元素
export function nthElement<T>(arr: T[], n: number = 0): T | undefined {
const index = n >= 0 ? n : arr.length + n
return arr[index]
}
// 数组乱序
export function shuffle<T>(arr: T[]): T[] {
const array = [...arr] // 创建新数组避免修改原数组
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[array[i], array[j]] = [array[j], array[i]]
}
return array
}
/* 浏览器对象 BOM */
// 当前网页地址
export function currentURL(): string {
return window.location.href
}
// 获取滚动条位置
export function getScrollPosition(el: HTMLElement | Window = window): { x: number; y: number } {
return el === window
? {
x: window.scrollX || document.documentElement.scrollLeft,
y: window.scrollY || document.documentElement.scrollTop
}
: {
x: (el as HTMLElement).scrollLeft,
y: (el as HTMLElement).scrollTop
}
}
// 获取 URL 参数
export function getURLParameters(url: string): Record<string, string> {
return Object.fromEntries(new URLSearchParams(url.split('?')[1]).entries())
}
// 兼容非 HTTPS 等环境:使用 execCommand 复制
function fallbackCopy(str: string): boolean {
try {
const textarea = document.createElement('textarea')
textarea.value = str
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.setAttribute('readonly', '')
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
// 复制文本(同步,优先 clipboard API,不可用时回退 execCommand)
export function copy(str: string): boolean {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(str)
return true
}
return fallbackCopy(str)
} catch (err) {
console.error('Copy failed:', err)
return fallbackCopy(str)
}
}
/**
* 异步复制到剪贴板,生产环境安全:优先 clipboard API,不可用时回退 execCommand。
*/
export async function copyToClipboard(str: string): Promise<boolean> {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(str)
return true
}
return fallbackCopy(str)
} catch {
return fallbackCopy(str)
}
}
/**
* 从图片 URL 拉取图片并复制到剪贴板(需支持 ClipboardItem,通常为 HTTPS 环境)。
*/
export async function copyImageFromUrl(imageUrl: string): Promise<boolean> {
try {
if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function') return false
const res = await fetch(imageUrl, { mode: 'cors' })
if (!res.ok) return false
const blob = await res.blob()
const type = blob.type || 'image/png'
await navigator.clipboard.write([new ClipboardItem({ [type]: blob })])
return true
} catch {
return false
}
}
/**
* 将 Blob 图片写入剪贴板(用于 html2canvas 等生成的图片)。
*/
export async function copyImageBlobToClipboard(blob: Blob): Promise<boolean> {
try {
if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function') return false
const type = blob.type || 'image/png'
await navigator.clipboard.write([new ClipboardItem({ [type]: blob })])
return true
} catch {
return false
}
}
// 检测设备类型
export function detectDeviceType(): 'Mobile' | 'Desktop' {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
? 'Mobile'
: 'Desktop'
}
/* Cookie 操作 */
// 设置 Cookie
export function setCookie(key: string, value: string, expireDays: number): void {
const date = new Date()
date.setDate(date.getDate() + expireDays)
document.cookie = `${key}=${encodeURIComponent(value)}${
expireDays ? `;expires=${date.toUTCString()}` : ''
}`
}
// 删除 Cookie
export function delCookie(name: string): void {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`
}
// 获取 Cookie
export function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : null
}
/* 日期相关 */
// 时间戳转时间
export function timestampToTime(timestamp: number = Date.now(), isMs: boolean = true): string {
const date = new Date(isMs ? timestamp : timestamp * 1000)
return date.toISOString().replace('T', ' ').slice(0, 19)
}
/* DOM 操作 */
let scrollTop = 0
export function preventScroll(): void {
scrollTop = window.scrollY
Object.assign(document.body.style, {
position: 'fixed',
width: '100%',
top: `-${scrollTop}px`,
overflowY: 'hidden'
})
}
export function recoverScroll(): void {
Object.assign(document.body.style, {
position: '',
width: '',
top: '',
overflowY: ''
})
window.scrollTo(0, scrollTop)
}
// 判断是否到达页面底部
export function bottomVisible(): boolean {
return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight
}
// 判断元素是否在可视区域
export function elementIsVisibleInViewport(
el: HTMLElement,
partiallyVisible: boolean = false
): boolean {
const { top, left, bottom, right } = el.getBoundingClientRect()
const { innerHeight, innerWidth } = window
return partiallyVisible
? top + el.offsetHeight > 0 &&
top < innerHeight &&
left + el.offsetWidth > 0 &&
left < innerWidth
: top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth
}
// 获取元素样式
export function getStyle(el: HTMLElement, ruleName: string): string {
return window.getComputedStyle(el)[ruleName as any]
}
/* 数字操作 */
export function commafy(num: number): string {
return num.toLocaleString('en-US')
}
export function randomNum(min: number, max?: number): number {
if (max === undefined) return Math.floor(Math.random() * min)
return Math.floor(Math.random() * (max - min + 1)) + min
}
/* 字符串操作 */
export function removeHtmlTags(str: string = ''): string {
return str.replace(/<[^>]*>/g, '')
}
// 判断是不是iframe
export function isIframe(url: string) {
return url.includes('/outside/iframe')
}
+41
View File
@@ -0,0 +1,41 @@
import { useWorktabStore } from '@/store/modules/worktab'
import { RouteLocationNormalized } from 'vue-router'
import { getIframeRoutes } from './menu'
import { isIframe } from './utils'
import { useSettingStore } from '@/store/modules/setting'
import { HOME_PAGE } from '@/router'
/**
* 根据当前路由信息设置工作标签页(worktab)
* @param to 当前路由对象
*/
export const setWorktab = (to: RouteLocationNormalized): void => {
const worktabStore = useWorktabStore()
const { meta, path, name, params, query } = to
if (!meta.isHideTab) {
// 如果是 iframe 页面,则特殊处理工作标签页
if (isIframe(path)) {
const iframeRoute = getIframeRoutes().find((route: any) => route.path === to.path)
if (iframeRoute?.meta) {
worktabStore.openTab({
title: iframeRoute.meta.title,
path,
name: name as string,
keepAlive: meta.keepAlive as boolean,
params,
query
})
}
} else if (useSettingStore().showWorkTab || path === HOME_PAGE) {
worktabStore.openTab({
title: meta.title as string,
path,
name: name as string,
keepAlive: meta.keepAlive as boolean,
params,
query
})
}
}
}
+232
View File
@@ -0,0 +1,232 @@
<template>
<div class="dashboard-page">
<div class="header">
<div class="header-title">仪表盘</div>
<div class="header-user" @click="handleUserClick">
<img src="@/assets/img/avatar11.png" alt="avatar" class="avatar" />
<span class="username">{{ userStore.getUserInfo.username || '用户' }}</span>
</div>
</div>
<div class="menu-grid">
<div
class="menu-item"
v-for="menu in menuList"
:key="menu.path"
@click="handleMenuClick(menu.path)"
>
<div class="menu-icon">{{ menu.icon }}</div>
<div class="menu-label">{{ menu.label }}</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card" v-for="stat in statsList" :key="stat.title">
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-value">
{{ stat.value }}<span class="stat-suffix">{{ stat.suffix }}</span>
</div>
</div>
</div>
<div class="charts-section">
<DualBarChart title="今日入库量(按货物,t)" :data="inboundByCargo" />
<DualBarChart title="今日出库量(按货物,t)" :data="outboundByCargo" />
<DualBarChart title="今日入库量(按往来单位+货物,t)" :data="inboundByPartnerCargo" />
<DualBarChart title="今日出库量(按往来单位+货物,t)" :data="outboundByPartnerCargo" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
import { showDialog } from 'vant'
import { dashboardApi, type DashboardCargoStat, type DashboardPartnerCargoStat } from '@/api/dashboardApi'
import DualBarChart from '@/components/Charts/DualBarChart.vue'
const router = useRouter()
const userStore = useUserStore()
const isLoading = ref(false)
const inboundByCargo = ref<DashboardCargoStat[]>([])
const outboundByCargo = ref<DashboardCargoStat[]>([])
const inboundByPartnerCargo = ref<DashboardPartnerCargoStat[]>([])
const outboundByPartnerCargo = ref<DashboardPartnerCargoStat[]>([])
onMounted(async () => {
isLoading.value = true
try {
const [inCargo, outCargo, inPartner, outPartner] = await Promise.all([
dashboardApi.getInboundByCargo(),
dashboardApi.getOutboundByCargo(),
dashboardApi.getInboundByPartnerCargo(),
dashboardApi.getOutboundByPartnerCargo()
])
inboundByCargo.value = inCargo
outboundByCargo.value = outCargo
inboundByPartnerCargo.value = inPartner
outboundByPartnerCargo.value = outPartner
} catch (error) {
console.error('获取数据失败', error)
} finally {
isLoading.value = false
}
})
const todayInboundVehicles = computed(() => {
const cargoSet = new Set<string>()
inboundByCargo.value.forEach(item => {
if (item.planned > 0) cargoSet.add(item.label)
})
return cargoSet.size
})
const todayOutboundVehicles = computed(() => {
const cargoSet = new Set<string>()
outboundByCargo.value.forEach(item => {
if (item.planned > 0) cargoSet.add(item.label)
})
return cargoSet.size
})
const statsList = computed(() => [
{ title: '今日入库', value: todayInboundVehicles.value, suffix: '车' },
{ title: '今日出库', value: todayOutboundVehicles.value, suffix: '车' }
])
const menuList = computed(() => {
return [
{ path: '/plans', label: '计划管理', icon: '📋' },
{ path: '/inventory', label: '出入库管理', icon: '📦' },
{ path: '/weighing', label: '地磅单管理', icon: '⚖️' },
{ path: '/master-data', label: '基础资料', icon: '📚' }
]
})
const handleMenuClick = (path: string) => {
router.push(path)
}
const handleUserClick = () => {
showDialog({
title: '确认退出',
message: '确定要退出登录吗?'
})
.then(() => {
userStore.logOut()
router.push('/login')
})
.catch(() => {})
}
</script>
<style lang="scss" scoped>
.dashboard-page {
min-height: 100vh;
background: #f5f7fa;
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
&-title {
font-size: 32px;
font-weight: 700;
color: #111827;
}
&-user {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid #e5e7eb;
}
.username {
font-size: 12px;
color: #6b7280;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.stat-title {
font-size: 14px;
color: #6b7280;
margin-bottom: 12px;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #111827;
}
.stat-suffix {
font-size: 20px;
color: #9ca3af;
margin-left: 4px;
}
.charts-section {
margin-bottom: 24px;
}
.menu-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.menu-item {
background: #fff;
border-radius: 12px;
padding: 12px 8px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: transform 0.2s;
cursor: pointer;
&:active {
transform: scale(0.95);
}
}
.menu-icon {
font-size: 28px;
margin-bottom: 6px;
}
.menu-label {
font-size: 12px;
color: #111827;
font-weight: 500;
}
</style>
+556
View File
@@ -0,0 +1,556 @@
<template>
<div class="page-content">
<div class="page-header">
<div class="page-header__back" @click="handleBack">
<div class="back-arrow"></div>
</div>
<div class="page-header__title">{{ isEdit ? '编辑出入库' : isView ? '查看出入库' : '新增出入库' }}</div>
</div>
<div class="form-container">
<van-form @submit="handleSubmit">
<div class="form-section">
<div class="form-item">
<div class="form-label">日期 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.date || '--' }}
</div>
<div v-else class="form-input" @click="showDateCalendar = true">
<span :class="{ placeholder: !formData.date }">
{{ formData.date || '选择日期' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">往来单位 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.partnerCompany || '--' }}
</div>
<div v-else class="form-input" @click="showCompanyPicker = true">
<span :class="{ placeholder: !formData.partnerCompany }">
{{ formData.partnerCompany || '请选择往来单位' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">业务类型 <span class="required">*</span></div>
<div class="radio-wrapper">
<van-radio-group v-model="formData.businessType" :disabled="isView" direction="horizontal">
<van-radio name="入库">入库</van-radio>
<van-radio name="出库">出库</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-item">
<div class="form-label">产品 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.product || '--' }}
</div>
<div v-else class="form-input" @click="showCargoPicker = true">
<span :class="{ placeholder: !formData.product }">
{{ formData.product || '请选择产品' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item" :class="{ 'quantity-default-red': formData.quantity === 32 }">
<div class="form-label">数量(吨) <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.quantity || '--' }}
</div>
<van-field
v-else
v-model.number="formData.quantity"
type="number"
placeholder="请输入数量(默认32,请核对)"
/>
</div>
<div class="form-item">
<div class="form-label">车牌号 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.vehicleNo || '--' }}
</div>
<div v-else class="form-input" @click="showVehiclePicker = true">
<span :class="{ placeholder: !formData.vehicleNo }">
{{ formData.vehicleNo || '请选择或输入车牌号' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">计划号</div>
<div v-if="isView" class="form-input">
{{ formData.planNo || '--' }}
</div>
<van-field
v-else
v-model="formData.planNo"
placeholder="请输入计划号"
:readonly="isPlanNoReadonly"
/>
</div>
<div class="form-item">
<div class="form-label">备注</div>
<div v-if="isView" class="form-input">
{{ formData.remarks || '--' }}
</div>
<van-field
v-else
v-model="formData.remarks"
type="textarea"
autosize
rows="3"
placeholder="请输入备注"
/>
</div>
</div>
<div v-if="!isView" class="form-actions">
<van-button block type="primary" native-type="submit" :loading="saving">
保存
</van-button>
<van-button block plain @click="handleBack">
取消
</van-button>
</div>
</van-form>
</div>
<!-- 公司选择器 -->
<van-popup v-model:show="showCompanyPicker" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="company-picker-container">
<div class="search-container">
<van-search
v-model="companySearchKeyword"
placeholder="搜索往来单位名称"
@search="handleCompanySearch"
@clear="handleCompanySearch"
shape="round"
/>
</div>
<van-picker
:columns="filteredCompanyOptions"
show-toolbar
@confirm="onCompanyConfirm"
@cancel="showCompanyPicker = false"
/>
</div>
</van-popup>
<!-- 货物选择器 -->
<van-popup v-model:show="showCargoPicker" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="company-picker-container">
<div class="search-container">
<van-search
v-model="cargoSearchKeyword"
placeholder="搜索产品名称"
@search="handleCargoSearch"
@clear="handleCargoSearch"
shape="round"
/>
</div>
<van-picker
:columns="filteredCargoOptions"
show-toolbar
@confirm="onCargoConfirm"
@cancel="showCargoPicker = false"
/>
</div>
</van-popup>
<!-- 日期选择器 -->
<van-calendar
v-model:show="showDateCalendar"
v-model="dateSelected"
@confirm="onDateConfirm"
/>
<!-- 车牌号选择器:支持搜索 + 手动输入 -->
<van-popup v-model:show="showVehiclePicker" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="vehicle-picker-container">
<div class="search-container">
<van-search
v-model="vehicleSearchKeyword"
placeholder="搜索车牌号"
@search="handleVehicleSearch"
@clear="handleVehicleSearch"
@update:model-value="handleVehicleSearch"
shape="round"
/>
</div>
<div class="vehicle-list">
<van-cell
v-for="item in filteredVehicleOptions"
:key="item.vehicleNo"
:title="item.vehicleNo"
clickable
@click="onVehicleSelect(item.vehicleNo)"
/>
<van-cell v-if="filteredVehicleOptions.length === 0 && !vehicleLoading" title="暂无匹配车牌号,可点击下方手动输入" />
</div>
<div class="manual-input-row">
<van-button block plain type="primary" @click="showManualVehicleInput = true">
手动输入新车牌号
</van-button>
</div>
</div>
</van-popup>
<!-- 手动输入车牌号弹层 -->
<van-dialog
v-model:show="showManualVehicleInput"
title="输入车牌号"
show-cancel-button
@confirm="onManualVehicleConfirm"
>
<van-field
v-model="manualVehicleNo"
placeholder="请输入车牌号"
clearable
class="dialog-field"
/>
</van-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { inventoryApi, type InventoryRecord, type InventoryCreate } from '@/api/inventoryApi'
import { companyApi, cargoApi, vehicleApi } from '@/api/masterDataApi'
import dayjs from 'dayjs'
const router = useRouter()
const route = useRoute()
const isView = computed(() => route.query.mode === 'view')
const isEdit = computed(() => route.query.mode === 'edit' || route.params.id)
// 计划带入新增时计划号锁定,避免误改
const isPlanNoReadonly = computed(() => !!route.query.planNo && !isEdit.value)
// const isAdd = computed(() => !isView.value && !isEdit.value) // 未使用
const saving = ref(false)
const editingRecord = ref<InventoryRecord | null>(null)
const showCompanyPicker = ref(false)
const showCargoPicker = ref(false)
const showVehiclePicker = ref(false)
const showManualVehicleInput = ref(false)
const showDateCalendar = ref(false)
const dateSelected = ref<Date | null>(null)
const companyOptions = ref<{ text: string; value: string }[]>([])
const vehicleOptions = ref<{ vehicleNo: string }[]>([])
const vehicleSearchKeyword = ref('')
const vehicleLoading = ref(false)
const manualVehicleNo = ref('')
// 公司搜索相关
const companySearchKeyword = ref('')
const allCompanyOptions = ref<{ text: string; value: string }[]>([])
const filteredCompanyOptions = computed(() => {
if (!companySearchKeyword.value) {
return [allCompanyOptions.value]
}
const filtered = allCompanyOptions.value.filter(item =>
item.text.toLowerCase().includes(companySearchKeyword.value.toLowerCase())
)
return [filtered]
})
// 产品搜索相关
const cargoSearchKeyword = ref('')
const allCargoOptions = ref<{ text: string; value: string }[]>([])
const filteredCargoOptions = computed(() => {
if (!cargoSearchKeyword.value) return [allCargoOptions.value]
const filtered = allCargoOptions.value.filter(item =>
item.text.toLowerCase().includes(cargoSearchKeyword.value.toLowerCase())
)
return [filtered]
})
const filteredVehicleOptions = computed(() => {
if (!vehicleSearchKeyword.value) return vehicleOptions.value
const kw = vehicleSearchKeyword.value.toLowerCase()
return vehicleOptions.value.filter(item =>
item.vehicleNo.toLowerCase().includes(kw)
)
})
// 如果是从计划管理跳转过来的(有planNo参数),日期为空;否则使用当前日期
const initialDate = route.query.planNo ? '' : dayjs().format('YYYY-MM-DD')
const purchasePlanIdFromRoute = route.query.purchasePlanId ? Number(route.query.purchasePlanId) : undefined
const formData = reactive<InventoryCreate>({
date: initialDate,
partnerCompany: (route.query.customerName as string) || '',
businessType: (route.query.businessType as '入库' | '出库') || '入库',
product: (route.query.product as string) || '',
quantity: 32,
vehicleNo: '',
planNo: (route.query.planNo as string) || '',
remarks: '',
purchasePlanId: purchasePlanIdFromRoute
})
// 初始化日期选择器的默认值(如果日期为空,使用当前日期作为选择器的默认值)
dateSelected.value = route.query.planNo ? null : dayjs().toDate()
const loadCompanies = async () => {
try {
const companies = await companyApi.list()
allCompanyOptions.value = companies.map(item => ({
text: item.name,
value: item.name
}))
companyOptions.value = allCompanyOptions.value
} catch (error) {
console.error('加载公司列表失败', error)
}
}
const handleCompanySearch = () => {
// 触发计算属性更新
}
const loadCargos = async () => {
try {
const cargos = await cargoApi.list()
allCargoOptions.value = cargos.map(item => ({
text: item.name,
value: item.name
}))
} catch (error) {
console.error('加载货物列表失败', error)
}
}
const handleCargoSearch = () => {
// 触发 computed 过滤
}
const loadVehicles = async () => {
vehicleLoading.value = true
try {
const list = await vehicleApi.list(vehicleSearchKeyword.value || undefined)
vehicleOptions.value = list.map(item => ({ vehicleNo: item.vehicleNo }))
} catch (error) {
console.error('加载车牌号列表失败', error)
} finally {
vehicleLoading.value = false
}
}
const handleVehicleSearch = () => {
loadVehicles()
}
const onVehicleSelect = (vehicleNo: string) => {
formData.vehicleNo = vehicleNo
showVehiclePicker.value = false
}
const onManualVehicleConfirm = () => {
const v = (manualVehicleNo.value || '').trim()
if (v) {
formData.vehicleNo = v
showVehiclePicker.value = false
}
showManualVehicleInput.value = false
manualVehicleNo.value = ''
}
// 保存前确保车牌号已写入车牌号表(新车牌则先创建)
const ensureVehicleExists = async (vehicleNo: string) => {
if (!vehicleNo?.trim()) return
try {
await vehicleApi.create({ vehicleNo: vehicleNo.trim(), remarks: null })
} catch (err: any) {
if (err.response?.status !== 409) throw err
}
}
const onCompanyConfirm = ({ selectedOptions }: any) => {
formData.partnerCompany = selectedOptions[0].value
showCompanyPicker.value = false
}
const onCargoConfirm = ({ selectedOptions }: any) => {
formData.product = selectedOptions[0].value
showCargoPicker.value = false
}
const onDateConfirm = (value: Date | Date[]) => {
const date = Array.isArray(value) ? value[0] : value
formData.date = dayjs(date).format('YYYY-MM-DD')
dateSelected.value = date
showDateCalendar.value = false
}
const loadData = async () => {
if (isEdit.value && route.params.id) {
try {
const record = await inventoryApi.get(Number(route.params.id))
editingRecord.value = record
Object.assign(formData, {
date: record.date,
partnerCompany: record.partnerCompany,
businessType: record.businessType,
product: record.product,
quantity: record.quantity,
vehicleNo: record.vehicleNo || '',
planNo: record.planNo || '',
remarks: record.remarks || '',
purchasePlanId: record.purchasePlanId
})
// 初始化日期选择器的值(用于编辑时回显)
if (record.date) {
dateSelected.value = dayjs(record.date).toDate()
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
router.back()
}
}
}
const handleSubmit = async () => {
if (!formData.date || !formData.partnerCompany || !formData.product || !formData.quantity || !formData.vehicleNo) {
showToast({ type: 'fail', message: '请填写必填项' })
return
}
saving.value = true
try {
if (formData.vehicleNo?.trim()) {
await ensureVehicleExists(formData.vehicleNo.trim())
}
if (editingRecord.value) {
await inventoryApi.update(editingRecord.value.id, formData)
showToast({ type: 'success', message: '更新成功' })
} else {
await inventoryApi.create(formData)
showToast({ type: 'success', message: '创建成功' })
}
// 根据来源跳转到对应页面(兼容 from=plan / from=plans)
const from = route.query.from as string
if (from === 'plan' || from === 'plans') {
router.push('/plans')
} else {
router.push('/inventory')
}
} catch (error: any) {
showToast({ type: 'fail', message: error.message || '操作失败' })
} finally {
saving.value = false
}
}
const handleBack = () => {
// 根据来源跳转到对应页面(兼容 from=plan / from=plans)
const from = route.query.from as string
if (from === 'plan' || from === 'plans') {
router.push('/plans')
} else {
router.push('/inventory')
}
}
// 监听公司选择器弹窗,当打开时重置搜索关键词
watch(showCompanyPicker, (newVal) => {
if (newVal) {
companySearchKeyword.value = ''
}
})
watch(showCargoPicker, (newVal) => {
if (newVal) {
cargoSearchKeyword.value = ''
}
})
watch(showVehiclePicker, (newVal) => {
if (newVal) {
vehicleSearchKeyword.value = ''
loadVehicles()
}
})
onMounted(async () => {
await Promise.all([loadCompanies(), loadCargos()])
if (isEdit.value || isView.value) {
await loadData()
}
})
</script>
<style lang="scss" scoped>
@import '@/assets/styles/detail-page.scss';
.company-picker-container {
height: 100%;
display: flex;
flex-direction: column;
}
.search-container {
padding: 16px;
border-bottom: 1px solid #ebedf0;
background: #fff;
:deep(.van-search) {
padding: 0;
}
:deep(.van-search__content) {
background: #f7f8fa;
border-radius: 20px;
}
:deep(.van-field__control) {
font-size: 14px;
}
}
/* 不强制让 van-picker 占满剩余高度,使用 popup 的 max-height 控制总体高度 */
.vehicle-picker-container {
display: flex;
flex-direction: column;
max-height: 70vh;
}
.vehicle-picker-container .vehicle-list {
flex: 1;
overflow-y: auto;
}
.vehicle-picker-container .manual-input-row {
padding: 12px 16px;
border-top: 1px solid #ebedf0;
background: #fff;
}
.dialog-field {
padding: 12px 16px;
}
/* 数量为默认值 32 时红色标识,提醒用户关注 */
.quantity-default-red .form-label,
.quantity-default-red .form-label .required {
color: var(--van-danger-color, #ee0a24);
}
.quantity-default-red :deep(.van-field__control) {
color: var(--van-danger-color, #ee0a24);
font-weight: 500;
}
</style>
+314
View File
@@ -0,0 +1,314 @@
<template>
<div class="page fill-page">
<div class="page-header">
<div class="header-title">山峡出入库信息</div>
</div>
<div v-if="loading" class="loading-wrap">
<van-loading type="spinner" />
</div>
<template v-else-if="record">
<!-- 已填写完整:只读 + 二维码 -->
<div v-if="isFilled" class="readonly-section">
<div class="form-section card">
<div class="section-title">出入库单信息(已锁定)</div>
<div class="info-row"><span class="label">日期:</span><span class="value">{{ record.date }}</span></div>
<div class="info-row"><span class="label">往来单位:</span><span class="value">{{ record.partnerCompany }}</span></div>
<div class="info-row"><span class="label">业务类型:</span><span class="value">{{ record.businessType }}</span></div>
<div class="info-row"><span class="label">产品:</span><span class="value">{{ record.product }}</span></div>
<div class="info-row"><span class="label">数量(吨):</span><span class="value">{{ record.quantity }}</span></div>
<div class="info-row">
<span class="label">车牌号:</span>
<span class="value vehicle-no">{{ record.vehicleNo }}</span>
</div>
<div class="info-row" v-if="record.remarks"><span class="label">备注:</span><span class="value">{{ record.remarks }}</span></div>
</div>
<div class="qrcode-section card">
<div class="inventory-id-line">
{{ record.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ record.id }}
</div>
<div class="qrcode-wrap">
<img :src="qrcodeSrc" alt="二维码" class="qrcode-img" />
</div>
<div class="qrcode-section-desc">出入库单标识(扫码核验)</div>
</div>
</div>
<!-- 未填写完整:可编辑表单 -->
<div v-else class="form-container">
<van-form @submit="handleSubmit">
<div class="form-section card">
<div class="section-title">请完善以下信息</div>
<div class="info-row readonly-row">
<span class="label">往来单位:</span>
<span class="value">{{ record.partnerCompany }}</span>
</div>
<div class="info-row readonly-row">
<span class="label">业务类型:</span>
<span class="value">{{ record.businessType }}</span>
</div>
<div class="info-row readonly-row">
<span class="label">产品:</span>
<span class="value">{{ record.product }}</span>
</div>
<div class="form-item">
<div class="form-label">日期 <span class="required">*</span></div>
<div class="form-input" @click="showDatePicker = true">
<span :class="{ placeholder: !formData.date }">{{ formData.date || '选择日期' }}</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">数量(吨) <span class="required">*</span></div>
<van-field v-model="formData.quantity" type="number" placeholder="请输入数量(吨)" />
</div>
<div class="form-item">
<div class="form-label">车牌号 <span class="required">*</span></div>
<van-field v-model="formData.vehicleNo" placeholder="请输入车牌号" />
</div>
<div class="form-item">
<div class="form-label">备注</div>
<van-field
:model-value="formData.remarks ?? ''"
@update:model-value="(v: string) => { formData.remarks = v }"
type="textarea"
autosize
rows="2"
placeholder="选填"
/>
</div>
</div>
<div class="form-actions">
<van-button block type="primary" native-type="submit" :loading="saving">保存</van-button>
</div>
</van-form>
</div>
</template>
<van-calendar v-model:show="showDatePicker" @confirm="onDateConfirm" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { showToast } from 'vant'
import { inventoryApi, type InventoryRecord, type InventoryUpdate } from '@/api/inventoryApi'
import { vehicleApi } from '@/api/masterDataApi'
import dayjs from 'dayjs'
const route = useRoute()
const loading = ref(true)
const saving = ref(false)
const record = ref<InventoryRecord | null>(null)
const showDatePicker = ref(false)
const formData = reactive<InventoryUpdate>({
date: '',
quantity: undefined,
vehicleNo: '',
remarks: ''
})
const isFilled = computed(() => {
if (!record.value) return false
const r = record.value
const hasDate = !!(r.date && r.date.trim())
const hasQty = r.quantity != null && r.quantity !== 0
const hasVehicle = !!(r.vehicleNo && r.vehicleNo.trim())
return hasDate && hasQty && hasVehicle
})
const qrcodeSrc = computed(() => {
if (!record.value) return ''
const id = String(record.value.id)
return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(id)}`
})
const loadData = async () => {
const id = route.params.id
if (!id) return
loading.value = true
try {
record.value = await inventoryApi.get(Number(id))
formData.date = record.value.date || ''
formData.quantity = record.value.quantity
formData.vehicleNo = record.value.vehicleNo || ''
formData.remarks = record.value.remarks || ''
} catch (e) {
showToast({ type: 'fail', message: '加载失败' })
} finally {
loading.value = false
}
}
const onDateConfirm = (value: Date | Date[]) => {
const d = Array.isArray(value) ? value[0] : value
formData.date = dayjs(d).format('YYYY-MM-DD')
showDatePicker.value = false
}
// 保存前确保车牌号已写入车牌号表(新车牌则先创建,与 Web 行为一致)
const ensureVehicleExists = async (vehicleNo: string) => {
if (!vehicleNo?.trim()) return
try {
await vehicleApi.create({ vehicleNo: vehicleNo.trim(), remarks: null })
} catch (err: any) {
if (err.response?.status !== 409) throw err
}
}
const handleSubmit = async () => {
const date = (formData.date || '').trim()
const qty = formData.quantity
const vehicle = (formData.vehicleNo || '').trim()
if (!date || (qty == null || qty === 0) || !vehicle) {
showToast({ type: 'fail', message: '请填写日期、数量、车牌号' })
return
}
if (!record.value) return
saving.value = true
try {
await ensureVehicleExists(vehicle)
await inventoryApi.update(record.value.id, {
date,
quantity: Number(qty),
vehicleNo: vehicle,
// 备注清空时传 null,否则后端不会更新该字段
remarks: formData.remarks === '' ? null : (formData.remarks || undefined)
})
showToast({ type: 'success', message: '保存成功' })
await loadData()
} catch (e: any) {
showToast({ type: 'fail', message: e?.response?.data?.detail || e?.message || '保存失败' })
} finally {
saving.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.fill-page {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 24px;
}
.page-header {
background: #fff;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
text-align: center;
.header-title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
}
.loading-wrap {
padding: 48px;
display: flex;
justify-content: center;
}
.card {
background: #fff;
border-radius: 12px;
margin: 16px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.info-row {
display: flex;
align-items: center;
margin-bottom: 10px;
font-size: 14px;
.label {
color: #6b7280;
min-width: 90px;
}
.value {
color: #111827;
flex: 1;
}
&.readonly-row {
padding: 4px 0;
}
}
.form-item {
margin-bottom: 12px;
.form-label {
font-size: 14px;
color: #374151;
margin-bottom: 6px;
.required {
color: #ef4444;
}
}
.form-input {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: #111827;
display: flex;
align-items: center;
justify-content: space-between;
.placeholder {
color: #9ca3af;
}
.arrow-icon {
color: #9ca3af;
}
}
}
.form-actions {
padding: 16px;
}
.qrcode-section {
text-align: center;
.inventory-id-line {
font-size: 20px;
font-weight: 700;
color: #111827;
margin-bottom: 12px;
}
.qrcode-wrap {
padding: 16px 0;
}
.qrcode-img {
width: 200px;
height: 200px;
display: inline-block;
}
.qrcode-section-desc {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-top: 8px;
}
}
.info-row .value.vehicle-no {
font-weight: 700;
color: #ef4444;
}
</style>
File diff suppressed because it is too large. Load diff
+791
View File
@@ -0,0 +1,791 @@
<template>
<div class="page">
<div class="header">
<div class="header-left">
<div class="header-title">计划出入库填写</div>
</div>
</div>
<div v-if="loading" class="loading-wrap">
<van-loading type="spinner" />
</div>
<template v-else>
<div v-if="errorMessage" class="error-wrap">
<van-empty :description="errorMessage" />
</div>
<div v-else-if="pageData" class="content">
<div class="card plan-card">
<div class="section-title">计划信息</div>
<div class="info-row">
<span class="label">往来单位:</span>
<span class="value">{{ pageData.plan.customerName || '未填' }}</span>
</div>
<div class="info-row">
<span class="label">产品:</span>
<span class="value">{{ pageData.plan.product }}</span>
</div>
<div class="info-row">
<span class="label">起始日期:</span>
<span class="value">{{ pageData.plan.startDate || '--' }}</span>
</div>
<div class="info-row">
<span class="label">截止日期:</span>
<span class="value">{{ pageData.plan.endDate || '--' }}</span>
</div>
</div>
<div class="card list-card">
<div class="list-header">
<div class="section-title">今日出入库单</div>
<van-button
size="small"
type="primary"
@click="openCreateDialog"
>
新增
</van-button>
</div>
<div v-if="pageData.today_inventories.length === 0" class="empty">
<van-empty description="今日暂无出入库单" />
</div>
<div v-else class="list">
<div
v-for="item in pageData.today_inventories"
:key="item.id"
class="inv-card"
>
<div class="inv-main">
<div class="inv-line">
<span class="label">单号:</span>
<span class="value">{{ item.batchNo || '未填单号' }}</span>
</div>
<div class="inv-line">
<span class="label">日期:</span>
<span class="value">{{ item.date || '--' }}</span>
</div>
<div class="inv-line">
<span class="label">车牌号:</span>
<span class="value">{{ item.vehicleNo || '--' }}</span>
</div>
<div class="inv-line">
<span class="label">数量(吨):</span>
<span class="value">{{ item.quantity }}</span>
</div>
</div>
<div class="inv-actions">
<van-button
size="small"
type="primary"
plain
@click="showQr(item)"
>
查看二维码
</van-button>
<van-button
size="small"
type="primary"
plain
@click="handleCopyFromList(item)"
>
复制图片到剪贴板
</van-button>
</div>
</div>
</div>
</div>
<div class="card list-card" v-if="pageData.month_inventories && pageData.month_inventories.length">
<div class="list-header">
<div class="section-title">本月历史出入库单</div>
</div>
<div class="list">
<div
v-for="item in pageData.month_inventories"
:key="item.id"
class="inv-card"
>
<div class="inv-main">
<div class="inv-line">
<span class="label">单号:</span>
<span class="value">{{ item.batchNo || '未填单号' }}</span>
</div>
<div class="inv-line">
<span class="label">日期:</span>
<span class="value">{{ item.date || '--' }}</span>
</div>
<div class="inv-line">
<span class="label">车牌号:</span>
<span class="value">{{ item.vehicleNo || '--' }}</span>
</div>
<div class="inv-line">
<span class="label">数量(吨):</span>
<span class="value">{{ item.quantity }}</span>
</div>
</div>
<div class="inv-actions">
<van-button
size="small"
type="primary"
plain
@click="showQr(item)"
>
查看二维码
</van-button>
<van-button
size="small"
type="primary"
plain
@click="handleCopyFromList(item)"
>
复制图片到剪贴板
</van-button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 新增出入库单弹窗 -->
<van-dialog
v-model:show="createDialogOpen"
title="新增出入库单"
show-cancel-button
cancel-button-text="取消"
confirm-button-text="保存"
:show-confirm-button="false"
class="create-dialog"
>
<div class="dialog-body">
<van-form @submit="handleCreateSubmit">
<div class="form-item">
<div class="form-label">日期</div>
<div class="form-value readonly">{{ todayStr }}</div>
</div>
<div class="form-item">
<div class="form-label">数量(吨) *</div>
<van-field
v-model.number="createForm.quantity"
type="number"
placeholder="请输入数量(吨)"
/>
</div>
<div class="form-item">
<div class="form-label">车牌号 *</div>
<van-field
v-model="createForm.vehicleNo"
placeholder="请输入车牌号"
/>
</div>
<div class="form-item">
<div class="form-label">备注</div>
<van-field
v-model="createForm.remarks"
type="textarea"
rows="2"
autosize
placeholder="选填"
/>
</div>
<div class="dialog-actions">
<van-button
block
type="primary"
native-type="submit"
:loading="creating"
>
保存
</van-button>
</div>
</van-form>
</div>
</van-dialog>
<!-- 查看二维码弹窗:风格与「出入库管理」的磅单二维码一致 -->
<van-dialog
v-model:show="qrDialogOpen"
title="出入库单二维码"
show-cancel-button
cancel-button-text="关闭"
confirm-button-text=""
:show-confirm-button="false"
class="qr-dialog"
>
<div
v-if="qrInventory"
ref="qrCaptureRef"
class="qr-dialog-body"
>
<div class="capture-header">山峡出入库信息</div>
<div class="capture-card">
<div class="capture-title">出入库单信息(已锁定)</div>
<div class="capture-row"><span class="label">日期:</span><span class="value">{{ qrInventory.date || '--' }}</span></div>
<div class="capture-row"><span class="label">往来单位:</span><span class="value">{{ qrInventory.partnerCompany || '--' }}</span></div>
<div class="capture-row"><span class="label">业务类型:</span><span class="value">{{ qrInventory.businessType }}</span></div>
<div class="capture-row"><span class="label">产品:</span><span class="value">{{ qrInventory.product }}</span></div>
<div class="capture-row"><span class="label">数量(吨):</span><span class="value">{{ qrInventory.quantity }}</span></div>
<div class="capture-row"><span class="label">车牌号:</span><span class="value vehicle-no">{{ qrInventory.vehicleNo || '--' }}</span></div>
<div v-if="qrInventory.batchNo" class="capture-row"><span class="label">单号:</span><span class="value">{{ qrInventory.batchNo }}</span></div>
<div v-if="qrInventory.remarks" class="capture-row"><span class="label">备注:</span><span class="value">{{ qrInventory.remarks }}</span></div>
</div>
<div class="capture-card capture-qrcode">
<div class="capture-id">{{ qrInventory.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ qrInventory.id }}</div>
<div class="capture-qr-wrap">
<img :src="qrImageSrc" alt="二维码" class="capture-qr-img" />
</div>
<div class="capture-desc">出入库单标识(扫码核验)</div>
</div>
</div>
</van-dialog>
<!-- 隐藏的截图区域:用于列表中的「复制图片到剪贴板」 -->
<div v-if="captureInventory" ref="captureRef" class="pound-slip-capture">
<div class="capture-header">山峡出入库信息</div>
<div class="capture-card">
<div class="capture-title">出入库单信息(已锁定)</div>
<div class="capture-row"><span class="label">日期:</span><span class="value">{{ captureInventory.date || '--' }}</span></div>
<div class="capture-row"><span class="label">往来单位:</span><span class="value">{{ captureInventory.partnerCompany || '--' }}</span></div>
<div class="capture-row"><span class="label">业务类型:</span><span class="value">{{ captureInventory.businessType }}</span></div>
<div class="capture-row"><span class="label">产品:</span><span class="value">{{ captureInventory.product }}</span></div>
<div class="capture-row"><span class="label">数量(吨):</span><span class="value">{{ captureInventory.quantity }}</span></div>
<div class="capture-row"><span class="label">车牌号:</span><span class="value vehicle-no">{{ captureInventory.vehicleNo || '--' }}</span></div>
<div v-if="captureInventory.batchNo" class="capture-row"><span class="label">单号:</span><span class="value">{{ captureInventory.batchNo }}</span></div>
<div v-if="captureInventory.remarks" class="capture-row"><span class="label">备注:</span><span class="value">{{ captureInventory.remarks }}</span></div>
</div>
<div class="capture-card capture-qrcode">
<div class="capture-id">{{ captureInventory.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ captureInventory.id }}</div>
<div class="capture-qr-wrap">
<img :src="getQrImageUrl(captureInventory.id)" alt="二维码" class="capture-qr-img" crossorigin="anonymous" />
</div>
<div class="capture-desc">出入库单标识(扫码核验)</div>
</div>
</div>
<!-- 全屏生成图片 Loading 遮罩 -->
<van-overlay :show="copyingImage" class="copy-overlay">
<div class="copy-overlay__content">
<van-loading type="spinner" color="#fff" />
<div class="copy-overlay__text">图片生成中...</div>
</div>
</van-overlay>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import dayjs from 'dayjs'
import { showToast } from 'vant'
import { planApi, type ThirdPartyPlanPageData } from '@/api/plansApi'
import { inventoryApi, type InventoryCreate, type InventoryRecord } from '@/api/inventoryApi'
import { copyImageBlobToClipboard } from '@/utils/utils'
import html2canvas from 'html2canvas'
const route = useRoute()
const loading = ref(true)
const errorMessage = ref('')
const pageData = ref<ThirdPartyPlanPageData | null>(null)
const todayStr = dayjs().format('YYYY-MM-DD')
const createDialogOpen = ref(false)
const creating = ref(false)
const createForm = reactive<Pick<InventoryCreate, 'quantity' | 'vehicleNo' | 'remarks'>>({
quantity: 0,
vehicleNo: '',
remarks: ''
})
const qrDialogOpen = ref(false)
const qrInventory = ref<InventoryRecord | null>(null)
const qrCaptureRef = ref<HTMLElement | null>(null)
// 生成图片时的全屏遮罩
const copyingImage = ref(false)
// 列表复制使用的隐藏截图区域
const captureInventory = ref<InventoryRecord | null>(null)
const captureRef = ref<HTMLElement | null>(null)
const getQrImageUrl = (inventoryId: number) =>
`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(
String(inventoryId)
)}`
const qrImageSrc = computed(() => {
if (!qrInventory.value) return ''
return getQrImageUrl(qrInventory.value.id)
})
const loadPage = async () => {
const id = Number(route.params.id)
if (!id) {
errorMessage.value = '链接无效:缺少计划ID'
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
pageData.value = await planApi.getThirdPartyPage(id)
} catch (e: any) {
errorMessage.value = e?.response?.data?.detail || e?.message || '加载失败'
} finally {
loading.value = false
}
}
const openCreateDialog = () => {
if (!pageData.value) return
createForm.quantity = 0
createForm.vehicleNo = ''
createForm.remarks = ''
createDialogOpen.value = true
}
const handleCreateSubmit = async () => {
if (!pageData.value) return
const qty = createForm.quantity
const vehicle = (createForm.vehicleNo || '').trim()
if (!qty || qty === 0 || !vehicle) {
showToast({ type: 'fail', message: '请填写数量和车牌号' })
return
}
creating.value = true
try {
const p = pageData.value.plan
await inventoryApi.create({
date: todayStr,
partnerCompany: p.customerName || '待填写',
businessType: p.businessType,
product: p.product,
quantity: Number(qty),
planNo: p.planNo,
vehicleNo: vehicle,
remarks: createForm.remarks || undefined,
purchasePlanId: p.id
})
showToast({ type: 'success', message: '创建成功' })
createDialogOpen.value = false
await loadPage()
} catch (e: any) {
showToast({ type: 'fail', message: e?.response?.data?.detail || e?.message || '创建失败' })
} finally {
creating.value = false
}
}
const showQr = (item: InventoryRecord) => {
qrInventory.value = item
qrDialogOpen.value = true
}
const handleCopyFromList = async (item: InventoryRecord) => {
captureInventory.value = item
try {
copyingImage.value = true
await nextTick()
const el = captureRef.value
if (!el) throw new Error('未找到截图区域')
const img = el.querySelector('.capture-qr-img') as HTMLImageElement | null
const waitForImg = () =>
new Promise<void>((resolve) => {
if (!img) {
resolve()
return
}
if (img.complete) resolve()
else img.onload = () => resolve()
})
await waitForImg()
const canvas = await html2canvas(el, {
useCORS: true,
scale: 2,
backgroundColor: '#f5f7fa'
})
const outBlob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/png', 1)
)
if (outBlob) {
const ok = await copyImageBlobToClipboard(outBlob)
if (ok) {
showToast({ type: 'success', message: '图片已复制到剪贴板' })
} else {
showToast({ type: 'fail', message: '当前环境不支持复制图片,请在 HTTPS 环境下使用' })
}
} else {
showToast({ type: 'fail', message: '生成图片失败' })
}
} catch (e: any) {
showToast({ type: 'fail', message: e?.message || '复制失败' })
} finally {
captureInventory.value = null
copyingImage.value = false
}
}
onMounted(() => {
loadPage()
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 24px;
}
.header {
background: #fff;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e5e7eb;
&-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
&-title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
}
.loading-wrap {
padding: 48px;
display: flex;
justify-content: center;
}
.error-wrap {
padding: 40px 16px;
}
.content {
padding: 16px;
}
.card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.info-row {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
.label {
color: #6b7280;
min-width: 90px;
}
.value {
color: #111827;
flex: 1;
}
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.list {
display: flex;
flex-direction: column;
gap: 12px;
}
.inv-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.inv-main {
flex: 1;
}
.inv-line {
display: flex;
align-items: center;
font-size: 13px;
margin-bottom: 4px;
.label {
color: #6b7280;
min-width: 80px;
}
.value {
color: #111827;
flex: 1;
}
}
.inv-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.empty {
padding-top: 8px;
}
.create-dialog .dialog-body {
padding: 16px;
}
.form-item {
margin-bottom: 12px;
.form-label {
font-size: 14px;
color: #374151;
margin-bottom: 6px;
}
.form-value.readonly {
font-size: 14px;
color: #111827;
padding: 8px 12px;
border-radius: 8px;
background: #f9fafb;
border: 1px solid #e5e7eb;
}
}
.dialog-actions {
margin-top: 8px;
}
/* 查看二维码弹窗:与「出入库管理」磅单二维码风格一致 */
.qr-dialog-body {
padding: 16px;
background: #f5f7fa;
max-height: 70vh;
overflow-y: auto;
}
.qr-dialog-body .capture-header {
font-size: 18px;
font-weight: 600;
color: #111827;
padding: 16px;
background: #fff;
text-align: center;
border-radius: 12px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.qr-dialog-body .capture-card {
background: #fff;
border-radius: 12px;
margin-bottom: 16px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.qr-dialog-body .capture-title {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.qr-dialog-body .capture-row {
display: flex;
align-items: center;
margin-bottom: 10px;
font-size: 14px;
}
.qr-dialog-body .capture-row .label {
color: #6b7280;
min-width: 90px;
}
.qr-dialog-body .capture-row .value {
color: #111827;
flex: 1;
}
.qr-dialog-body .capture-row .value.vehicle-no {
font-weight: 700;
color: #ef4444;
}
.qr-dialog-body .capture-qrcode {
text-align: center;
}
.qr-dialog-body .capture-id {
font-size: 20px;
font-weight: 700;
color: #111827;
margin-bottom: 12px;
}
.qr-dialog-body .capture-qr-wrap {
padding: 16px 0;
min-height: 200px;
}
.qr-dialog-body .capture-qr-img {
width: 200px;
height: 200px;
display: inline-block;
}
.qr-dialog-body .capture-desc {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-top: 8px;
}
/* 列表复制使用的隐藏截图区域(与弹窗样式一致,置于屏外) */
.pound-slip-capture {
position: fixed;
left: -9999px;
top: 0;
width: 360px;
background: #f5f7fa;
padding: 16px;
font-family: inherit;
}
.pound-slip-capture .capture-header {
font-size: 18px;
font-weight: 600;
color: #111827;
padding: 16px;
background: #fff;
text-align: center;
border-radius: 12px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.pound-slip-capture .capture-card {
background: #fff;
border-radius: 12px;
margin-bottom: 16px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.pound-slip-capture .capture-title {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.pound-slip-capture .capture-row {
display: flex;
align-items: center;
margin-bottom: 10px;
font-size: 14px;
}
.pound-slip-capture .capture-row .label {
color: #6b7280;
min-width: 90px;
}
.pound-slip-capture .capture-row .value {
color: #111827;
flex: 1;
}
.pound-slip-capture .capture-row .value.vehicle-no {
font-weight: 700;
color: #ef4444;
}
.pound-slip-capture .capture-qrcode {
text-align: center;
}
.pound-slip-capture .capture-id {
font-size: 20px;
font-weight: 700;
color: #111827;
margin-bottom: 12px;
}
.pound-slip-capture .capture-qr-wrap {
padding: 16px 0;
min-height: 200px;
}
.pound-slip-capture .capture-qr-img {
width: 200px;
height: 200px;
display: inline-block;
}
.pound-slip-capture .capture-desc {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-top: 8px;
}
/* 全屏复制图片 Loading 遮罩 */
.copy-overlay {
z-index: 2000;
}
.copy-overlay__content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #fff;
}
.copy-overlay__text {
font-size: 14px;
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<template>
<div class="login-page">
<div class="login-header">
<div class="title">山峡化工地磅系统</div>
</div>
<div class="login-form-container">
<van-form @submit="handleSubmit">
<van-cell-group inset>
<van-field
v-model="formData.username"
name="username"
label="用户名"
placeholder="请输入用户名"
:rules="[{ required: true, message: '请输入用户名' }]"
/>
<van-field
v-model="formData.password"
type="password"
name="password"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '请输入密码' }]"
/>
</van-cell-group>
<div class="login-actions">
<van-button
round
block
type="primary"
native-type="submit"
:loading="loading"
class="login-button"
>
登录
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { useUserStore } from '@/store/modules/user'
import { UserService } from '@/api/usersApi'
import { ApiStatus } from '@/utils/http/status'
const router = useRouter()
const userStore = useUserStore()
const formData = reactive({
username: '',
password: ''
})
const loading = ref(false)
const handleSubmit = async () => {
if (!formData.username || !formData.password) {
showToast({
type: 'fail',
message: '请输入用户名和密码'
})
return
}
loading.value = true
try {
const res = await UserService.login(formData.username, formData.password)
if (res.code === ApiStatus.success && res.data) {
userStore.setLoginStatus(true)
// 使用 setToken 方法保存 token
if (res.data.accessToken) {
userStore.setToken(res.data.accessToken, res.data.refreshToken)
}
// 设置用户信息
if (res.data.user) {
userStore.setUserInfo(res.data.user)
}
showToast({
type: 'success',
message: '登录成功'
})
router.push('/dashboard')
} else {
showToast({
type: 'fail',
message: res.message || '登录失败'
})
}
} catch (error: any) {
console.error('Login error:', error)
showToast({
type: 'fail',
message: error.message || '登录失败,请检查网络连接'
})
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background-image: url('@/assets/img/sx1.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
padding: 40px 24px 24px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 0;
}
}
.login-header {
text-align: center;
margin-bottom: 30px;
position: relative;
z-index: 1;
/* 保持与有 logo 时相同的顶部距离:
之前 logo 高度 80px + logo 底部间距 24px = 104px */
padding-top: 104px;
}
.title {
font-size: 28px;
font-weight: 700;
color: #fff;
margin-bottom: 12px;
}
.login-form-container {
width: 100%;
max-width: 400px;
position: relative;
z-index: 1;
padding: 40px 24px;
background: rgba(255, 255, 255, 0.95);
border-radius: 24px;
backdrop-filter: blur(10px);
}
.login-actions {
margin-top: 32px;
}
.login-button {
height: 48px;
font-size: 16px;
font-weight: 600;
}
// 减小 label 宽度
:deep(.van-field__label) {
width: 60px !important;
min-width: 60px !important;
}
</style>
@@ -0,0 +1,129 @@
<template>
<div class="page-content">
<div class="page-header">
<div class="page-header__back" @click="handleBack">
<div class="back-arrow"></div>
</div>
<div class="page-header__title">{{ isEdit ? '编辑货物' : isView ? '查看货物' : '新增货物' }}</div>
</div>
<div class="form-container">
<van-form @submit="handleSubmit">
<div class="form-section">
<div class="form-item">
<div class="form-label">货物名称 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.name || '--' }}
</div>
<van-field
v-else
v-model="formData.name"
placeholder="请输入货物名称"
/>
</div>
<div class="form-item">
<div class="form-label">备注</div>
<div v-if="isView" class="form-input">
{{ formData.remarks || '--' }}
</div>
<van-field
v-else
:model-value="formData.remarks || ''"
@update:model-value="formData.remarks = $event || null"
type="textarea"
autosize
rows="3"
placeholder="请输入备注"
/>
</div>
</div>
<div v-if="!isView" class="form-actions">
<van-button block type="primary" native-type="submit" :loading="saving">
保存
</van-button>
<van-button block plain @click="handleBack">
取消
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { cargoApi, type Cargo, type CargoCreate } from '@/api/masterDataApi'
const router = useRouter()
const route = useRoute()
const isView = computed(() => route.query.mode === 'view')
const isEdit = computed(() => route.query.mode === 'edit' || route.params.id)
// const isAdd = computed(() => !isView.value && !isEdit.value) // 未使用
const saving = ref(false)
const editingCargo = ref<Cargo | null>(null)
const formData = reactive<CargoCreate>({
name: '',
remarks: null
})
const loadData = async () => {
if (isEdit.value && route.params.id) {
try {
const cargo = await cargoApi.get(Number(route.params.id))
editingCargo.value = cargo
Object.assign(formData, {
name: cargo.name,
remarks: cargo.remarks || ''
})
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
router.back()
}
}
}
const handleSubmit = async () => {
if (!formData.name) {
showToast({ type: 'fail', message: '请填写货物名称' })
return
}
saving.value = true
try {
if (editingCargo.value) {
await cargoApi.update(editingCargo.value.id, formData)
showToast({ type: 'success', message: '更新成功' })
} else {
await cargoApi.create(formData)
showToast({ type: 'success', message: '创建成功' })
}
router.back()
} catch (error: any) {
showToast({ type: 'fail', message: error.message || '操作失败' })
} finally {
saving.value = false
}
}
const handleBack = () => {
router.back()
}
onMounted(() => {
if (isEdit.value || isView.value) {
loadData()
}
})
</script>
<style lang="scss" scoped>
@import '@/assets/styles/detail-page.scss';
</style>
@@ -0,0 +1,129 @@
<template>
<div class="page-content">
<div class="page-header">
<div class="page-header__back" @click="handleBack">
<div class="back-arrow"></div>
</div>
<div class="page-header__title">{{ isEdit ? '编辑公司' : isView ? '查看公司' : '新增公司' }}</div>
</div>
<div class="form-container">
<van-form @submit="handleSubmit">
<div class="form-section">
<div class="form-item">
<div class="form-label">公司名称 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.name || '--' }}
</div>
<van-field
v-else
v-model="formData.name"
placeholder="请输入公司名称"
/>
</div>
<div class="form-item">
<div class="form-label">备注</div>
<div v-if="isView" class="form-input">
{{ formData.remarks || '--' }}
</div>
<van-field
v-else
:model-value="formData.remarks || ''"
@update:model-value="formData.remarks = $event || null"
type="textarea"
autosize
rows="3"
placeholder="请输入备注"
/>
</div>
</div>
<div v-if="!isView" class="form-actions">
<van-button block type="primary" native-type="submit" :loading="saving">
保存
</van-button>
<van-button block plain @click="handleBack">
取消
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { companyApi, type Company, type CompanyCreate } from '@/api/masterDataApi'
const router = useRouter()
const route = useRoute()
const isView = computed(() => route.query.mode === 'view')
const isEdit = computed(() => route.query.mode === 'edit' || route.params.id)
// const isAdd = computed(() => !isView.value && !isEdit.value) // 未使用
const saving = ref(false)
const editingCompany = ref<Company | null>(null)
const formData = reactive<CompanyCreate>({
name: '',
remarks: null
})
const loadData = async () => {
if (isEdit.value && route.params.id) {
try {
const company = await companyApi.get(Number(route.params.id))
editingCompany.value = company
Object.assign(formData, {
name: company.name,
remarks: company.remarks || ''
})
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
router.back()
}
}
}
const handleSubmit = async () => {
if (!formData.name) {
showToast({ type: 'fail', message: '请填写公司名称' })
return
}
saving.value = true
try {
if (editingCompany.value) {
await companyApi.update(editingCompany.value.id, formData)
showToast({ type: 'success', message: '更新成功' })
} else {
await companyApi.create(formData)
showToast({ type: 'success', message: '创建成功' })
}
router.back()
} catch (error: any) {
showToast({ type: 'fail', message: error.message || '操作失败' })
} finally {
saving.value = false
}
}
const handleBack = () => {
router.back()
}
onMounted(() => {
if (isEdit.value || isView.value) {
loadData()
}
})
</script>
<style lang="scss" scoped>
@import '@/assets/styles/detail-page.scss';
</style>
+609
View File
@@ -0,0 +1,609 @@
<template>
<div class="page">
<div class="header">
<div class="header-left">
<van-icon name="arrow-left" class="back-icon" @click="handleBack" />
<div class="header-title">基础资料</div>
</div>
</div>
<van-tabs v-model:active="activeTab" @change="handleTabChange">
<!-- 往来单位 -->
<van-tab title="往来单位" name="company">
<div class="toolbar">
<van-search
v-model="companySearchKeyword"
placeholder="搜索公司名称 / 备注"
@search="handleCompanySearch"
@clear="handleCompanySearch"
class="search-input"
/>
<van-button type="primary" size="small" class="search-button" @click="handleCompanySearch">搜索</van-button>
</div>
<van-list
v-model:loading="companyLoading"
:finished="companyFinished"
finished-text="没有更多了"
@load="onCompanyLoad"
>
<div v-if="!companyLoading && companyData.length === 0" class="empty">
<van-empty description="暂无数据" />
</div>
<div v-else class="list">
<div
v-for="item in companyData"
:key="item.id"
class="card"
>
<div class="card-body">
<div class="info-row">
<span class="label">公司名称:</span>
<span class="value">{{ item.name }}</span>
</div>
<div class="info-row" v-if="item.remarks">
<span class="label">备注:</span>
<span class="value">{{ item.remarks }}</span>
</div>
</div>
<div class="card-footer">
<van-button size="small" type="primary" plain @click="handleCompanyEdit(item)">编辑</van-button>
<van-button size="small" type="danger" plain @click="handleCompanyDelete(item.id)">删除</van-button>
</div>
</div>
</div>
</van-list>
</van-tab>
<!-- 车牌号 -->
<van-tab title="车牌号" name="vehicle">
<div class="toolbar">
<van-search
v-model="vehicleSearchKeyword"
placeholder="搜索车牌号 / 备注"
@search="handleVehicleSearch"
@clear="handleVehicleSearch"
class="search-input"
/>
<van-button type="primary" size="small" class="search-button" @click="handleVehicleSearch">搜索</van-button>
</div>
<van-list
v-model:loading="vehicleLoading"
:finished="vehicleFinished"
finished-text="没有更多了"
@load="onVehicleLoad"
>
<div v-if="!vehicleLoading && vehicleDisplayList.length === 0" class="empty">
<van-empty description="暂无数据" />
</div>
<div v-else class="list">
<div
v-for="item in vehicleDisplayList"
:key="item.id"
class="card"
>
<div class="card-body">
<div class="info-row">
<span class="label">车牌号:</span>
<span class="value">{{ item.vehicleNo }}</span>
</div>
<div class="info-row" v-if="item.remarks">
<span class="label">备注:</span>
<span class="value">{{ item.remarks }}</span>
</div>
</div>
<div class="card-footer">
<van-button size="small" type="primary" plain @click="handleVehicleEdit(item)">编辑</van-button>
<van-button size="small" type="danger" plain @click="handleVehicleDelete(item.id)">删除</van-button>
</div>
</div>
</div>
</van-list>
</van-tab>
<!-- 货物 -->
<van-tab title="货物" name="cargo">
<div class="toolbar">
<van-search
v-model="cargoSearchKeyword"
placeholder="搜索货物名称 / 备注"
@search="handleCargoSearch"
@clear="handleCargoSearch"
class="search-input"
/>
<van-button type="primary" size="small" class="search-button" @click="handleCargoSearch">搜索</van-button>
</div>
<van-list
v-model:loading="cargoLoading"
:finished="cargoFinished"
finished-text="没有更多了"
@load="onCargoLoad"
>
<div v-if="!cargoLoading && cargoData.length === 0" class="empty">
<van-empty description="暂无数据" />
</div>
<div v-else class="list">
<div
v-for="item in cargoData"
:key="item.id"
class="card"
>
<div class="card-body">
<div class="info-row">
<span class="label">货物名称:</span>
<span class="value">{{ item.name }}</span>
</div>
<div class="info-row" v-if="item.remarks">
<span class="label">备注:</span>
<span class="value">{{ item.remarks }}</span>
</div>
</div>
<div class="card-footer">
<van-button size="small" type="primary" plain @click="handleCargoEdit(item)">编辑</van-button>
<van-button size="small" type="danger" plain @click="handleCargoDelete(item.id)">删除</van-button>
</div>
</div>
</div>
</van-list>
</van-tab>
</van-tabs>
<!-- 浮动新增按钮 -->
<div class="fab-button" @click="handleFabAdd">
<van-icon name="plus" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { companyApi, cargoApi, vehicleApi, type Company, type Cargo, type Vehicle } from '@/api/masterDataApi'
const router = useRouter()
const activeTab = ref('company')
const companyLoading = ref(false)
const companyFinished = ref(false)
const companyData = ref<Company[]>([])
const companySearchKeyword = ref('')
const cargoLoading = ref(false)
const cargoFinished = ref(false)
const cargoData = ref<Cargo[]>([])
const cargoSearchKeyword = ref('')
const companyAllData = ref<Company[]>([])
const companyCurrentPage = ref(1)
const companyPageSize = ref(20)
const cargoAllData = ref<Cargo[]>([])
const cargoCurrentPage = ref(1)
const cargoPageSize = ref(20)
const vehicleLoading = ref(false)
const vehicleFinished = ref(false)
const vehicleSearchKeyword = ref('')
const vehicleAllData = ref<Vehicle[]>([])
const vehicleCurrentPage = ref(1)
const vehiclePageSize = ref(20)
const vehicleDisplayList = ref<Vehicle[]>([])
const loadCompanyData = async (reset = false) => {
if (reset) {
companyCurrentPage.value = 1
companyAllData.value = []
companyData.value = []
companyFinished.value = false
}
companyLoading.value = true
try {
if (reset || companyCurrentPage.value === 1) {
const companies = await companyApi.list(companySearchKeyword.value || undefined)
companyAllData.value = companies
}
const start = (companyCurrentPage.value - 1) * companyPageSize.value
const end = start + companyPageSize.value
const newData = companyAllData.value.slice(start, end)
if (reset || companyCurrentPage.value === 1) {
companyData.value = newData
} else {
companyData.value.push(...newData)
}
if (companyData.value.length >= companyAllData.value.length) {
companyFinished.value = true
} else {
companyCurrentPage.value++
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
companyFinished.value = true
} finally {
companyLoading.value = false
}
}
const loadCargoData = async (reset = false) => {
if (reset) {
cargoCurrentPage.value = 1
cargoAllData.value = []
cargoData.value = []
cargoFinished.value = false
}
cargoLoading.value = true
try {
if (reset || cargoCurrentPage.value === 1) {
const cargos = await cargoApi.list(cargoSearchKeyword.value || undefined)
cargoAllData.value = cargos
}
const start = (cargoCurrentPage.value - 1) * cargoPageSize.value
const end = start + cargoPageSize.value
const newData = cargoAllData.value.slice(start, end)
if (reset || cargoCurrentPage.value === 1) {
cargoData.value = newData
} else {
cargoData.value.push(...newData)
}
if (cargoData.value.length >= cargoAllData.value.length) {
cargoFinished.value = true
} else {
cargoCurrentPage.value++
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
cargoFinished.value = true
} finally {
cargoLoading.value = false
}
}
const onCompanyLoad = () => {
if (!companyFinished.value) {
loadCompanyData()
}
}
const onCargoLoad = () => {
if (!cargoFinished.value) {
loadCargoData()
}
}
const loadVehicleData = async (reset = false) => {
if (reset) {
vehicleCurrentPage.value = 1
vehicleAllData.value = []
vehicleDisplayList.value = []
vehicleFinished.value = false
}
vehicleLoading.value = true
try {
if (reset || vehicleCurrentPage.value === 1) {
const vehicles = await vehicleApi.list(vehicleSearchKeyword.value || undefined)
vehicleAllData.value = vehicles
}
const start = (vehicleCurrentPage.value - 1) * vehiclePageSize.value
const end = start + vehiclePageSize.value
const newData = vehicleAllData.value.slice(start, end)
if (reset || vehicleCurrentPage.value === 1) {
vehicleDisplayList.value = newData
} else {
vehicleDisplayList.value.push(...newData)
}
if (vehicleDisplayList.value.length >= vehicleAllData.value.length) {
vehicleFinished.value = true
} else {
vehicleCurrentPage.value++
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
vehicleFinished.value = true
} finally {
vehicleLoading.value = false
}
}
const onVehicleLoad = () => {
if (!vehicleFinished.value) {
loadVehicleData()
}
}
const handleTabChange = (name: string) => {
if (name === 'company') {
loadCompanyData(true)
} else if (name === 'cargo') {
loadCargoData(true)
} else {
loadVehicleData(true)
}
}
const handleCompanySearch = () => {
loadCompanyData(true) // 重置并重新加载
}
const handleCargoSearch = () => {
loadCargoData(true) // 重置并重新加载
}
const handleVehicleSearch = () => {
loadVehicleData(true)
}
const handleFabAdd = () => {
if (activeTab.value === 'company') handleCompanyAdd()
else if (activeTab.value === 'cargo') handleCargoAdd()
else handleVehicleAdd()
}
const handleBack = () => {
router.push('/dashboard')
}
const handleCompanyAdd = () => {
router.push('/master-data/company/detail')
}
const handleCompanyEdit = (item: Company) => {
router.push(`/master-data/company/detail/${item.id}?mode=edit`)
}
const handleCompanyDelete = (id: number) => {
showDialog({
title: '确认删除',
message: '确定要删除这个公司吗?',
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonText: '确认'
})
.then(async () => {
await companyApi.delete(id)
showToast({ type: 'success', message: '删除成功' })
loadCompanyData(true) // 重新加载数据
})
.catch(() => {})
}
const handleCargoAdd = () => {
router.push('/master-data/cargo/detail')
}
const handleCargoEdit = (item: Cargo) => {
router.push(`/master-data/cargo/detail/${item.id}?mode=edit`)
}
const handleCargoDelete = (id: number) => {
showDialog({
title: '确认删除',
message: '确定要删除这个货物吗?',
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonText: '确认'
})
.then(async () => {
await cargoApi.delete(id)
showToast({ type: 'success', message: '删除成功' })
loadCargoData(true) // 重新加载数据
})
.catch(() => {})
}
const handleVehicleAdd = () => {
router.push('/master-data/vehicle/detail')
}
const handleVehicleEdit = (item: Vehicle) => {
router.push(`/master-data/vehicle/detail/${item.id}?mode=edit`)
}
const handleVehicleDelete = (id: number) => {
showDialog({
title: '确认删除',
message: '确定要删除该车牌号吗?',
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonText: '确认'
})
.then(async () => {
await vehicleApi.delete(id)
showToast({ type: 'success', message: '删除成功' })
loadVehicleData(true)
})
.catch(() => {})
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 16px;
}
.header {
background: #fff;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
&-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
&-title {
font-size: 20px;
font-weight: 600;
color: #111827;
}
.back-icon {
font-size: 20px;
color: #111827;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
}
.toolbar {
padding: 0px 16px;
background: #fff;
display: flex;
gap: 8px;
align-items: center;
border-bottom: 1px solid #e5e7eb;
}
.search-input {
flex: 1;
:deep(.van-search__content) {
padding: 0px 0px;
}
:deep(.van-field__control) {
font-size: 14px;
}
}
.search-button {
flex-shrink: 0;
min-width: 50px;
height: 32px;
padding: 0 12px;
font-size: 14px;
}
.list {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card-body {
margin-bottom: 12px;
}
.info-row {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
.label {
color: #6b7280;
min-width: 100px;
}
.value {
color: #111827;
flex: 1;
}
}
.card-footer {
display: flex;
justify-content: center;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
flex-wrap: wrap;
}
.empty {
padding: 40px 0;
text-align: center;
}
.modal {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.modal-actions {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.fab-button {
position: fixed;
right: 16px;
bottom: 50px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #52B788;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(82, 183, 136, 0.4);
cursor: pointer;
z-index: 100;
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
}
.van-icon {
font-size: 24px;
}
}
</style>
@@ -0,0 +1,127 @@
<template>
<div class="page-content">
<div class="page-header">
<div class="page-header__back" @click="handleBack">
<div class="back-arrow"></div>
</div>
<div class="page-header__title">{{ isEdit ? '编辑车牌号' : isView ? '查看车牌号' : '新增车牌号' }}</div>
</div>
<div class="form-container">
<van-form @submit="handleSubmit">
<div class="form-section">
<div class="form-item">
<div class="form-label">车牌号 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.vehicleNo || '--' }}
</div>
<van-field
v-else
v-model="formData.vehicleNo"
placeholder="请输入车牌号"
/>
</div>
<div class="form-item">
<div class="form-label">备注</div>
<div v-if="isView" class="form-input">
{{ formData.remarks || '--' }}
</div>
<van-field
v-else
:model-value="formData.remarks || ''"
@update:model-value="formData.remarks = $event || null"
type="textarea"
autosize
rows="3"
placeholder="请输入备注"
/>
</div>
</div>
<div v-if="!isView" class="form-actions">
<van-button block type="primary" native-type="submit" :loading="saving">
保存
</van-button>
<van-button block plain @click="handleBack">
取消
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { vehicleApi, type Vehicle, type VehicleCreate } from '@/api/masterDataApi'
const router = useRouter()
const route = useRoute()
const isView = computed(() => route.query.mode === 'view')
const isEdit = computed(() => route.query.mode === 'edit' || !!route.params.id)
const saving = ref(false)
const editingVehicle = ref<Vehicle | null>(null)
const formData = reactive<VehicleCreate>({
vehicleNo: '',
remarks: null
})
const loadData = async () => {
if (isEdit.value && route.params.id) {
try {
const vehicle = await vehicleApi.get(Number(route.params.id))
editingVehicle.value = vehicle
Object.assign(formData, {
vehicleNo: vehicle.vehicleNo,
remarks: vehicle.remarks || ''
})
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
router.back()
}
}
}
const handleSubmit = async () => {
if (!formData.vehicleNo?.trim()) {
showToast({ type: 'fail', message: '请填写车牌号' })
return
}
saving.value = true
try {
if (editingVehicle.value) {
await vehicleApi.update(editingVehicle.value.id, formData)
showToast({ type: 'success', message: '更新成功' })
} else {
await vehicleApi.create(formData)
showToast({ type: 'success', message: '创建成功' })
}
router.back()
} catch (error: any) {
showToast({ type: 'fail', message: error.message || '操作失败' })
} finally {
saving.value = false
}
}
const handleBack = () => {
router.back()
}
onMounted(() => {
if (isEdit.value || isView.value) {
loadData()
}
})
</script>
<style lang="scss" scoped>
@import '@/assets/styles/detail-page.scss';
</style>
+510
View File
@@ -0,0 +1,510 @@
<template>
<div class="page-content">
<div class="page-header">
<div class="page-header__back" @click="handleBack">
<div class="back-arrow"></div>
</div>
<div class="page-header__title">{{ isEdit ? '编辑计划' : isView ? '查看计划' : '新增计划' }}</div>
</div>
<div class="form-container">
<van-form @submit="handleSubmit">
<div class="form-section">
<div class="form-item">
<div class="form-label">计划号</div>
<div class="form-input" v-if="isView || editingPlan">
{{ formData.planNo || '系统自动生成' }}
</div>
<van-field
v-else
v-model="formData.planNo"
placeholder="系统自动生成"
readonly
/>
</div>
<div class="form-item">
<div class="form-label">业务类型 <span class="required">*</span></div>
<div class="radio-wrapper">
<van-radio-group v-model="formData.businessType" :disabled="isView" direction="horizontal">
<van-radio name="入库">入库</van-radio>
<van-radio name="出库">出库</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-item">
<div class="form-label">往来单位</div>
<div v-if="isView" class="form-input">
{{ formData.customerName || '--' }}
</div>
<div v-else class="form-input" @click="showCompanyPicker = true">
<span :class="{ placeholder: !formData.customerName }">
{{ formData.customerName || '请选择往来单位' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">产品 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.product || '--' }}
</div>
<div v-else class="form-input" @click="showCargoPicker = true">
<span :class="{ placeholder: !formData.product }">
{{ formData.product || '请选择产品' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">计划开始时间</div>
<div v-if="isView" class="form-input">
{{ formData.startDate || '--' }}
</div>
<div v-else class="form-input" @click="showStartDateCalendar = true">
<span :class="{ placeholder: !formData.startDate }">
{{ formData.startDate || '选择日期' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">计划结束时间</div>
<div v-if="isView" class="form-input">
{{ formData.endDate || '--' }}
</div>
<div v-else class="form-input" @click="showEndDateCalendar = true">
<span :class="{ placeholder: !formData.endDate }">
{{ formData.endDate || '选择日期' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">计划量(吨) <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.plannedQuantity || '--' }}
</div>
<van-field
v-else
v-model.number="formData.plannedQuantity"
type="number"
placeholder="请输入计划量"
/>
</div>
<div class="form-item">
<div class="form-label">备注</div>
<div v-if="isView" class="form-input">
{{ formData.remarks || '--' }}
</div>
<van-field
v-else
v-model="formData.remarks"
type="textarea"
autosize
rows="3"
placeholder="填写备注信息"
/>
</div>
</div>
<div v-if="!isView" class="form-actions">
<van-button block type="primary" native-type="submit" :loading="saving">
保存
</van-button>
<van-button block plain @click="handleBack">
取消
</van-button>
</div>
</van-form>
</div>
<!-- 公司选择器 -->
<van-popup v-model:show="showCompanyPicker" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="company-picker-container">
<div class="search-container">
<van-search
v-model="companySearchKeyword"
placeholder="搜索往来单位名称"
@search="handleCompanySearch"
@clear="handleCompanySearch"
shape="round"
/>
</div>
<van-picker
:columns="filteredCompanyOptions"
show-toolbar
@confirm="onCompanyConfirm"
@cancel="showCompanyPicker = false"
/>
</div>
</van-popup>
<!-- 货物选择器 -->
<van-popup v-model:show="showCargoPicker" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="company-picker-container">
<div class="search-container">
<van-search
v-model="cargoSearchKeyword"
placeholder="搜索产品名称"
@search="handleCargoSearch"
@clear="handleCargoSearch"
shape="round"
/>
</div>
<van-picker
:columns="filteredCargoOptions"
show-toolbar
@confirm="onCargoConfirm"
@cancel="showCargoPicker = false"
/>
</div>
</van-popup>
<!-- 开始日期选择器 -->
<van-calendar
v-model:show="showStartDateCalendar"
v-model="startDateSelected"
@confirm="onStartDateConfirm"
/>
<!-- 结束日期选择器 -->
<van-calendar
v-model:show="showEndDateCalendar"
v-model="endDateSelected"
@confirm="onEndDateConfirm"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { planApi, type Plan, type PlanCreate } from '@/api/plansApi'
import { companyApi, cargoApi } from '@/api/masterDataApi'
import dayjs from 'dayjs'
const router = useRouter()
const route = useRoute()
const isView = computed(() => route.query.mode === 'view')
const isEdit = computed(() => route.query.mode === 'edit' || route.params.id)
// const isAdd = computed(() => !isView.value && !isEdit.value) // 未使用
const saving = ref(false)
const editingPlan = ref<Plan | null>(null)
const showCompanyPicker = ref(false)
const showCargoPicker = ref(false)
const showStartDateCalendar = ref(false)
const showEndDateCalendar = ref(false)
const startDateSelected = ref<Date | null>(null)
const endDateSelected = ref<Date | null>(null)
const companyOptions = ref<{ text: string; value: string }[]>([])
const cargoOptions = ref<{ text: string; value: string }[]>([])
// 搜索相关 - 往来单位
const companySearchKeyword = ref('')
const allCompanyOptions = ref<{ text: string; value: string }[]>([])
const filteredCompanyOptions = computed(() => {
if (!companySearchKeyword.value) return [allCompanyOptions.value]
const filtered = allCompanyOptions.value.filter(item =>
item.text.toLowerCase().includes(companySearchKeyword.value.toLowerCase())
)
return [filtered]
})
// 搜索相关 - 产品
const cargoSearchKeyword = ref('')
const allCargoOptions = ref<{ text: string; value: string }[]>([])
const filteredCargoOptions = computed(() => {
if (!cargoSearchKeyword.value) return [allCargoOptions.value]
const filtered = allCargoOptions.value.filter(item =>
item.text.toLowerCase().includes(cargoSearchKeyword.value.toLowerCase())
)
return [filtered]
})
const formData = reactive<PlanCreate>({
planNo: '',
planDate: dayjs().format('YYYY-MM-DD'),
startDate: dayjs().format('YYYY-MM-DD'),
endDate: '',
businessType: '入库',
product: '',
plannedQuantity: 0,
customerName: '',
remarks: ''
})
// 初始化日期选择器的默认值
startDateSelected.value = dayjs().toDate()
const loadCompanies = async () => {
try {
const companies = await companyApi.list()
allCompanyOptions.value = companies.map(item => ({
text: item.name,
value: item.name
}))
companyOptions.value = allCompanyOptions.value
} catch (error) {
console.error('加载公司列表失败', error)
}
}
const loadCargos = async () => {
try {
const cargos = await cargoApi.list()
allCargoOptions.value = cargos.map(item => ({
text: item.name,
value: item.name
}))
cargoOptions.value = allCargoOptions.value
} catch (error) {
console.error('加载货物列表失败', error)
}
}
const onCompanyConfirm = ({ selectedOptions }: any) => {
formData.customerName = selectedOptions[0].value
showCompanyPicker.value = false
}
const onCargoConfirm = ({ selectedOptions }: any) => {
formData.product = selectedOptions[0].value
showCargoPicker.value = false
}
const handleCompanySearch = () => {
// 触发 computed 过滤
}
const handleCargoSearch = () => {
// 触发 computed 过滤
}
const onStartDateConfirm = (value: Date | Date[]) => {
const date = Array.isArray(value) ? value[0] : value
formData.startDate = dayjs(date).format('YYYY-MM-DD')
startDateSelected.value = date
showStartDateCalendar.value = false
}
const onEndDateConfirm = (value: Date | Date[]) => {
const date = Array.isArray(value) ? value[0] : value
formData.endDate = dayjs(date).format('YYYY-MM-DD')
endDateSelected.value = date
showEndDateCalendar.value = false
}
const loadData = async () => {
if (isEdit.value && route.params.id) {
try {
const plan = await planApi.get(Number(route.params.id))
editingPlan.value = plan
Object.assign(formData, {
planNo: plan.planNo || '',
planDate: plan.planDate,
startDate: plan.startDate || '',
endDate: plan.endDate || '',
businessType: plan.businessType,
product: plan.product,
plannedQuantity: plan.plannedQuantity,
customerName: plan.customerName || '',
remarks: plan.remarks || ''
})
// 初始化日期选择器的值
if (plan.startDate) {
startDateSelected.value = dayjs(plan.startDate).toDate()
}
if (plan.endDate) {
endDateSelected.value = dayjs(plan.endDate).toDate()
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
router.back()
}
}
}
const handleSubmit = async () => {
if (!formData.product || !formData.plannedQuantity) {
showToast({ type: 'fail', message: '请填写必填项' })
return
}
saving.value = true
try {
if (editingPlan.value) {
await planApi.update(editingPlan.value.id, formData)
showToast({ type: 'success', message: '更新成功' })
} else {
await planApi.create(formData)
showToast({ type: 'success', message: '创建成功' })
}
router.back()
} catch (error: any) {
showToast({ type: 'fail', message: error.message || '操作失败' })
} finally {
saving.value = false
}
}
const handleBack = () => {
router.back()
}
onMounted(async () => {
await Promise.all([loadCompanies(), loadCargos()])
if (isEdit.value || isView.value) {
await loadData()
}
})
// 打开弹窗时重置搜索关键词
watch(showCompanyPicker, (val) => {
if (val) companySearchKeyword.value = ''
})
watch(showCargoPicker, (val) => {
if (val) cargoSearchKeyword.value = ''
})
</script>
<style lang="scss" scoped>
.page-content {
min-height: 100vh;
background: #f5f7fa;
}
.page-header {
display: flex;
align-items: center;
padding: 16px;
background-color: #fff;
border-bottom: 1px solid #eee;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 16px;
&__back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 12px;
}
.back-arrow {
width: 16px;
height: 16px;
border-left: 2px solid #333;
border-bottom: 2px solid #333;
transform: rotate(45deg);
}
&__title {
font-size: 18px;
font-weight: bold;
color: #333;
}
}
.form-container {
padding: 16px;
}
.form-section {
background-color: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
font-size: 14px;
color: #333;
margin-bottom: 8px;
display: block;
font-weight: 500;
.required {
color: #ee0a24;
margin-left: 4px;
}
}
.form-input {
display: flex;
align-items: center;
background-color: #f7f7f7;
border-radius: 8px;
padding: 12px 16px;
font-size: 14px;
color: #333;
min-height: 44px;
.placeholder {
color: #999;
}
.arrow-icon {
margin-left: auto;
color: #999;
}
}
.radio-wrapper {
padding: 12px 0;
}
.form-actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
padding: 0 16px;
}
.company-picker-container {
height: 100%;
display: flex;
flex-direction: column;
}
.search-container {
padding: 16px;
border-bottom: 1px solid #ebedf0;
background: #fff;
:deep(.van-search) {
padding: 0;
}
:deep(.van-search__content) {
background: #f7f8fa;
border-radius: 20px;
}
:deep(.van-field__control) {
font-size: 14px;
}
}
</style>
+792
View File
@@ -0,0 +1,792 @@
<template>
<div class="page">
<div class="header">
<div class="header-left">
<van-icon name="arrow-left" class="back-icon" @click="handleBack" />
<div class="header-title">计划管理</div>
</div>
</div>
<div class="search-bar">
<van-search
v-model="searchKeyword"
placeholder="搜索往来单位 / 计划号"
@search="handleSearch"
@clear="handleSearch"
class="search-input"
/>
<van-button type="primary" size="normal" class="search-button" @click="handleSearch">搜索</van-button>
</div>
<div class="filter-bar">
<div
v-for="item in dateFilterOptions"
:key="item.value"
class="filter-chip"
:class="{ active: dateFilterType === item.value }"
@click="setDateFilter(item.value)"
>
{{ item.label }}
</div>
</div>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div v-if="!loading && tableData.length === 0" class="empty">
<van-empty description="暂无数据" />
</div>
<div v-else class="list">
<div
v-for="item in tableData"
:key="item.id"
class="card"
@click="handleView(item)"
>
<div class="card-header">
<div class="card-title">
<span class="plan-no">{{ item.planNo || '未填计划号' }}</span>
<van-tag :type="item.businessType === '入库' ? 'success' : 'warning'" plain>
{{ item.businessType }}
</van-tag>
</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="label">往来单位:</span>
<span class="value">{{ item.customerName || '未填' }}</span>
</div>
<div class="info-row">
<span class="label">产品:</span>
<span class="value">{{ item.product }}</span>
</div>
<div class="info-row">
<span class="label">日期:</span>
<span class="value">{{ item.startDate || item.planDate }} ~ {{ item.endDate || item.planDate }}</span>
</div>
<div class="info-row">
<span class="label">计划量/已执行:</span>
<span class="value">{{ item.plannedQuantity }} / {{ ((item.executedQuantity || 0) / 1000).toFixed(2) }} 吨</span>
</div>
<div class="info-row">
<span class="label">状态:</span>
<van-tag :type="getExecutionStatusType(getCorrectExecutionStatus(item))" plain>
{{ getCorrectExecutionStatus(item) }}
</van-tag>
</div>
</div>
<div class="card-footer">
<van-button size="small" type="primary" plain @click.stop="handleViewRelated(item)">查看</van-button>
<van-button size="small" type="primary" plain @click.stop="handleEdit(item)">编辑</van-button>
<van-button size="small" type="success" plain @click.stop="handleGenerateInventory(item)">生成出入库单</van-button>
<van-button size="small" type="danger" plain @click.stop="handleDelete(item.id)">删除</van-button>
</div>
</div>
</div>
</van-list>
<!-- 浮动新增按钮 -->
<div class="fab-button" @click="handleAdd">
<van-icon name="plus" />
</div>
<!-- 关联数据弹窗 -->
<van-dialog
v-model:show="relatedModalOpen"
title="查看关联数据"
show-cancel-button
cancel-button-text="关闭"
confirm-button-text=""
:show-confirm-button="false"
class="related-dialog"
>
<div v-if="loadingRelated" class="loading-container">
<van-loading type="spinner" />
</div>
<div v-else class="related-content">
<div v-if="mergedData.length === 0" class="empty-related">
<van-empty description="暂无关联数据" />
</div>
<div v-else class="related-list">
<div
v-for="(item, index) in mergedData"
:key="`${item.type}-${item.id}`"
class="related-item"
>
<!-- 出入库单 -->
<div v-if="item.type === 'inventory'" class="related-section">
<div class="related-header">
<van-tag type="primary">出入库单</van-tag>
</div>
<div class="related-info">
<div class="related-row">
<span class="label">出入库单号:</span>
<span class="value">{{ item.batchNo || '--' }}</span>
</div>
<div class="related-row">
<span class="label">日期:</span>
<span class="value">{{ item.date }}</span>
</div>
<div class="related-row">
<span class="label">往来单位:</span>
<span class="value">{{ item.partnerCompany }}</span>
</div>
<div class="related-row">
<span class="label">业务类型:</span>
<van-tag :type="item.businessType === '入库' ? 'success' : 'warning'" size="medium">
{{ item.businessType }}
</van-tag>
</div>
<div class="related-row">
<span class="label">产品:</span>
<span class="value">{{ item.product }}</span>
</div>
<div class="related-row">
<span class="label">车牌号:</span>
<span class="value">{{ item.vehicleNo }}</span>
</div>
<div class="related-row">
<span class="label">数量(吨):</span>
<span class="value">{{ item.quantity }}</span>
</div>
<div class="related-row">
<span class="label">状态:</span>
<van-tag
:type="
item.status === '已完成'
? 'success'
: item.status === '待入库' || item.status === '待出库'
? 'warning'
: 'default'
"
size="medium"
>
{{ item.status || '待处理' }}
</van-tag>
</div>
</div>
</div>
<!-- 地磅单 -->
<div v-else-if="item.type === 'weighing'" class="related-section">
<div class="related-header">
<van-tag type="warning">地磅单</van-tag>
</div>
<div class="related-info">
<div class="related-row">
<span class="label">出入库单号:</span>
<span class="value">{{ item.batchNo || '--' }}</span>
</div>
<div class="related-row">
<span class="label">车牌号:</span>
<span class="value">{{ item.vehicleNo }}</span>
</div>
<div class="related-row">
<span class="label">货物名称:</span>
<span class="value">{{ item.cargoName }}</span>
</div>
<div class="related-row">
<span class="label">业务类型:</span>
<van-tag :type="item.businessType === '入库' ? 'success' : 'warning'" size="medium">
{{ item.businessType }}
</van-tag>
</div>
<div class="related-row">
<span class="label">毛重(kg):</span>
<span class="value">{{ item.grossWeight }}</span>
</div>
<div class="related-row">
<span class="label">皮重(kg):</span>
<span class="value">{{ item.tareWeight }}</span>
</div>
<div class="related-row">
<span class="label">净重(kg):</span>
<span class="value">{{ item.netWeight }}</span>
</div>
<div class="related-row">
<span class="label">毛重时间:</span>
<span class="value">{{ item.grossWeightTime }}</span>
</div>
<div class="related-row">
<span class="label">皮重时间:</span>
<span class="value">{{ item.tareWeightTime }}</span>
</div>
</div>
</div>
<!-- 分割线 -->
<van-divider v-if="index < mergedData.length - 1" />
</div>
</div>
</div>
</van-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs'
import { useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { planApi, type Plan } from '@/api/plansApi'
import type { InventoryRecord } from '@/api/inventoryApi'
import type { WeighingRecord } from '@/api/weighingApi'
const router = useRouter()
const loading = ref(false)
const finished = ref(false)
const tableData = ref<Plan[]>([])
const allData = ref<Plan[]>([]) // 存储所有数据
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
const dateFilterOptions = [
{ label: '全部', value: 'all' },
{ label: '今日', value: 'today' },
{ label: '明日', value: 'tomorrow' },
{ label: '昨日', value: 'yesterday' },
{ label: '本周', value: 'week' }
] as const
// 关联数据相关
const relatedModalOpen = ref(false)
const loadingRelated = ref(false)
const relatedData = ref<{ inventories: InventoryRecord[], weighings: WeighingRecord[] }>({ inventories: [], weighings: [] })
// 合并并排序数据
const mergedData = computed(() => {
const items: any[] = []
// 添加出入库单,标记类型
relatedData.value.inventories.forEach((inv: any) => {
items.push({
...inv,
type: 'inventory',
sortTime: inv.createdAt || inv.date || ''
})
})
// 添加地磅单,标记类型
relatedData.value.weighings.forEach((w: any) => {
items.push({
...w,
type: 'weighing',
sortTime: w.grossWeightTime || w.createdAt || ''
})
})
// 按时间倒序排列(空时间排在最后)
return items.sort((a, b) => {
const timeA = a.sortTime ? new Date(a.sortTime).getTime() : 0
const timeB = b.sortTime ? new Date(b.sortTime).getTime() : 0
// 如果时间都为0(空),保持原顺序;否则按时间倒序
if (timeA === 0 && timeB === 0) return 0
if (timeA === 0) return 1
if (timeB === 0) return -1
return timeB - timeA
})
})
const getCorrectExecutionStatus = (row: Plan): string => {
if (!row.executionStatus) {
return row.businessType === '入库' ? '待入库' : '待出库'
}
if (row.businessType === '入库') {
if (row.executionStatus === '待出库' || row.executionStatus === '出库中' || row.executionStatus === '已出库') {
if ((row.executedQuantity || 0) > 0) {
return '入库中'
}
return '待入库'
}
} else if (row.businessType === '出库') {
if (row.executionStatus === '待入库' || row.executionStatus === '入库中' || row.executionStatus === '已入库') {
if ((row.executedQuantity || 0) > 0) {
return '出库中'
}
return '待出库'
}
}
return row.executionStatus
}
const getExecutionStatusType = (status: string | undefined): 'default' | 'primary' | 'success' | 'warning' | 'danger' => {
if (!status) return 'default'
if (status === '已入库' || status === '已出库') return 'success'
if (status === '入库中' || status === '出库中') return 'primary'
if (status === '待入库' || status === '待出库') return 'warning'
return 'default'
}
const loadData = async (reset = false) => {
if (reset) {
currentPage.value = 1
allData.value = []
tableData.value = []
finished.value = false
}
loading.value = true
try {
// 如果是第一页或搜索,重新获取所有数据
if (reset || currentPage.value === 1) {
const params: any = {
q: searchKeyword.value || undefined
}
// 按创建时间过滤(与 web 端保持一致)
const today = dayjs()
if (dateFilterType.value === 'today') {
const d = today.format('YYYY-MM-DD')
params.createdFrom = d
params.createdTo = d
} else if (dateFilterType.value === 'tomorrow') {
const t = today.add(1, 'day').format('YYYY-MM-DD')
params.createdFrom = t
params.createdTo = t
} else if (dateFilterType.value === 'yesterday') {
const y = today.subtract(1, 'day').format('YYYY-MM-DD')
params.createdFrom = y
params.createdTo = y
} else if (dateFilterType.value === 'week') {
// 本周:周一到周日
const weekday = today.day() === 0 ? 7 : today.day()
const monday = today.subtract(weekday - 1, 'day').format('YYYY-MM-DD')
const sunday = today.add(7 - weekday, 'day').format('YYYY-MM-DD')
params.createdFrom = monday
params.createdTo = sunday
}
const plans = await planApi.list(params)
allData.value = plans
}
// 分页切片
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
const newData = allData.value.slice(start, end)
if (reset || currentPage.value === 1) {
tableData.value = newData
} else {
tableData.value.push(...newData)
}
// 判断是否加载完成
if (tableData.value.length >= allData.value.length) {
finished.value = true
} else {
currentPage.value++
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
finished.value = true
} finally {
loading.value = false
}
}
const onLoad = () => {
if (!finished.value) {
loadData()
}
}
const handleSearch = () => {
loadData(true) // 重置并重新加载
}
const setDateFilter = (type: (typeof dateFilterOptions)[number]['value']) => {
if (dateFilterType.value === type) return
dateFilterType.value = type
loadData(true)
}
const handleBack = () => {
router.push('/dashboard')
}
const handleAdd = () => {
router.push('/plans/detail')
}
const handleEdit = (record: Plan) => {
router.push(`/plans/detail/${record.id}?mode=edit`)
}
const handleView = (record: Plan) => {
router.push(`/plans/detail/${record.id}?mode=view`)
}
const handleViewRelated = async (record: Plan) => {
loadingRelated.value = true
relatedModalOpen.value = true
try {
const data = await planApi.getRelated(record.id)
relatedData.value = data
} catch (error) {
showToast({ type: 'fail', message: '加载关联数据失败' })
relatedModalOpen.value = false
} finally {
loadingRelated.value = false
}
}
const handleDelete = (id: number) => {
showDialog({
title: '确认删除',
message: '确定要删除这个计划吗?',
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonText: '确认'
})
.then(async () => {
await planApi.delete(id)
showToast({ type: 'success', message: '删除成功' })
loadData(true) // 重新加载数据
})
.catch(() => {})
}
const handleGenerateInventory = (record: Plan) => {
router.push({
path: '/inventory',
query: {
planNo: record.planNo || '',
product: record.product || '',
businessType: record.businessType || '',
customerName: record.customerName || '',
purchasePlanId: String(record.id),
from: 'plan'
}
})
showToast({ type: 'success', message: '已跳转到出入库页面' })
}
onMounted(() => {
loadData(true) // 初始加载
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 16px;
}
.header {
background: #fff;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e5e7eb;
&-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
&-title {
font-size: 20px;
font-weight: 600;
color: #111827;
}
.back-icon {
font-size: 20px;
color: #111827;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
}
.search-bar {
padding: 0px 16px;
background: #fff;
border-bottom: 0;
display: flex;
align-items: center;
gap: 8px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 4px;
background: #fff;
border-bottom: 1px solid #f3f4f6;
overflow-x: auto;
}
.filter-chip {
flex-shrink: 0;
padding: 4px 10px;
font-size: 13px;
border-radius: 999px;
border: 1px solid #d1d5db;
color: #4b5563;
background-color: #fff;
line-height: 1.4;
}
.filter-chip.active {
border-color: #52b788;
background-color: #52b788;
color: #fff;
}
.search-input {
flex: 1;
:deep(.van-search__content) {
padding: 0px 0px;
}
:deep(.van-field__control) {
font-size: 14px;
}
}
.search-button {
flex-shrink: 0;
min-width: 50px;
height: 32px;
padding: 0 12px;
font-size: 14px;
}
.list {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card-header {
margin-bottom: 12px;
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.plan-no {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.card-body {
margin-bottom: 12px;
}
.info-row {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
.label {
color: #6b7280;
min-width: 100px;
}
.value {
color: #111827;
flex: 1;
}
}
.card-footer {
display: flex;
justify-content: center;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
flex-wrap: wrap;
}
.empty {
padding: 40px 0;
text-align: center;
}
.modal {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.modal-actions {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.related-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.related-item {
padding: 12px;
background: #f9fafb;
border-radius: 8px;
}
.related-content {
margin-top: 8px;
}
.related-row {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
.label {
color: #6b7280;
min-width: 80px;
}
.value {
color: #111827;
flex: 1;
}
}
.loading-container {
padding: 40px;
text-align: center;
}
.related-content {
max-height: 60vh;
overflow-y: auto;
padding: 16px;
}
.empty-related {
padding: 40px 0;
text-align: center;
}
.related-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.related-item {
padding: 12px;
background: #f9fafb;
border-radius: 8px;
}
.related-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.related-header {
margin-bottom: 8px;
}
.related-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.related-row {
display: flex;
align-items: center;
font-size: 14px;
.label {
color: #6b7280;
min-width: 100px;
}
.value {
color: #111827;
flex: 1;
}
}
:deep(.related-dialog) {
.van-dialog__content {
padding: 0;
}
}
.fab-button {
position: fixed;
right: 16px;
bottom: 50px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #52B788;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(82, 183, 136, 0.4);
cursor: pointer;
z-index: 100;
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
}
.van-icon {
font-size: 24px;
}
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<template>
<div class="page-content">
<div class="page-header">
<div class="page-header__back" @click="handleBack">
<div class="back-arrow"></div>
</div>
<div class="page-header__title">{{ isEdit ? '编辑用户' : isView ? '查看用户' : '新增用户' }}</div>
</div>
<div class="form-container">
<van-form @submit="handleSubmit">
<div class="form-section">
<div class="form-item">
<div class="form-label">用户名 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.username || '--' }}
</div>
<van-field
v-else
v-model="formData.username"
placeholder="请输入用户名"
:disabled="!!editingUser"
/>
</div>
<div class="form-item">
<div class="form-label">{{ editingUser ? '新密码' : '密码' }} <span class="required" v-if="!editingUser">*</span></div>
<div v-if="isView" class="form-input">
******
</div>
<van-field
v-else
v-model="formData.password"
type="password"
:placeholder="editingUser ? '留空则不修改密码' : '请输入密码'"
/>
</div>
<div class="form-item">
<div class="form-label">角色 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.role === 'admin' ? '管理员' : '操作员' }}
</div>
<div v-else class="form-input" @click="showRolePicker = true">
<span :class="{ placeholder: !formData.role }">
{{ formData.role === 'admin' ? '管理员' : formData.role === 'operator' ? '操作员' : '请选择角色' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
</div>
<div v-if="!isView" class="form-actions">
<van-button block type="primary" native-type="submit" :loading="saving">
保存
</van-button>
<van-button block plain @click="handleBack">
取消
</van-button>
</div>
</van-form>
</div>
<!-- 角色选择器 -->
<van-popup v-model:show="showRolePicker" round position="bottom">
<van-picker
:columns="roleOptions"
show-toolbar
@confirm="onRoleConfirm"
@cancel="showRolePicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { userApi, type User, type UserCreate } from '@/api/usersApi'
const router = useRouter()
const route = useRoute()
const isView = computed(() => route.query.mode === 'view')
const isEdit = computed(() => route.query.mode === 'edit' || route.params.id)
// const isAdd = computed(() => !isView.value && !isEdit.value) // 未使用
const saving = ref(false)
const editingUser = ref<User | null>(null)
const showRolePicker = ref(false)
const roleOptions = [
{ text: '管理员', value: 'admin' },
{ text: '操作员', value: 'operator' }
]
const formData = reactive<UserCreate>({
username: '',
password: '',
role: 'operator'
})
const loadData = async () => {
if (isEdit.value && route.params.id) {
try {
const user = await userApi.get(Number(route.params.id))
editingUser.value = user
Object.assign(formData, {
username: user.username,
password: '',
role: user.role || 'operator'
})
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
router.back()
}
}
}
const onRoleConfirm = ({ selectedOptions }: any) => {
formData.role = selectedOptions[0].value
showRolePicker.value = false
}
const handleSubmit = async () => {
if (!formData.username || (!editingUser.value && !formData.password)) {
showToast({ type: 'fail', message: '请填写必填项' })
return
}
saving.value = true
try {
if (editingUser.value) {
const updateData: any = {
role: formData.role
}
if (formData.password) {
updateData.password = formData.password
}
await userApi.update(editingUser.value.id, updateData)
showToast({ type: 'success', message: '更新成功' })
} else {
await userApi.create(formData)
showToast({ type: 'success', message: '创建成功' })
}
router.back()
} catch (error: any) {
showToast({ type: 'fail', message: error.message || '操作失败' })
} finally {
saving.value = false
}
}
const handleBack = () => {
router.back()
}
onMounted(() => {
if (isEdit.value || isView.value) {
loadData()
}
})
</script>
<style lang="scss" scoped>
@import '@/assets/styles/detail-page.scss';
</style>
+254
View File
@@ -0,0 +1,254 @@
<template>
<div class="page">
<div class="header">
<div class="header-title">用户管理</div>
<van-button type="primary" size="small" icon="plus" @click="handleAdd">新增</van-button>
</div>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div v-if="!loading && tableData.length === 0" class="empty">
<van-empty description="暂无数据" />
</div>
<div v-else class="list">
<div
v-for="item in tableData"
:key="item.id"
class="card"
>
<div class="card-body">
<div class="info-row">
<span class="label">用户名:</span>
<span class="value">{{ item.username }}</span>
</div>
<div class="info-row">
<span class="label">角色:</span>
<van-tag :type="item.role === 'admin' ? 'danger' : 'primary'" plain>
{{ item.role === 'admin' ? '管理员' : '操作员' }}
</van-tag>
</div>
<div class="info-row">
<span class="label">创建时间:</span>
<span class="value">{{ item.createdAt }}</span>
</div>
</div>
<div class="card-footer">
<van-button size="mini" type="primary" plain @click="handleEdit(item)">编辑</van-button>
<van-button
size="mini"
type="danger"
plain
:disabled="item.username === 'admin'"
@click="handleDelete(item.id, item.username)"
>
删除
</van-button>
</div>
</div>
</div>
</van-list>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { userApi, type User } from '@/api/usersApi'
const router = useRouter()
const loading = ref(false)
const finished = ref(false)
const tableData = ref<User[]>([])
const allData = ref<User[]>([]) // 存储所有数据
const currentPage = ref(1)
const pageSize = ref(20)
const loadData = async (reset = false) => {
if (reset) {
currentPage.value = 1
allData.value = []
tableData.value = []
finished.value = false
}
loading.value = true
try {
// 如果是第一页或重置,重新获取所有数据
if (reset || currentPage.value === 1) {
const users = await userApi.list()
allData.value = users
}
// 分页切片
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
const newData = allData.value.slice(start, end)
if (reset || currentPage.value === 1) {
tableData.value = newData
} else {
tableData.value.push(...newData)
}
// 判断是否加载完成
if (tableData.value.length >= allData.value.length) {
finished.value = true
} else {
currentPage.value++
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
finished.value = true
} finally {
loading.value = false
}
}
const onLoad = () => {
if (!finished.value) {
loadData()
}
}
const handleAdd = () => {
router.push('/user/detail')
}
const handleEdit = (item: User) => {
router.push(`/user/detail/${item.id}?mode=edit`)
}
const handleDelete = (id: number, username: string) => {
if (username === 'admin') {
showToast({ type: 'fail', message: '不能删除管理员账号' })
return
}
showDialog({
title: '确认删除',
message: '确定要删除这个用户吗?',
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonText: '确认'
})
.then(async () => {
await userApi.delete(id)
showToast({ type: 'success', message: '删除成功' })
loadData(true) // 重新加载数据
})
.catch(() => {})
}
onMounted(() => {
loadData(true) // 初始加载
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 16px;
}
.header {
background: #fff;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e5e7eb;
&-title {
font-size: 20px;
font-weight: 600;
color: #111827;
}
}
.list {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card-body {
margin-bottom: 12px;
}
.info-row {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
.label {
color: #6b7280;
min-width: 100px;
}
.value {
color: #111827;
flex: 1;
}
}
.card-footer {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.empty {
padding: 40px 0;
text-align: center;
}
.modal {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.modal-actions {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
</style>
+853
View File
@@ -0,0 +1,853 @@
<template>
<div class="page-content">
<div class="page-header">
<div class="page-header__back" @click="handleBack">
<div class="back-arrow"></div>
</div>
<div class="page-header__title">{{ isEdit ? '编辑地磅单' : isView ? '查看地磅单' : '新增地磅单' }}</div>
</div>
<div class="form-container">
<van-form @submit="handleSubmit">
<div class="form-section">
<div class="form-item">
<div class="form-label">车牌号 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.vehicleNo || '--' }}
</div>
<van-field
v-else
v-model="formData.vehicleNo"
placeholder="请输入车牌号"
/>
</div>
<div class="form-item">
<div class="form-label">货物名称 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.cargoName || '--' }}
</div>
<div v-else class="form-input" @click="showCargoPicker = true">
<span :class="{ placeholder: !formData.cargoName }">
{{ formData.cargoName || '请选择货物名称' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">业务类型 <span class="required">*</span></div>
<div class="radio-wrapper">
<van-radio-group v-model="formData.businessType" :disabled="isView" direction="horizontal">
<van-radio name="入库">入库</van-radio>
<van-radio name="出库">出库</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-item">
<div class="form-label">供货单位 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.supplier || '--' }}
</div>
<div v-else class="form-input" @click="showSupplierPicker = true">
<span :class="{ placeholder: !formData.supplier }">
{{ formData.supplier || '请选择供货单位' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">收货单位 <span class="required">*</span></div>
<div v-if="isView" class="form-input">
{{ formData.consignee || '--' }}
</div>
<div v-else class="form-input" @click="showConsigneePicker = true">
<span :class="{ placeholder: !formData.consignee }">
{{ formData.consignee || '请选择收货单位' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">毛重(kg)</div>
<div v-if="isView" class="form-input">
{{ formData.grossWeight || '--' }}
</div>
<van-field
v-else
v-model.number="formData.grossWeight"
type="number"
placeholder="请输入毛重"
@update:model-value="handleGrossWeightChange"
/>
</div>
<div class="form-item">
<div class="form-label">毛重时间</div>
<div v-if="isView" class="form-input">
{{ formData.grossWeightTime || '--' }}
</div>
<div v-else class="form-input" @click="handleOpenGrossWeightDateTime">
<span :class="{ placeholder: !formData.grossWeightTime }">
{{ formData.grossWeightTime || '选择时间' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">皮重(kg)</div>
<div v-if="isView" class="form-input">
{{ formData.tareWeight || '--' }}
</div>
<van-field
v-else
v-model.number="formData.tareWeight"
type="number"
placeholder="请输入皮重"
@update:model-value="handleTareWeightChange"
/>
</div>
<div class="form-item">
<div class="form-label">皮重时间</div>
<div v-if="isView" class="form-input">
{{ formData.tareWeightTime || '--' }}
</div>
<div v-else class="form-input" @click="handleOpenTareWeightDateTime">
<span :class="{ placeholder: !formData.tareWeightTime }">
{{ formData.tareWeightTime || '选择时间' }}
</span>
<van-icon name="arrow" class="arrow-icon" />
</div>
</div>
<div class="form-item">
<div class="form-label">扣杂(kg)</div>
<div v-if="isView" class="form-input">
{{ formData.deduction || 0 }}
</div>
<van-field
v-else
v-model.number="formData.deduction"
type="number"
placeholder="请输入扣杂"
@update:model-value="calculateNetWeight"
/>
</div>
<div class="form-item">
<div class="form-label">净重(kg)</div>
<div class="form-input">
<span :class="{ 'text-danger': isNetInvalid }">
{{ formData.grossWeight > 0 && formData.tareWeight > 0 ? netWeight : '--' }}
</span>
</div>
<div v-if="isNetInvalid" class="error-message">净重必须大于0</div>
</div>
<div class="form-item">
<div class="form-label">计划号</div>
<div v-if="isView" class="form-input">
{{ formData.planNo || '--' }}
</div>
<van-field
v-else
v-model="formData.planNo"
placeholder="请输入计划号"
/>
</div>
<div class="form-item">
<div class="form-label">备注</div>
<div v-if="isView" class="form-input">
{{ formData.remarks || '--' }}
</div>
<van-field
v-else
v-model="formData.remarks"
type="textarea"
autosize
rows="3"
placeholder="请输入备注"
/>
</div>
</div>
<div v-if="!isView" class="form-actions">
<van-button block type="primary" native-type="submit" :loading="saving" :disabled="isNetInvalid">
保存
</van-button>
<van-button block plain @click="handleBack">
取消
</van-button>
</div>
</van-form>
</div>
<!-- 货物选择器 -->
<van-popup v-model:show="showCargoPicker" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="company-picker-container">
<div class="search-container">
<van-search
v-model="cargoSearchKeyword"
placeholder="搜索货物名称"
@search="handleCargoSearch"
@clear="handleCargoSearch"
shape="round"
/>
</div>
<van-picker
:columns="filteredCargoOptions"
show-toolbar
@confirm="onCargoConfirm"
@cancel="showCargoPicker = false"
/>
</div>
</van-popup>
<!-- 供货单位选择器 -->
<van-popup v-model:show="showSupplierPicker" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="company-picker-container">
<div class="search-container">
<van-search
v-model="companySearchKeyword"
placeholder="搜索往来单位名称"
@search="handleCompanySearch"
@clear="handleCompanySearch"
shape="round"
/>
</div>
<van-picker
:columns="filteredCompanyOptions"
show-toolbar
@confirm="onSupplierConfirm"
@cancel="showSupplierPicker = false"
/>
</div>
</van-popup>
<!-- 收货单位选择器 -->
<van-popup v-model:show="showConsigneePicker" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="company-picker-container">
<div class="search-container">
<van-search
v-model="companySearchKeyword"
placeholder="搜索往来单位名称"
@search="handleCompanySearch"
@clear="handleCompanySearch"
shape="round"
/>
</div>
<van-picker
:columns="filteredCompanyOptions"
show-toolbar
@confirm="onConsigneeConfirm"
@cancel="showConsigneePicker = false"
/>
</div>
</van-popup>
<!-- 毛重日期选择器 -->
<van-calendar
v-model:show="showGrossWeightDateCalendar"
v-model="grossWeightDateSelected"
@confirm="onGrossWeightDateConfirm"
/>
<!-- 毛重时间选择器 -->
<van-popup v-model:show="showGrossWeightTimePicker" round position="bottom">
<van-picker
v-model="grossWeightTimeSelected"
:columns="timeColumns"
show-toolbar
@confirm="onGrossWeightTimeConfirm"
@cancel="onGrossWeightTimeCancel"
/>
</van-popup>
<!-- 皮重日期选择器 -->
<van-calendar
v-model:show="showTareWeightDateCalendar"
v-model="tareWeightDateSelected"
@confirm="onTareWeightDateConfirm"
/>
<!-- 皮重时间选择器 -->
<van-popup v-model:show="showTareWeightTimePicker" round position="bottom">
<van-picker
v-model="tareWeightTimeSelected"
:columns="timeColumns"
show-toolbar
@confirm="onTareWeightTimeConfirm"
@cancel="onTareWeightTimeCancel"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { weighingApi, type WeighingRecord, type WeighingCreate } from '@/api/weighingApi'
import { companyApi, cargoApi } from '@/api/masterDataApi'
import { inventoryApi } from '@/api/inventoryApi'
import { planApi } from '@/api/plansApi'
import dayjs from 'dayjs'
const router = useRouter()
const route = useRoute()
const isView = computed(() => route.query.mode === 'view')
const isEdit = computed(() => route.query.mode === 'edit' || route.params.id)
// const isAdd = computed(() => !isView.value && !isEdit.value) // 未使用
const saving = ref(false)
const editingRecord = ref<WeighingRecord | null>(null)
const showCargoPicker = ref(false)
const showSupplierPicker = ref(false)
const showConsigneePicker = ref(false)
const companyOptions = ref<{ text: string; value: string }[]>([])
const cargoOptions = ref<{ text: string; value: string }[]>([])
// 搜索相关
const companySearchKeyword = ref('')
const allCompanyOptions = ref<{ text: string; value: string }[]>([])
const filteredCompanyOptions = computed(() => {
if (!companySearchKeyword.value) return [allCompanyOptions.value]
const filtered = allCompanyOptions.value.filter(item =>
item.text.toLowerCase().includes(companySearchKeyword.value.toLowerCase())
)
return [filtered]
})
const cargoSearchKeyword = ref('')
const allCargoOptions = ref<{ text: string; value: string }[]>([])
const filteredCargoOptions = computed(() => {
if (!cargoSearchKeyword.value) return [allCargoOptions.value]
const filtered = allCargoOptions.value.filter(item =>
item.text.toLowerCase().includes(cargoSearchKeyword.value.toLowerCase())
)
return [filtered]
})
// 毛重时间选择器相关状态
const showGrossWeightDateCalendar = ref(false)
const showGrossWeightTimePicker = ref(false)
const grossWeightDateSelected = ref<Date | null>(null)
const grossWeightTimeSelected = ref<number[]>([])
// 皮重时间选择器相关状态
const showTareWeightDateCalendar = ref(false)
const showTareWeightTimePicker = ref(false)
const tareWeightDateSelected = ref<Date | null>(null)
const tareWeightTimeSelected = ref<number[]>([])
// 生成时间选择器的列(小时、分钟、秒)
const generateTimeColumns = () => {
const hours = Array.from({ length: 24 }, (_, i) => ({
text: String(i).padStart(2, '0'),
value: i
}))
const minutes = Array.from({ length: 60 }, (_, i) => ({
text: String(i).padStart(2, '0'),
value: i
}))
const seconds = Array.from({ length: 60 }, (_, i) => ({
text: String(i).padStart(2, '0'),
value: i
}))
return [hours, minutes, seconds]
}
const timeColumns = generateTimeColumns()
const formData = reactive<WeighingCreate>({
vehicleNo: '',
cargoName: '',
businessType: '入库',
supplier: '',
consignee: '',
grossWeight: 0,
grossWeightTime: '',
tareWeight: 0,
tareWeightTime: '',
deduction: 0,
netWeight: 0,
planNo: '',
remarks: '',
purchasePlanId: undefined,
inboundOutboundId: undefined,
weighingPoint: '山峡化工',
plannedQuantity: undefined,
currentQuantity: undefined,
executedQuantity: undefined,
batchNo: ''
})
// 初始化时间选择器的默认值(当前时间)
const initTimePickerDefaults = () => {
const now = dayjs()
const defaultTime = [now.hour(), now.minute(), now.second()]
grossWeightTimeSelected.value = defaultTime
tareWeightTimeSelected.value = defaultTime
grossWeightDateSelected.value = now.toDate()
tareWeightDateSelected.value = now.toDate()
}
initTimePickerDefaults()
// 从路由参数获取数据(从出入库管理跳转过来)
const initFromRoute = async () => {
const inboundOutboundId = route.query.inboundOutboundId as string
if (inboundOutboundId) {
try {
// 获取出入库单数据
const inventory = await inventoryApi.get(Number(inboundOutboundId))
// 填充基础数据(优先使用路由参数,如果没有则使用出入库单数据)
formData.vehicleNo = (route.query.vehicleNo as string) || inventory.vehicleNo
formData.cargoName = (route.query.cargoName as string) || inventory.product
const businessType = (route.query.businessType as '入库' | '出库') || inventory.businessType
formData.businessType = businessType
// 根据业务类型设置供货单位和收货单位
// 入库单:供货单位 = 往来单位,收货单位 = 安庆市山峡化工有限公司
// 出库单:供货单位 = 安庆市山峡化工有限公司,收货单位 = 往来单位
if (route.query.supplier) {
formData.supplier = route.query.supplier as string
} else {
formData.supplier = businessType === '入库' ? inventory.partnerCompany : '安庆市山峡化工有限公司'
}
if (route.query.consignee) {
formData.consignee = route.query.consignee as string
} else {
formData.consignee = businessType === '入库' ? '安庆市山峡化工有限公司' : inventory.partnerCompany
}
formData.planNo = (route.query.planNo as string) || inventory.planNo || ''
// 填充出入库单号(优先使用路由参数,如果没有则使用出入库单数据)
formData.batchNo = (route.query.batchNo as string) || inventory.batchNo || ''
formData.inboundOutboundId = Number(inboundOutboundId)
formData.weighingPoint = '山峡化工'
// 本次量:优先使用路由参数中的quantity(吨),如果没有则使用出入库单的quantity(吨),然后转换为kg
const quantityInTons = route.query.quantity ? Number(route.query.quantity) : inventory.quantity
formData.currentQuantity = quantityInTons * 1000
// 如果有计划ID,获取计划数据(优先使用路由参数)
const purchasePlanId = (route.query.purchasePlanId as string) || inventory.purchasePlanId?.toString()
if (purchasePlanId) {
// 先设置计划ID,确保即使获取计划数据失败也能保存计划ID
formData.purchasePlanId = Number(purchasePlanId)
try {
const plan = await planApi.get(Number(purchasePlanId))
// 计划量就是计划的plannedQuantity(单位:吨),需要转换为kg
formData.plannedQuantity = plan.plannedQuantity * 1000
} catch (error) {
console.error('获取计划数据失败', error)
// 即使获取计划数据失败,计划ID也已经设置了
}
}
} catch (error) {
console.error('加载出入库单数据失败', error)
showToast({ type: 'fail', message: '加载出入库单数据失败' })
}
}
}
const netWeight = computed(() => {
const gross = formData.grossWeight || 0
const tare = formData.tareWeight || 0
const deduction = formData.deduction || 0
const net = gross - tare - deduction
formData.netWeight = net
return net
})
const isNetInvalid = computed(() => {
// 只有当毛重和皮重都填写了,且净重小于等于0时才禁用保存
// 如果只填写了其中一个,或者都没填写,允许保存
if (formData.grossWeight > 0 && formData.tareWeight > 0) {
return netWeight.value <= 0
}
return false
})
const handleGrossWeightChange = () => {
calculateNetWeight()
// 毛重输入后,自动给毛重时间赋值
if (formData.grossWeight && formData.grossWeight > 0) {
formData.grossWeightTime = dayjs().format('YYYY-MM-DD HH:mm:ss')
// 同步更新日期和时间选择器的值
const now = dayjs()
grossWeightDateSelected.value = now.toDate()
grossWeightTimeSelected.value = [now.hour(), now.minute(), now.second()]
} else {
// 如果毛重被清空,时间也清空
formData.grossWeightTime = ''
}
}
const handleTareWeightChange = () => {
calculateNetWeight()
// 皮重输入后,自动给皮重时间赋值
if (formData.tareWeight && formData.tareWeight > 0) {
formData.tareWeightTime = dayjs().format('YYYY-MM-DD HH:mm:ss')
// 同步更新日期和时间选择器的值
const now = dayjs()
tareWeightDateSelected.value = now.toDate()
tareWeightTimeSelected.value = [now.hour(), now.minute(), now.second()]
} else {
// 如果皮重被清空,时间也清空
formData.tareWeightTime = ''
}
}
const calculateNetWeight = () => {
netWeight.value // 触发计算
}
const loadCompanies = async () => {
try {
const companies = await companyApi.list()
allCompanyOptions.value = companies.map(item => ({
text: item.name,
value: item.name
}))
companyOptions.value = allCompanyOptions.value
} catch (error) {
console.error('加载公司列表失败', error)
}
}
const loadCargos = async () => {
try {
const cargos = await cargoApi.list()
allCargoOptions.value = cargos.map(item => ({
text: item.name,
value: item.name
}))
cargoOptions.value = allCargoOptions.value
} catch (error) {
console.error('加载货物列表失败', error)
}
}
const onCargoConfirm = ({ selectedOptions }: any) => {
formData.cargoName = selectedOptions[0].value
showCargoPicker.value = false
}
const onSupplierConfirm = ({ selectedOptions }: any) => {
formData.supplier = selectedOptions[0].value
showSupplierPicker.value = false
}
const onConsigneeConfirm = ({ selectedOptions }: any) => {
formData.consignee = selectedOptions[0].value
showConsigneePicker.value = false
}
const handleCompanySearch = () => {
// 触发 computed 过滤
}
const handleCargoSearch = () => {
// 触发 computed 过滤
}
// 拆分日期时间字符串为日期和时间部分
const splitDateTime = (dateTimeStr: string) => {
if (!dateTimeStr) return { date: null, time: [0, 0, 0] }
const [datePart, timePart] = dateTimeStr.split(' ')
const date = datePart ? dayjs(datePart).toDate() : null
const time = timePart ? timePart.split(':').map(Number) : [0, 0, 0]
return { date, time }
}
// 合并日期和时间为字符串
const mergeDateTime = (date: Date | null, time: number[]): string => {
if (!date) return ''
const [hour, minute, second] = time
return dayjs(date)
.hour(hour)
.minute(minute)
.second(second)
.format('YYYY-MM-DD HH:mm:ss')
}
// 打开毛重日期时间选择器
const handleOpenGrossWeightDateTime = () => {
// 如果已有值,先拆分并设置
if (formData.grossWeightTime) {
const { date, time } = splitDateTime(formData.grossWeightTime)
if (date) {
grossWeightDateSelected.value = date
grossWeightTimeSelected.value = time
}
}
showGrossWeightDateCalendar.value = true
}
// 毛重日期选择确认
const onGrossWeightDateConfirm = (value: Date | Date[]) => {
const date = Array.isArray(value) ? value[0] : value
grossWeightDateSelected.value = date
showGrossWeightDateCalendar.value = false
// 如果没有已选时间,使用当前时间作为默认值
if (grossWeightTimeSelected.value.length === 0) {
const now = dayjs()
grossWeightTimeSelected.value = [now.hour(), now.minute(), now.second()]
}
// 自动弹出时间选择器
showGrossWeightTimePicker.value = true
}
// 毛重时间选择确认
const onGrossWeightTimeConfirm = ({ selectedValues, selectedOptions }: any) => {
// Vant Picker 可能返回 selectedValues 或通过 selectedOptions 获取
const timeValues = selectedValues || selectedOptions?.map((opt: any) => opt.value) || grossWeightTimeSelected.value
grossWeightTimeSelected.value = timeValues
// 合并日期和时间
if (grossWeightDateSelected.value) {
formData.grossWeightTime = mergeDateTime(grossWeightDateSelected.value, timeValues)
}
showGrossWeightTimePicker.value = false
}
// 毛重时间选择取消
const onGrossWeightTimeCancel = () => {
// 如果已选择日期,使用默认时间(当前时间)
if (grossWeightDateSelected.value && !formData.grossWeightTime) {
const now = dayjs()
grossWeightTimeSelected.value = [now.hour(), now.minute(), now.second()]
formData.grossWeightTime = mergeDateTime(grossWeightDateSelected.value, grossWeightTimeSelected.value)
}
showGrossWeightTimePicker.value = false
}
// 打开皮重日期时间选择器
const handleOpenTareWeightDateTime = () => {
// 如果已有值,先拆分并设置
if (formData.tareWeightTime) {
const { date, time } = splitDateTime(formData.tareWeightTime)
if (date) {
tareWeightDateSelected.value = date
tareWeightTimeSelected.value = time
}
}
showTareWeightDateCalendar.value = true
}
// 皮重日期选择确认
const onTareWeightDateConfirm = (value: Date | Date[]) => {
const date = Array.isArray(value) ? value[0] : value
tareWeightDateSelected.value = date
showTareWeightDateCalendar.value = false
// 如果没有已选时间,使用当前时间作为默认值
if (tareWeightTimeSelected.value.length === 0) {
const now = dayjs()
tareWeightTimeSelected.value = [now.hour(), now.minute(), now.second()]
}
// 自动弹出时间选择器
showTareWeightTimePicker.value = true
}
// 皮重时间选择确认
const onTareWeightTimeConfirm = ({ selectedValues, selectedOptions }: any) => {
// Vant Picker 可能返回 selectedValues 或通过 selectedOptions 获取
const timeValues = selectedValues || selectedOptions?.map((opt: any) => opt.value) || tareWeightTimeSelected.value
tareWeightTimeSelected.value = timeValues
// 合并日期和时间
if (tareWeightDateSelected.value) {
formData.tareWeightTime = mergeDateTime(tareWeightDateSelected.value, timeValues)
}
showTareWeightTimePicker.value = false
}
// 皮重时间选择取消
const onTareWeightTimeCancel = () => {
// 如果已选择日期,使用默认时间(当前时间)
if (tareWeightDateSelected.value && !formData.tareWeightTime) {
const now = dayjs()
tareWeightTimeSelected.value = [now.hour(), now.minute(), now.second()]
formData.tareWeightTime = mergeDateTime(tareWeightDateSelected.value, tareWeightTimeSelected.value)
}
showTareWeightTimePicker.value = false
}
const loadData = async () => {
if (isEdit.value && route.params.id) {
try {
const record = await weighingApi.get(Number(route.params.id))
editingRecord.value = record
Object.assign(formData, {
vehicleNo: record.vehicleNo,
cargoName: record.cargoName,
businessType: record.businessType,
supplier: record.supplier,
consignee: record.consignee,
grossWeight: record.grossWeight,
grossWeightTime: record.grossWeightTime,
tareWeight: record.tareWeight,
tareWeightTime: record.tareWeightTime,
deduction: record.deduction || 0,
netWeight: record.netWeight,
planNo: record.planNo || '',
remarks: record.remarks || '',
purchasePlanId: record.purchasePlanId,
inboundOutboundId: record.inboundOutboundId,
weighingPoint: record.weighingPoint || '山峡化工',
plannedQuantity: record.plannedQuantity,
currentQuantity: record.currentQuantity,
executedQuantity: record.executedQuantity,
batchNo: record.batchNo || ''
})
// 拆分并设置日期时间选择器的值(用于编辑时回显)
if (record.grossWeightTime) {
const { date, time } = splitDateTime(record.grossWeightTime)
if (date) {
grossWeightDateSelected.value = date
grossWeightTimeSelected.value = time
}
}
if (record.tareWeightTime) {
const { date, time } = splitDateTime(record.tareWeightTime)
if (date) {
tareWeightDateSelected.value = date
tareWeightTimeSelected.value = time
}
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
router.back()
}
}
}
const handleSubmit = async () => {
// 只验证必填项:车牌号、货物名称、供货单位、收货单位
// 毛重和皮重可以分步保存,不强制要求
if (!formData.vehicleNo || !formData.cargoName || !formData.supplier || !formData.consignee) {
showToast({ type: 'fail', message: '请填写必填项' })
return
}
// 只有当毛重和皮重都填写了,才验证净重
if (formData.grossWeight > 0 && formData.tareWeight > 0 && isNetInvalid.value) {
showToast({ type: 'fail', message: '净重必须大于0' })
return
}
// 提交前确保日期时间格式正确(合并日期和时间)
// 如果用户只选择了日期但没选择时间,且毛重/皮重有值,使用默认时间
if (formData.grossWeight > 0 && grossWeightDateSelected.value && !formData.grossWeightTime) {
formData.grossWeightTime = mergeDateTime(grossWeightDateSelected.value, grossWeightTimeSelected.value)
}
if (formData.tareWeight > 0 && tareWeightDateSelected.value && !formData.tareWeightTime) {
formData.tareWeightTime = mergeDateTime(tareWeightDateSelected.value, tareWeightTimeSelected.value)
}
saving.value = true
try {
if (editingRecord.value) {
await weighingApi.update(editingRecord.value.id, formData)
showToast({ type: 'success', message: '更新成功' })
} else {
await weighingApi.create(formData)
showToast({ type: 'success', message: '创建成功' })
}
// 根据来源跳转到对应页面
const from = route.query.from as string
if (from === 'inventory') {
router.push('/inventory')
} else {
router.push('/weighing')
}
} catch (error: any) {
showToast({ type: 'fail', message: error.message || '操作失败' })
} finally {
saving.value = false
}
}
const handleBack = () => {
// 根据来源跳转到对应页面
const from = route.query.from as string
if (from === 'inventory') {
router.push('/inventory')
} else {
router.push('/weighing')
}
}
onMounted(async () => {
await Promise.all([loadCompanies(), loadCargos()])
if (isEdit.value || isView.value) {
await loadData()
} else {
// 如果是新增模式,且是从出入库管理跳转过来的,初始化数据
await initFromRoute()
}
})
// 打开弹窗时重置搜索关键词
watch(showCargoPicker, (val) => {
if (val) cargoSearchKeyword.value = ''
})
watch(showSupplierPicker, (val) => {
if (val) companySearchKeyword.value = ''
})
watch(showConsigneePicker, (val) => {
if (val) companySearchKeyword.value = ''
})
</script>
<style lang="scss" scoped>
@import '@/assets/styles/detail-page.scss';
.error-message {
color: #ee0a24;
font-size: 12px;
margin-top: 4px;
}
.text-danger {
color: #ee0a24;
}
.company-picker-container {
height: 100%;
display: flex;
flex-direction: column;
}
.search-container {
padding: 16px;
border-bottom: 1px solid #ebedf0;
background: #fff;
:deep(.van-search) {
padding: 0;
}
:deep(.van-search__content) {
background: #f7f8fa;
border-radius: 20px;
}
:deep(.van-field__control) {
font-size: 14px;
}
}
/* 不强制让 van-picker 占满剩余高度,使用 popup 的 max-height 控制总体高度 */
</style>
+758
View File
@@ -0,0 +1,758 @@
<template>
<div class="page">
<div class="header">
<div class="header-left">
<van-icon name="arrow-left" class="back-icon" @click="handleBack" />
<div class="header-title">地磅单管理</div>
</div>
</div>
<div class="search-bar">
<van-search
v-model="searchKeyword"
placeholder="搜索车牌号 / 货物名称"
@search="handleSearch"
@clear="handleSearch"
class="search-input"
/>
<van-button type="primary" size="normal" class="search-button" @click="handleSearch">搜索</van-button>
</div>
<div class="filter-bar">
<div
v-for="item in dateFilterOptions"
:key="item.value"
class="filter-chip"
:class="{ active: dateFilterType === item.value }"
@click="setDateFilter(item.value)"
>
{{ item.label }}
</div>
</div>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div v-if="!loading && tableData.length === 0" class="empty">
<van-empty description="暂无数据" />
</div>
<div v-else class="list">
<div
v-for="item in tableData"
:key="item.id"
class="card"
:class="{ voided: item.isVoided === 1 }"
@click="handleView(item)"
>
<div class="card-header">
<div class="card-title">
<span class="vehicle-no">{{ item.vehicleNo }}</span>
<div class="tags">
<van-tag v-if="item.isVoided === 1" type="danger" plain>已作废</van-tag>
<van-tag :type="item.businessType === '入库' ? 'success' : 'warning'" plain>
{{ item.businessType }}
</van-tag>
</div>
</div>
</div>
<div class="card-body">
<div class="info-row" v-if="item.planNo">
<span class="label">计划单号:</span>
<span class="value">{{ item.planNo }}</span>
</div>
<div class="info-row" v-if="item.batchNo">
<span class="label">出入库单号:</span>
<span class="value">{{ item.batchNo }}</span>
</div>
<div class="info-row">
<span class="label">货物:</span>
<span class="value">{{ item.cargoName }}</span>
</div>
<div class="info-row" v-if="item.currentQuantity !== undefined && item.currentQuantity !== null">
<span class="label">本次量(吨):</span>
<span class="value">{{ (item.currentQuantity / 1000).toFixed(2) }}</span>
</div>
<div class="info-row">
<span class="label">净重(吨):</span>
<span class="value">{{ item.netWeight ? (item.netWeight / 1000).toFixed(2) : '--' }}</span>
</div>
<div class="info-row">
<span class="label">供货单位:</span>
<span class="value">{{ item.supplier || '--' }}</span>
</div>
<div class="info-row">
<span class="label">收货单位:</span>
<span class="value">{{ item.consignee || '--' }}</span>
</div>
<div class="info-row">
<span class="label">日期:</span>
<span class="value">{{ item.inventoryDate || (item.grossWeightTime ? item.grossWeightTime.split(' ')[0] : '--') }}</span>
</div>
</div>
<div class="card-footer">
<van-button size="small" type="primary" plain @click.stop="handleViewRelated(item)">查看</van-button>
<van-button size="small" type="primary" plain @click.stop="handleEdit(item)">编辑</van-button>
<van-button
v-if="item.isVoided === 0 && isWeighingRecordToday(item)"
size="small"
type="warning"
plain
@click.stop="handleVoid(item.id)"
>
作废
</van-button>
</div>
</div>
</div>
</van-list>
<!-- 浮动新增按钮 -->
<div class="fab-button" @click="handleAdd">
<van-icon name="plus" />
</div>
<!-- 关联数据弹窗 -->
<van-dialog
v-model:show="relatedModalOpen"
title="查看关联数据"
show-cancel-button
cancel-button-text="关闭"
confirm-button-text=""
:show-confirm-button="false"
class="related-dialog"
>
<div v-if="loadingRelated" class="loading-container">
<van-loading type="spinner" />
</div>
<div v-else class="related-content">
<!-- 计划 -->
<div v-if="relatedData.plan" class="related-section">
<div class="related-header">
<van-tag type="primary">计划</van-tag>
</div>
<div class="related-info">
<div class="related-row">
<span class="label">计划号:</span>
<span class="value">{{ relatedData.plan.planNo }}</span>
</div>
<div class="related-row">
<span class="label">业务类型:</span>
<van-tag :type="relatedData.plan.businessType === '入库' ? 'success' : 'warning'" size="medium">
{{ relatedData.plan.businessType }}
</van-tag>
</div>
<div class="related-row">
<span class="label">往来单位:</span>
<span class="value">{{ relatedData.plan.customerName }}</span>
</div>
<div class="related-row">
<span class="label">产品:</span>
<span class="value">{{ relatedData.plan.product }}</span>
</div>
<div class="related-row">
<span class="label">计划日期:</span>
<span class="value">{{ relatedData.plan.planDate }}</span>
</div>
<div class="related-row">
<span class="label">起始日期:</span>
<span class="value">{{ relatedData.plan.startDate }}</span>
</div>
<div class="related-row">
<span class="label">截止日期:</span>
<span class="value">{{ relatedData.plan.endDate }}</span>
</div>
<div class="related-row">
<span class="label">执行状态:</span>
<van-tag
:type="relatedData.plan.executionStatus === '已入库' || relatedData.plan.executionStatus === '已出库' ? 'success' : 'warning'"
size="medium"
>
{{ relatedData.plan.executionStatus }}
</van-tag>
</div>
<div class="related-row">
<span class="label">计划量(吨):</span>
<span class="value">{{ relatedData.plan.plannedQuantity }}</span>
</div>
<div class="related-row">
<span class="label">已执行量(吨):</span>
<span class="value">{{ ((relatedData.plan.executedQuantity || 0) / 1000).toFixed(2) }}</span>
</div>
<div class="related-row" v-if="relatedData.plan.remarks">
<span class="label">备注:</span>
<span class="value">{{ relatedData.plan.remarks }}</span>
</div>
</div>
</div>
<van-empty v-else-if="!relatedData.inventory" description="暂无关联的计划" />
<!-- 分割线 -->
<van-divider v-if="relatedData.plan && relatedData.inventory" />
<!-- 出入库单 -->
<div v-if="relatedData.inventory" class="related-section">
<div class="related-header">
<van-tag type="default">出入库单</van-tag>
</div>
<div class="related-info">
<div class="related-row">
<span class="label">出入库单号:</span>
<span class="value">{{ relatedData.inventory.batchNo }}</span>
</div>
<div class="related-row">
<span class="label">日期:</span>
<span class="value">{{ relatedData.inventory.date }}</span>
</div>
<div class="related-row">
<span class="label">往来单位:</span>
<span class="value">{{ relatedData.inventory.partnerCompany }}</span>
</div>
<div class="related-row">
<span class="label">业务类型:</span>
<van-tag :type="relatedData.inventory.businessType === '入库' ? 'success' : 'warning'" size="medium">
{{ relatedData.inventory.businessType }}
</van-tag>
</div>
<div class="related-row">
<span class="label">产品:</span>
<span class="value">{{ relatedData.inventory.product }}</span>
</div>
<div class="related-row">
<span class="label">车牌号:</span>
<span class="value">{{ relatedData.inventory.vehicleNo }}</span>
</div>
<div class="related-row">
<span class="label">数量(吨):</span>
<span class="value">{{ relatedData.inventory.quantity }}</span>
</div>
<div class="related-row">
<span class="label">状态:</span>
<van-tag
:type="
relatedData.inventory.status === '已完成'
? 'success'
: relatedData.inventory.status === '待入库' || relatedData.inventory.status === '待出库'
? 'warning'
: 'default'
"
size="medium"
>
{{ relatedData.inventory.status || '待处理' }}
</van-tag>
</div>
<div class="related-row" v-if="relatedData.inventory.remarks">
<span class="label">备注:</span>
<span class="value">{{ relatedData.inventory.remarks }}</span>
</div>
</div>
</div>
<van-empty v-else-if="!relatedData.plan" description="暂无关联的出入库单" />
</div>
</van-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import dayjs from 'dayjs'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { weighingApi, type WeighingRecord } from '@/api/weighingApi'
import type { Plan } from '@/api/plansApi'
import type { InventoryRecord } from '@/api/inventoryApi'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const finished = ref(false)
const tableData = ref<WeighingRecord[]>([])
const allData = ref<WeighingRecord[]>([]) // 存储所有数据
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const dateFilterType = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'>('all')
const dateFilterOptions = [
{ label: '全部', value: 'all' },
{ label: '今日', value: 'today' },
{ label: '明日', value: 'tomorrow' },
{ label: '昨日', value: 'yesterday' },
{ label: '本周', value: 'week' }
] as const
// 关联数据相关
const relatedModalOpen = ref(false)
const loadingRelated = ref(false)
const relatedData = ref<{ plan: Plan | null, inventory: InventoryRecord | null }>({ plan: null, inventory: null })
const loadData = async (reset = false) => {
if (reset) {
currentPage.value = 1
allData.value = []
tableData.value = []
finished.value = false
}
loading.value = true
try {
// 如果是第一页或搜索,重新获取所有数据
if (reset || currentPage.value === 1) {
const params: any = {}
if (searchKeyword.value) {
params.vehicleNo = searchKeyword.value
}
// 按毛重时间日期过滤(和 web 端一致)
const today = dayjs()
if (dateFilterType.value === 'today') {
const d = today.format('YYYY-MM-DD')
params.dateFrom = d
params.dateTo = d
} else if (dateFilterType.value === 'tomorrow') {
const t = today.add(1, 'day').format('YYYY-MM-DD')
params.dateFrom = t
params.dateTo = t
} else if (dateFilterType.value === 'yesterday') {
const y = today.subtract(1, 'day').format('YYYY-MM-DD')
params.dateFrom = y
params.dateTo = y
} else if (dateFilterType.value === 'week') {
// 本周:周一到周日
const weekday = today.day() === 0 ? 7 : today.day()
const monday = today.subtract(weekday - 1, 'day').format('YYYY-MM-DD')
const sunday = today.add(7 - weekday, 'day').format('YYYY-MM-DD')
params.dateFrom = monday
params.dateTo = sunday
}
const records = await weighingApi.list(params)
// 按日期倒序排列(日期取 inventoryDate | 毛重日期 | 创建日期)
const getRecordDate = (r: WeighingRecord) =>
r.inventoryDate ||
(r.grossWeightTime ? r.grossWeightTime.split(' ')[0] : '') ||
(r.createdAt ? r.createdAt.split(' ')[0] : '') ||
''
allData.value = records.slice().sort((a, b) => getRecordDate(b).localeCompare(getRecordDate(a)))
}
// 分页切片
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
const newData = allData.value.slice(start, end)
if (reset || currentPage.value === 1) {
tableData.value = newData
} else {
tableData.value.push(...newData)
}
// 判断是否加载完成
if (tableData.value.length >= allData.value.length) {
finished.value = true
} else {
currentPage.value++
}
} catch (error) {
showToast({ type: 'fail', message: '加载数据失败' })
finished.value = true
} finally {
loading.value = false
}
}
const onLoad = () => {
if (!finished.value) {
loadData()
}
}
const handleSearch = () => {
loadData(true) // 重置并重新加载
}
const setDateFilter = (type: (typeof dateFilterOptions)[number]['value']) => {
if (dateFilterType.value === type) return
dateFilterType.value = type
loadData(true)
}
const handleBack = () => {
// 如果是从出入库管理跳转过来的,返回到出入库管理页面
if (route.query.from === 'inventory') {
router.push('/inventory')
} else {
router.push('/dashboard')
}
}
const handleAdd = () => {
// 构建查询参数
const query: any = {}
if (route.query.inboundOutboundId) query.inboundOutboundId = route.query.inboundOutboundId
if (route.query.vehicleNo) query.vehicleNo = route.query.vehicleNo
if (route.query.cargoName) query.cargoName = route.query.cargoName
if (route.query.businessType) query.businessType = route.query.businessType
if (route.query.batchNo) query.batchNo = route.query.batchNo
if (route.query.quantity) query.quantity = route.query.quantity
// 如果是从出入库管理跳转过来的,保留from参数;否则设置为weighing
query.from = route.query.from || 'weighing'
router.push({ path: '/weighing/detail', query })
}
const handleView = (record: WeighingRecord) => {
router.push(`/weighing/detail/${record.id}?mode=view`)
}
const handleEdit = (record: WeighingRecord) => {
router.push(`/weighing/detail/${record.id}?mode=edit`)
}
/** 获取地磅单记录日期(inventoryDate | 毛重日期 | 创建日期) */
const getWeighingRecordDate = (item: WeighingRecord) => {
return (
item.inventoryDate ||
(item.grossWeightTime ? item.grossWeightTime.split(' ')[0] : '') ||
(item.createdAt ? item.createdAt.split(' ')[0] : '') ||
''
)
}
/** 判断是否为当天记录(仅当天可作废) */
const isWeighingRecordToday = (item: WeighingRecord) => {
const dateStr = getWeighingRecordDate(item)
return dateStr === dayjs().format('YYYY-MM-DD')
}
const handleVoid = (id: number) => {
showDialog({
title: '确认作废',
message: '确定要作废这条地磅单吗?',
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonText: '确认'
})
.then(async () => {
await weighingApi.void(id)
showToast({ type: 'success', message: '作废成功' })
loadData(true) // 重新加载数据
})
.catch(() => {})
}
const handleViewRelated = async (record: WeighingRecord) => {
loadingRelated.value = true
relatedModalOpen.value = true
try {
const data = await weighingApi.getRelated(record.id)
relatedData.value = data
} catch (error) {
showToast({ type: 'fail', message: '加载关联数据失败' })
relatedModalOpen.value = false
} finally {
loadingRelated.value = false
}
}
onMounted(() => {
loadData(true) // 初始加载
if (route.query.inboundOutboundId) {
// 从出入库管理跳转过来,自动打开新增页面
handleAdd()
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 16px;
}
.header {
background: #fff;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e5e7eb;
&-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
&-title {
font-size: 20px;
font-weight: 600;
color: #111827;
}
.back-icon {
font-size: 20px;
color: #111827;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
}
.search-bar {
padding: 0px 16px;
background: #fff;
border-bottom: 0;
display: flex;
align-items: center;
gap: 8px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 4px;
background: #fff;
border-bottom: 1px solid #f3f4f6;
overflow-x: auto;
}
.filter-chip {
flex-shrink: 0;
padding: 4px 10px;
font-size: 13px;
border-radius: 999px;
border: 1px solid #d1d5db;
color: #4b5563;
background-color: #fff;
line-height: 1.4;
}
.filter-chip.active {
border-color: #52b788;
background-color: #52b788;
color: #fff;
}
.search-input {
flex: 1;
:deep(.van-search__content) {
padding: 0px 0px;
}
:deep(.van-field__control) {
font-size: 14px;
}
}
.search-button {
flex-shrink: 0;
min-width: 50px;
height: 32px;
padding: 0 12px;
font-size: 14px;
}
.list {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&.voided {
opacity: 0.7;
border: 1px solid #fecaca;
}
}
.card-header {
margin-bottom: 12px;
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.vehicle-no {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.tags {
display: flex;
gap: 8px;
}
.card-body {
margin-bottom: 12px;
}
.info-row {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
.label {
color: #6b7280;
min-width: 100px;
}
.value {
color: #111827;
flex: 1;
}
}
.card-footer {
display: flex;
justify-content: center;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
flex-wrap: wrap;
}
.empty {
padding: 40px 0;
text-align: center;
}
.modal {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.modal-actions {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.error-tip {
color: #ff4d4f;
font-size: 12px;
padding: 8px 16px;
background: #fff2f0;
border-radius: 4px;
margin-top: 8px;
}
.loading-container {
padding: 40px;
text-align: center;
}
.related-content {
max-height: 60vh;
overflow-y: auto;
padding: 16px;
}
.related-section {
padding: 12px;
background: #f9fafb;
border-radius: 8px;
margin-bottom: 16px;
}
.related-header {
margin-bottom: 8px;
}
.related-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.related-row {
display: flex;
align-items: center;
font-size: 14px;
.label {
color: #6b7280;
min-width: 100px;
}
.value {
color: #111827;
flex: 1;
}
}
:deep(.related-dialog) {
.van-dialog__content {
padding: 0;
}
}
.fab-button {
position: fixed;
right: 16px;
bottom: 50px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #52B788;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(82, 183, 136, 0.4);
cursor: pointer;
z-index: 100;
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
}
.van-icon {
font-size: 24px;
}
}
</style>
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}