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>
No files matched your search
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
.env.local
|
||||
.env.*.local
|
||||
.auto-import.json
|
||||
src/types/*.d.ts
|
||||
|
||||
|
||||
.env
|
||||
@@ -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
|
||||
|
||||
@@ -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操作,核心功能一致
|
||||
- 移动端适配良好,交互体验符合移动端习惯
|
||||
|
||||
@@ -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. 数据懒加载(只加载可见区域的数据)
|
||||
|
||||
@@ -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. 添加骨架屏加载效果
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 122 KiB |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 699 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// 通用组合函数(移动端简化版本)
|
||||
export function useCommon() {
|
||||
const refresh = () => {
|
||||
// 刷新页面逻辑
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return {
|
||||
refresh
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// 主题组合函数(移动端简化版本)
|
||||
export function useTheme() {
|
||||
const switchThemeStyles = (theme: string) => {
|
||||
// 切换主题逻辑
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
}
|
||||
|
||||
return {
|
||||
switchThemeStyles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// 节日礼花、文字滚动配置
|
||||
|
||||
// 节日列表配置
|
||||
export const festivalList = [
|
||||
{
|
||||
date: '2025-01-01',
|
||||
name: '元旦',
|
||||
image: '',
|
||||
scrollText: '新年快乐!祝您在2025年万事如意,事业腾飞,阖家幸福,好运连连!'
|
||||
},
|
||||
{
|
||||
date: '2024-12-25',
|
||||
name: '圣诞节',
|
||||
image: '',
|
||||
scrollText: 'Merry Christmas!祝您圣诞快乐,愿节日的欢乐与祝福如雪花般纷至沓来!'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 简单的国际化函数
|
||||
* 目前返回原始字符串,后续可以扩展为完整的 i18n 实现
|
||||
*/
|
||||
export const $t = (key: string): string => {
|
||||
// 简单的实现,直接返回 key
|
||||
// 后续可以集成 vue-i18n 或其他 i18n 库
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,9 @@
|
||||
ECharts 配置项参考地址
|
||||
|
||||
地址:https://echarts.apache.org/examples/zh/editor.html?c=line-simple
|
||||
|
||||
示例编辑:可以在这里方便快捷的配置
|
||||
|
||||
完整代码:配置后的所有代码
|
||||
|
||||
配置项:根据需求选择配置项配置
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum ApiStatus {
|
||||
success = 200
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 ...
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 全局事件总线,用于全局事件的发布与订阅
|
||||
* 用法:
|
||||
* mittBus.on('event', callback)
|
||||
* mittBus.emit('event', data)
|
||||
*/
|
||||
import mitt from 'mitt'
|
||||
const mittBus = mitt()
|
||||
export default mittBus
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 配置
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||