This commit is contained in:
zlwu committed 2026-07-17 21:59:47 +08:00
1 parent 8be55c08e9
commit a903fcf6d4
75 files changed
+9547 -124

No files matched your search

+2 -1
View File
@@ -40,7 +40,8 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
- **Sidebar menu icons**: First-level menu items SHALL show icons before the name; second-level and deeper items SHALL NOT show icons (text only). Implemented via `level === 0` check in `SidebarSubmenu.vue`.
- **Tab bar (WorkTab) icons**: Tab bar tabs SHALL NOT show icons — only text titles. `meta.icon` is for sidebar only.
- **Form field annotations**: Field hints (e.g. "数值越小越靠前") SHALL appear as small text after the label via `<template #label>字段名<span class="form-label-tip">注释</span></template>`, NOT beside the input control. Style class: `.form-label-tip` (12px, `--mint-text-muted`).
- **Attachment upload card layout**: Upload trigger SHALL be a card (140×96px, dashed border `#a7f3d0`) inline as the first item in the same flex row as uploaded attachment cards; select file → immediate upload via `fetchUploadAttachment`; preview/download/delete on cards; hint below the row. Authority: `ProtocolFormDialog.vue` «协议附件». See `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md` §「附件上传卡片化布局」for full Vue template + SCSS + interaction rules.
- **Attachment upload card layout** (`web-admin`): Upload trigger SHALL be a card (140×96px, dashed border `#a7f3d0`) inline as the first item in the same flex row as uploaded attachment cards; select file → immediate upload via `fetchUploadAttachment`; preview/download/delete on cards; hint below the row. Authority: `ProtocolFormDialog.vue` «协议附件». See `openspec/.../ui-conventions/spec.md` §「附件上传卡片化布局」.
- **Miniprogram photo evidence upload** (`miniprogram`, baseline): Photo/album images only — `uni.chooseImage` + `uni.uploadFile` (`name: file`, `formData.folder`, Bearer header) → collect `data.url` → business API submits `imageUrls`. SHALL NOT use Web `el-upload`/`FormData`+`request`, Base64-in-JSON, or document pickers (`chooseMessageFile`). Upload path SHALL be in `API_ENCRYPT_EXCLUDE_PATHS`. Authority: `openspec/.../ui-conventions` §「小程序拍照凭证上传(基线)」+ `api-conventions` §「小程序拍照凭证上传接口」; reference pattern from `sqxp-wx` delivery/supplier evidence upload.
- **Button icons**: All `web-admin` `el-button` with visible text labels SHALL include a leading `<el-icon>` from `@element-plus/icons-vue` (explicit import). See `openspec/.../ui-conventions/spec.md` §「按钮须携带图标」.
- **Sort fields (sort_order/orderNum)**: web-admin 列表、新增弹窗、编辑弹窗中默认不展示排序字段。后续根据功能需要自行添加。
- **Status/conclusion/type fields as Tags**: All status, conclusion, and type fields in table columns and view dialogs SHALL render as `<el-tag>` with semantic colors. Tables: use `<el-tag :type="..." size="small">` in column templates. SystemViewDialog: use `tagType` property on `ViewField`. Semantic colors: `success`=正常/启用/已完成/通过, `warning`=进行中/待确认/已过期, `danger`=异常/已作废/拒绝/不通过, `info`=待执行/待生效/停用/默认, `primary`=特殊分类/来源类型. SHALL NOT use plain text or custom `<span class="tag">`. See `openspec/.../ui-conventions/spec.md` "状态、结论与类型字段使用 Tag 标签展示".
+1 -1
View File
@@ -1 +1 @@
74168
27977
+1 -1
View File
@@ -1 +1 @@
{"/Users/zlwu/work/00code/shanxia2.0/backend/start-prod.sh":{"size":7357,"mtime_ns":1782639355476060453,"hash":"ca89869446be74a2bf2367b3b28f448580220859a39a84ad9613ac5fe35a8433"},"/Users/zlwu/work/00code/shanxia2.0/backend/start.sh":{"size":9548,"mtime_ns":1780114661446983244,"hash":"4d632863e8a0cd81194b491d83a0218a4157435ae812dec76a6f6197b4d48e57"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/__init__.py":{"size":21,"mtime_ns":1780113982281973984,"hash":"60a04ff08fd97084e83ea809fd13feda6d2848be20ebf309e31fc1dad7523230"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/main.py":{"size":6280,"mtime_ns":1782790852770448643,"hash":"8f9da799977bee7134d87a26ff8434ac5ef8eabb0b7368dd537eee49ad7fd00b"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/tasks/__init__.py":{"size":2207,"mtime_ns":1783208067881286091,"hash":"313e6da0e91d743ce891dd283458c79b50b24c4d909dc96ca7b3fd9d2bfa5013"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/auth.py":{"size":7457,"mtime_ns":1782790841500516545,"hash":"34bac56e1c2cb6215f4b5fa2dc32d77d4b4abca51428819eebf4afd269cc51cf"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/api_crypto.py":{"size":1870,"mtime_ns":1780108930724444498,"hash":"c04b748aea86890a5971f749523c7b17dbad1ddd6f9f7f07a5679d159c984e33"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/login_crypto.py":{"size":3858,"mtime_ns":1780108930725155710,"hash":"b82d51afabf2293a9cb1fb2e5f994c383948e7f47ea95fe674686b4f3f3342b1"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/config.py":{"size":6958,"mtime_ns":1784032424448146538,"hash":"c2fb44aedb880733de28afa7f92d2c1c681eb600f821bbeba2bcda3933e5bc37"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/wechat_pay.py":{"size":23585,"mtime_ns":1780108930725768130,"hash":"3d52265ce65d21e7103f94afa1e8547aee0823b5ea6d65c50317436ac81fb4fc"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/api_request_log_middleware.py":{"size":11971,"mtime_ns":1782701669198208175,"hash":"0b84c32828939e6102bb3647691fa8f04b1f68e0e0e9f28127374cc44e19a11c"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/database.py":{"size":1510,"mtime_ns":1782790847265601586,"hash":"8bc9c8ae62e2be2c6920036582ba448dfcd1711c41cebb62274ff6bc6ee14b0b"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/security.py":{"size":728,"mtime_ns":1780108930725470004,"hash":"df0f01115064f7b8de1c81dbe9a6db39360c236610b68c33b3234a51d7f0025b"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/request_payload.py":{"size":2842,"mtime_ns":1780108930725350836,"hash":"e9937151530ab1417ef297aef59111977725cf90be2a8fa76c00a0618789aba0"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/__init__.py":{"size":61,"mtime_ns":1780108930724312414,"hash":"3dd1842ee59b0c804176b03b4b8a323bd4a252b7feb1bbc32db64f3a8928570e"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/response.py":{"size":1824,"mtime_ns":1780108930725412462,"hash":"f1739352d65ea8287d76e282a59553aa8d63c97e35764c64ac378aa5bd9c347e"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/menu_guard.py":{"size":1611,"mtime_ns":1780108930725223002,"hash":"3e26077288c3620a4780038b891a326206b45bffe00aca545bdae30172487235"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/db_collation.py":{"size":548,"mtime_ns":1780108930725025043,"hash":"0c680e25a18244ca839a7de6ed1f4313b33bc8da118d89c463aee1b6af00c6f9"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/context.py":{"size":421,"mtime_ns":1780108930724824458,"hash":"83a10aee67162374993c10a30bf8bb8bd9c4b35cf48f42c832415ccebd91660f"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/utils.py":{"size":1498,"mtime_ns":1780108930725607296,"hash":"09323693661c1d4560458b86a954128ccff82bbaf6522da0b44f24da99ddf9ea"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/storage.py":{"size":14141,"mtime_ns":1782855136810943216,"hash":"3ae2c413c10898a8adb3d6773870bafbfbc18c739c10e0e8b4f765b4b5ca6c08"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/order_status_compat.py":{"size":2828,"mtime_ns":1780108930725284669,"hash":"5349e5a0745ee6734c4a995337779307adefc46f453906171988a77de2081540"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/exceptions.py":{"size":4946,"mtime_ns":1780108930725084335,"hash":"79c997d964bca1a4f12f2b92fb1f181fdb9e2b3cf72eae39f4cc164d23794fd7"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/data_scope.py":{"size":8043,"mtime_ns":1782648925694296304,"hash":"626dff86937e5cacec634ecbc61cd53d3763d760223a1d4ec7cd11f64e59a220"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/admin_request_json.py":{"size":2669,"mtime_ns":1780108930724378914,"hash":"4f68b3cf84291e7bddad6ecf4bb4d29ca5e1ec246a9f7f9c4cf5909d0ec1174a"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/api_crypto_middleware.py":{"size":10876,"mtime_ns":1782790001926627098,"hash":"b39f128b6612589ea6ac5babd30752e9585b3af07b82bb1ad53c9e3c0d90816f"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/wechat_message.py":{"size":11194,"mtime_ns":1780108930725684255,"hash":"5086070deb14d99d1a69e419b2299ec4Line truncated
{"/Users/zlwu/work/00code/shanxia2.0/backend/start-prod.sh":{"size":7357,"mtime_ns":1782639355476060453,"hash":"ca89869446be74a2bf2367b3b28f448580220859a39a84ad9613ac5fe35a8433"},"/Users/zlwu/work/00code/shanxia2.0/backend/start.sh":{"size":9548,"mtime_ns":1780114661446983244,"hash":"4d632863e8a0cd81194b491d83a0218a4157435ae812dec76a6f6197b4d48e57"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/__init__.py":{"size":21,"mtime_ns":1780113982281973984,"hash":"60a04ff08fd97084e83ea809fd13feda6d2848be20ebf309e31fc1dad7523230"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/main.py":{"size":6280,"mtime_ns":1782790852770448643,"hash":"8f9da799977bee7134d87a26ff8434ac5ef8eabb0b7368dd537eee49ad7fd00b"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/tasks/__init__.py":{"size":2207,"mtime_ns":1783208067881286091,"hash":"313e6da0e91d743ce891dd283458c79b50b24c4d909dc96ca7b3fd9d2bfa5013"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/auth.py":{"size":7457,"mtime_ns":1782790841500516545,"hash":"34bac56e1c2cb6215f4b5fa2dc32d77d4b4abca51428819eebf4afd269cc51cf"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/api_crypto.py":{"size":1870,"mtime_ns":1780108930724444498,"hash":"c04b748aea86890a5971f749523c7b17dbad1ddd6f9f7f07a5679d159c984e33"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/login_crypto.py":{"size":3858,"mtime_ns":1780108930725155710,"hash":"b82d51afabf2293a9cb1fb2e5f994c383948e7f47ea95fe674686b4f3f3342b1"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/config.py":{"size":6958,"mtime_ns":1784032424448146538,"hash":"c2fb44aedb880733de28afa7f92d2c1c681eb600f821bbeba2bcda3933e5bc37"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/wechat_pay.py":{"size":23585,"mtime_ns":1780108930725768130,"hash":"3d52265ce65d21e7103f94afa1e8547aee0823b5ea6d65c50317436ac81fb4fc"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/api_request_log_middleware.py":{"size":11971,"mtime_ns":1782701669198208175,"hash":"0b84c32828939e6102bb3647691fa8f04b1f68e0e0e9f28127374cc44e19a11c"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/database.py":{"size":1510,"mtime_ns":1782790847265601586,"hash":"8bc9c8ae62e2be2c6920036582ba448dfcd1711c41cebb62274ff6bc6ee14b0b"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/security.py":{"size":728,"mtime_ns":1780108930725470004,"hash":"df0f01115064f7b8de1c81dbe9a6db39360c236610b68c33b3234a51d7f0025b"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/request_payload.py":{"size":2842,"mtime_ns":1780108930725350836,"hash":"e9937151530ab1417ef297aef59111977725cf90be2a8fa76c00a0618789aba0"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/__init__.py":{"size":61,"mtime_ns":1780108930724312414,"hash":"3dd1842ee59b0c804176b03b4b8a323bd4a252b7feb1bbc32db64f3a8928570e"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/response.py":{"size":1824,"mtime_ns":1780108930725412462,"hash":"f1739352d65ea8287d76e282a59553aa8d63c97e35764c64ac378aa5bd9c347e"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/menu_guard.py":{"size":1611,"mtime_ns":1780108930725223002,"hash":"3e26077288c3620a4780038b891a326206b45bffe00aca545bdae30172487235"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/db_collation.py":{"size":548,"mtime_ns":1780108930725025043,"hash":"0c680e25a18244ca839a7de6ed1f4313b33bc8da118d89c463aee1b6af00c6f9"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/context.py":{"size":421,"mtime_ns":1780108930724824458,"hash":"83a10aee67162374993c10a30bf8bb8bd9c4b35cf48f42c832415ccebd91660f"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/utils.py":{"size":1498,"mtime_ns":1780108930725607296,"hash":"09323693661c1d4560458b86a954128ccff82bbaf6522da0b44f24da99ddf9ea"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/storage.py":{"size":14141,"mtime_ns":1782855136810943216,"hash":"3ae2c413c10898a8adb3d6773870bafbfbc18c739c10e0e8b4f765b4b5ca6c08"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/order_status_compat.py":{"size":2828,"mtime_ns":1780108930725284669,"hash":"5349e5a0745ee6734c4a995337779307adefc46f453906171988a77de2081540"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/exceptions.py":{"size":4946,"mtime_ns":1780108930725084335,"hash":"79c997d964bca1a4f12f2b92fb1f181fdb9e2b3cf72eae39f4cc164d23794fd7"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/data_scope.py":{"size":8043,"mtime_ns":1782648925694296304,"hash":"626dff86937e5cacec634ecbc61cd53d3763d760223a1d4ec7cd11f64e59a220"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/admin_request_json.py":{"size":2669,"mtime_ns":1780108930724378914,"hash":"4f68b3cf84291e7bddad6ecf4bb4d29ca5e1ec246a9f7f9c4cf5909d0ec1174a"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/api_crypto_middleware.py":{"size":10876,"mtime_ns":1782790001926627098,"hash":"b39f128b6612589ea6ac5babd30752e9585b3af07b82bb1ad53c9e3c0d90816f"},"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/wechat_message.py":{"size":11194,"mtime_ns":1780108930725684255,"hash":"5086070deb14d99d1a69e419b2299ec4Line truncated
+254
View File
@@ -0,0 +1,254 @@
# 山峡小程序 · 四模块开发清单
> **用途**:明确移动端开发范围、分期节奏与进度。阶段性完成后请把对应项从 `[ ]` 改为 `[x]`,并更新文首「进度总览」。
> **权威 UI**:`mobile-ui/shanxia-mp-*.html` + `docs/miniprogram-ui-spec-sanqiang.md` + Pattern 四页。
> **附件基线**:仅拍照凭证 → OpenSpec `zkxw-engineering-standards`(ui/api/architecture「小程序拍照凭证上传」)。
> **更新日期**:2026-07-16
---
## 进度总览
| 阶段 | 说明 | 状态 |
|------|------|------|
| P0 基础设施 | 登录、Tab 壳、工作台、我的、上传工具、`/api/mini` 约定 | **基本完成**(见 §2;Pattern 组件随 P1 抽取) |
| P1 CRM | 线索 / 商机 / 客户 / 公海 / 报价 / 合同 | **基本完成**(列表/详情已接 `/web/crm/*`;联调冒烟待做) |
| P2 报销 | 待办、报销/借款申请与审批、我的单据 | **基本完成**(已接 `/reimburses` `/advances`;联调冒烟待做) |
| P3 地磅 | 仪表盘、计划、地磅单、出入库 | **基本完成**(已接 `/dashboard` `/plans` `/weighing` `/inventory`;联调冒烟待做) |
| P4 运输 | 计划、归档、购买证、运费结算/审批 | **基本完成**(已接 `/transport/*`;联调冒烟待做) |
| P5 收尾 | 联调、合法域名、发版检查、文档回写 | **接口冒烟完成**;真机 UI / 域名待做 |
**图例**:`[ ]` 未开始 · `[~]` 进行中 · `[x]` 已完成 · `[-]` 本期不做(灰卡/明确排除)
---
## 1. 范围边界
### 1.1 本期要做(Web 一级菜单 → 小程序)
| 模块 | Web 路径 | 小程序入口 | Demo |
|------|----------|------------|------|
| CRM 管理 | `/crm` | 工作台 → CRM 区块 | `shanxia-mp-crm-demo.html` |
| 报销管理 | `/bx` | 工作台 → 报销区块 | `shanxia-mp-bx-demo.html` |
| 地磅管理 | `/db` | 工作台 → 地磅区块 | `shanxia-mp-db-demo.html` |
| 运输管理 | `/pl` | 工作台 → 运输区块 | `shanxia-mp-pl-demo.html` |
### 1.2 明确不做
| 项 | 说明 |
|----|------|
| 工作流管理 `/workflow` | 不单独做一级模块;报销审批走 BX 业务接口 |
| 系统管理 `/system` | 租户/菜单/角色等仅 Web |
| Tab「首页」「待办」业务内容 | Demo 标 Phase 2:壳可占位,本期不开发实质功能 |
| Web 专属配置页 | 如 BX 费用项目/工作流配置、CRM 产品主数据维护等(见各模块灰卡) |
| 小程序文档类附件 | PDF/Word 等;仅拍照凭证(见 OpenSpec) |
### 1.3 信息架构(与 Demo 一致)
```
登录 → 默认「工作台」Tab
Tab:首页(占位) | 待办(占位) | 工作台★ | 我的
工作台:四大模块菜单卡片 → 业务列表/详情/表单(无 TabBar,返回工作台)
```
---
## 2. P0 基础设施
> 目标:四模块可挂载的统一壳 + 鉴权 + 上传 + 列表/详情模式对齐 Pattern。
### 2.1 工程与壳
- [x] uni-app 工程存在(`miniprogram/`,自 sqxp 精简)
- [x] 登录页 + 协议/隐私 + RSA 登录对接 Web 公共鉴权
- [x] 占位首页 `pages/home`
- [x] TabBar:首页 / 待办 / 工作台 / 我的(登录默认进工作台)
- [x] 工作台页:四模块 + 菜单卡片(对齐 `shanxia-mp-workbench-demo.html`;灰卡禁用)
- [x] 「我的」:展示用户信息、退出登录(对齐 `shanxia-mp-mine-demo.html`)
- [x] 首页 / 待办:占位页即可(本期不做业务)
- [x] 全局样式 Token 对齐 `shanxia-mp-base.css` / UI 规范(`styles/mp-shell.scss`)
- [~] Pattern 落地:列表 / 查看 / 表单 / 反馈 — Demo 已有;可复用组件随 **P1** 首业务页抽取(见 `pages/common/patterns-readme.md`)
### 2.2 网络与上传
- [x] `config/api.js`:区分环境基址;真机局域网说明(注释)
- [x] 业务 API 路径约定:P0 认证复用 `/web/auth/*`;上传复用 `/web/admin/attachment/upload`;业务 `/api/mini/*` 随模块补充
- [x] `utils/upload.js`:`chooseImage` + `uploadFile` + URL 收集(OpenSpec 基线)
- [x] 上传路径已在服务端 `API_ENCRYPT_EXCLUDE_PATHS`(`/api/web/admin/attachment/upload`)
- [ ] 微信合法域名(request / uploadFile)清单与发版脚本核对(发版前 / P5)
### 2.3 菜单与权限
- [x] 工作台菜单前端写死四模块(`config/workbench-menus.js`,与 Demo 灰卡一致)
- [ ] 按角色权限过滤/置灰(有菜单权限接口后再接;P0 全量展示启用卡)
---
## 3. P1 CRM 管理
> Demo 启用菜单 = 本期范围;灰卡 = 本期不做。
| 菜单 | 本期 | 建议能力 | 进度 |
|------|------|----------|------|
| 线索管理 | 要做 | 列表/筛选 Tab、详情、新增/跟进、分配、转商机 | [x] 列表/详情/新增/跟进/转客户/无效;分配待 Web |
| 商机管理 | 要做 | 列表、详情、跟进/阶段(看板可二期) | [x] 列表/详情/跟进;阶段变更二期 |
| 客户管理 | 要做 | 列表、详情、跟进入口 | [x] |
| 公海池 | 要做 | 列表、领取/分配 | [x] 列表+领取;分配待 Web 选人 |
| 报价管理 | 要做 | 列表、详情、关键状态操作 | [x] 列表/详情(审批在 Web) |
| 合同管理 | 要做 | 列表、详情、关键状态(无审批流复杂页可简化) | [x] 列表/详情(审批在 Web) |
| 产品分类 | 不做 | Web 主数据 | [-] |
| 产品管理 | 不做 | Web 主数据 | [-] |
| 合同审批 | 不做 | Demo 灰卡 | [-] |
| 线索/商机统计、看板、漏斗 | 不做 | Web 分析页,除非产品加开 | [-] |
### P1 任务拆解
- [x] 工作台 CRM 菜单卡片接线(`WORKBENCH_ROUTE_MAP`)
- [x] 线索:列表 + 详情 + 表单/跟进(`mobile-ui` Pattern:Header/Tabs/Card/Tag/BottomBar)
- [x] 商机:列表 + 详情 + 跟进
- [x] 客户:列表 + 详情 + 跟进
- [x] 公海池:列表 + 领取(分配需选人,留 Web)
- [x] 报价:列表 + 详情
- [x] 合同:列表 + 详情
- [ ] 接口联调与租户/数据权限冒烟(需 admin 账号 + 后端)
- [ ] 阶段验收:销售角色可完成「线索→商机→客户」主路径
---
## 4. P2 报销管理
| 菜单 | 本期 | 建议能力 | 进度 |
|------|------|----------|------|
| 待办任务 | 要做 | 待审批/我的申请/已办 Tab;通过/驳回 | [x] |
| 报销申请 | 要做 | 新建/编辑、明细、提交(票据以拍照凭证为主) | [x] 新建+拍照+草稿/提交 |
| 报销审批 | 要做 | 列表 + 审批动作(可与待办合并入口) | [x] |
| 借款申请 | 要做 | 备用金申请 | [x] |
| 借款审批 | 要做 | 备用金审批 | [x] |
| 我的单据 | 要做 | 我的申请单列表/详情 | [x] |
| 借款发放 | 不做 | Demo 灰卡(偏财务后台) | [-] |
| 全部单据 | 不做 | Demo 灰卡(总经理 Web) | [-] |
| 付款记录 | 不做 | Demo 灰卡 | [-] |
| 费用项目/工作流配置 | 不做 | Web 基础配置 | [-] |
| 支付发放冲抵 | 不做 | 本期不进工作台 | [-] |
### P2 任务拆解
- [x] 工作台报销菜单卡片接线
- [x] 待办任务列表(对齐 bx-demo:大麦黄 Header + Tabs + 卡片审批)
- [x] 报销申请(表单 + 拍照凭证 `/attachments/upload` + bind)
- [x] 报销审批动作
- [x] 借款(备用金)申请 / 审批
- [x] 我的单据
- [ ] 接口联调与审批状态机冒烟
- [ ] 阶段验收:申请人提交 → 审批人通过/驳回闭环
---
## 5. P3 地磅管理
| 菜单 | 本期 | 建议能力 | 进度 |
|------|------|----------|------|
| 仪表盘 | 要做 | 关键统计卡片(只读) | [x] |
| 地磅计划 | 要做 | 列表、详情、必要办理动作 | [x] 列表/详情/生成出入库关联 |
| 地磅单管理 | 要做 | 列表、详情、二次过磅等现场动作 | [x] 二次过磅 + 当天作废 |
| 地磅出入库 | 要做 | 列表、详情 | [x] |
### P3 任务拆解
- [x] 工作台地磅菜单卡片接线
- [x] 仪表盘(对齐 db-demo 青绿 Header + 统计卡)
- [x] 地磅计划列表/详情
- [x] 地磅单列表/详情/二次过磅/作废
- [x] 出入库列表/详情
- [ ] 接口联调(注意与地磅系统边界,见 ecosystem-landscape)
- [ ] 阶段验收:查计划 → 看待过磅单 → 完成一次现场办理路径
---
## 6. P4 运输管理
| 菜单 | 本期 | 建议能力 | 进度 |
|------|------|----------|------|
| 运输计划 | 要做 | 列表 Tab、详情、安排运输等 | [x] 待发运/在途/已完成 + 安排运输 |
| 计划归档 | 要做 | 只读列表/详情 | [x] |
| 购买证上传 | 要做 | 列表 + **拍照凭证**上传(OpenSpec) | [x] 拍照 + 证书齐备 |
| 运费结算 | 要做 | 列表、详情、提交 | [x] 结算单/待结算生成 + 提交审批 |
| 运费审批 | 要做 | 列表、审批 | [x] |
| 财务发放 | 不做 | Demo 灰卡 | [-] |
| 车辆充值 | 不做 | Demo 灰卡(ETC 等) | [-] |
| 车辆维修 | 不做 | Demo 灰卡 | [-] |
| 运输地址库 | 不做 | Demo 灰卡(主数据) | [-] |
| 基础信息库其余页 | 不做 | 车辆/承运商/驾押等主数据偏 Web | [-] |
| 证件提醒 / 尿素等 | 不做 | 未进工作台启用卡 | [-] |
### P4 任务拆解
- [x] 工作台运输菜单卡片接线
- [x] 运输计划列表/详情/办理(对齐 pl-demo)
- [x] 计划归档
- [x] 购买证上传(拍照凭证)
- [x] 运费结算 + 运费审批(含选人提交审批)
- [ ] 接口联调
- [ ] 阶段验收:计划办理 → 购买证上传 → 运费提交/审批主路径
---
## 7. P5 收尾与发版
- [x] 四模块冒烟清单(角色 × 主路径)— 接口级已跑通,见 §7.1
- [ ] 真机:登录、列表、上传、审批/办理
- [ ] `npm run release:check-api`(或等价)通过
- [ ] 微信后台域名与上传域名配置完成
- [x] README / 本清单进度回写(接口冒烟结论已写入)
- [ ] 可选:OpenSpec 归档或增补「小程序四模块」变更说明
### 7.1 四模块冒烟清单(联调时勾选)
> 账号:具备对应模块权限的 admin / 业务角色;基址见 `miniprogram/config/api.js`。
> **接口冒烟执行**:2026-07-17,`admin` / 本机 `http://127.0.0.1:8000/api`,脚本 `miniprogram/scripts/mp_api_smoke.py`(结果见 `mp_api_smoke_last.json`)。
> 图例:`[x]` 接口可达且读通;写操作因缺样本/字段校验未闭环的在备注中说明。
| # | 模块 | 路径 | 预期 | 结果 |
|---|------|------|------|------|
| S1 | 公共 | 登录 → current-user | Token 可用 | [x] 登录+current-user ok |
| S2 | CRM | 线索列表 → 详情 → 跟进 | 列表/详情正常 | [x] 列表+详情 ok;跟进 body 422(字段待对齐) |
| S3 | CRM | 公海列表/领取 | 列表正常 | [x] 列表空态 ok(无领取样本) |
| S4 | 报销 | 我的报销 / 新建 | 列表可读 | [x] my 列表 ok;新建缺 `details` 字段 422(小程序 payload 待对齐) |
| S5 | 报销 | 待审批列表 | 列表正常 | [x] 空态 ok(无待审样本) |
| S6 | 地磅 | 仪表盘 + 地磅单列表 | 数字/列表可读 | [x] stats+列表 ok;无二次过磅样本 |
| S7 | 运输 | 计划列表 / 安排 | 列表正常 | [x] 列表 ok;安排因证书未齐备业务拒绝(接口可达) |
| S8 | 运输 | 购买证相关计划列表 | 列表正常 | [x] 列表空态 ok |
| S9 | 运输 | 运费结算 + 用户选项 | 列表/选人接口正常 | [x] freight 列表 + users/options ok;无待结算 |
| S10 | 运输 | 运费待审批列表 | 列表正常 | [x] 列表空态 ok |
**接口冒烟结论**:S1–S10 **读路径全部通**(0 FAIL)。写路径有 3 处需跟进:① 线索跟进字段;② 报销创建体应用 `details` 而非 `items`;③ 安排运输依赖证书齐备业务数据。
**前端 UI**:仍由人工在微信开发者工具/真机验收。
---
## 8. 推荐开发顺序
1. **P0** 壳与工作台(否则业务页无处挂)
2. **P2 报销** 或 **P1 CRM**(审批/跟进闭环短,易验证上传与列表模式)
3. **P3 地磅**(现场办理,偏读+少量动作)
4. **P4 运输**(计划+运费+购买证,依赖上传基线)
5. **P5** 联调发版
> 顺序可按业务优先级调整;调整时只改本节与文首总览,子清单保持勾选状态。
---
## 9. 进度记录(追加)
| 日期 | 阶段 | 说明 |
|------|------|------|
| 2026-07-16 | 范围 | 确认仅 CRM / 地磅 / 运输 / 报销四模块;本清单建立 |
| 2026-07-16 | 规范 | 拍照凭证上传写入 OpenSpec engineering-standards |
| 2026-07-16 | P0 | TabBar + 工作台/我的/占位页 + upload.js + mp-shell;清单回写 |
| 2026-07-16 | P1–P4 | CRM / 报销 / 地磅 / 运输业务页落地并接线工作台 |
| 2026-07-16 | P4+ | 运费提交审批改为用户选项选人 Sheet;P5 冒烟表写入 §7.1 |
| 2026-07-17 | P5 | 接口冒烟 S1–S10 读路径全通(脚本 `mp_api_smoke.py`);写路径 3 处待对齐见 §7.1 |
<!-- 每完成一批任务追加一行,例如:
| 2026-0x-xx | P0 | TabBar + 工作台落地 |
-->
+57 -5
View File
@@ -62,11 +62,63 @@ npm run release:check-api # CI 严格模式:仍为本机地址则失败
## 视觉规范
UI Token 与页面骨架以仓库根目录 **`docs/miniprogram-ui-spec-sanqiang.md`** 及 `mobile-ui/sanqiang-*.html` Demo 为准。主品牌色:`#0a9f6d`。
UI Token 与页面骨架以仓库根目录 **`mobile-ui/`**(`shanxia-mp-base.css`、`shanxia-mp-*.html`)及 **`docs/miniprogram-ui-spec-sanqiang.md`** 为准。主品牌色:`#0a9f6d`(勿混用 Web Fresh Mint `#10b981`)。
## CRM(P1)
工作台 CRM 启用菜单已接业务页:
| 菜单 | 路径 |
|------|------|
| 线索 | `pages/crm/clue/*` |
| 商机 | `pages/crm/opportunity/*` |
| 客户 | `pages/crm/customer/*` |
| 公海 | `pages/crm/pool/list` |
| 报价 | `pages/crm/quote/*` |
| 合同 | `pages/crm/contract/*` |
接口复用管理端 `/web/crm/*`(需 admin 权限账号)。API 封装:`api/crm.js`。
## 报销(P2)
| 菜单 | 路径 |
|------|------|
| 待办任务 | `pages/bx/todo/list` |
| 报销申请 | `pages/bx/reimburse/*` |
| 报销/借款审批 | `pages/bx/approve/list?type=` |
| 借款申请 | `pages/bx/advance/*` |
| 我的单据 | `pages/bx/mine/list` |
接口:`/reimburses`、`/advances`、`/expense-items`、`/attachments`(协议 `code===0`)。封装:`api/bx.js`。拍照凭证走 BX 附件上传。
## 地磅(P3)
| 菜单 | 路径 |
|------|------|
| 仪表盘 | `pages/db/dashboard/index` |
| 地磅计划 | `pages/db/plans/*` |
| 地磅单 | `pages/db/weighing/*`(二次过磅 / 作废) |
| 出入库 | `pages/db/inventory/*` |
接口:`/dashboard`、`/plans`、`/weighing`、`/inventory`(**直出 JSON**,非门户信封)。封装:`api/db.js`。
## 运输(P4)
| 菜单 | 路径 |
|------|------|
| 运输计划 | `pages/pl/plan/*`(列表 Tab / 详情 / 安排运输) |
| 计划归档 | `pages/pl/archive/list` |
| 购买证上传 | `pages/pl/cert/*`(拍照凭证) |
| 运费结算 | `pages/pl/freight/list` + `detail` |
| 运费审批 | `pages/pl/freight/approval` |
接口:`/transport/plan`、`/transport/order/arrange`、`/transport/freight`(协议兼容 `code===0` 或直出)。封装:`api/pl.js`。
运费提交审批:底部 Sheet 多选审批人(`/web/admin/users/options` + `components/mp-approver-picker`),支持或签/顺序/会签。
## 后续建设建议
1. 在 `pages/` 下按业务域新增页面(如客户端、供应商端等)
2. 在 `pages.json` 注册路由与 tabBar
3. 在 `api/` 目录补充业务接口模块
4. 后端新增 `/api/mini/*` 时同步更新 `config/api.js`
开发范围与分期进度见 **`docs/miniprogram-dev-checklist.md`**(含 §7.1 冒烟表)。
1. ~~P0~~ / ~~P1 CRM~~ / ~~P2 报销~~ / ~~P3 地磅~~ / ~~P4 运输~~ → **P5** 按冒烟表联调 + 合法域名 + `npm run release:check-api`
2. 灰卡菜单仍走 `biz-placeholder`;联调时核对微信合法域名与 `config/api.js` 基址
+41
View File
@@ -0,0 +1,41 @@
/**
* 管理端公共选项(审批人选人等)
* 协议:门户信封 code===200
*/
import { request, parseResponseJson } from '@/utils/request'
function unwrap(res) {
const body = parseResponseJson(res)
if (!body || typeof body !== 'object') {
throw new Error('接口响应异常')
}
if (body.code != null && body.code !== 200) {
throw new Error(body.message || body.msg || '请求失败')
}
return body.data != null ? body.data : body
}
/**
* @returns {Promise<Array<{ userId: string, nickName: string, userNo: string, label: string }>>}
*/
export async function fetchUserOptions() {
const res = await request({
url: '/web/admin/users/options',
method: 'GET'
})
const data = unwrap(res)
const list = Array.isArray(data) ? data : []
return list
.map((item) => {
const userId = String(item.value ?? item.userId ?? item.id ?? '')
const nickName = String(item.nickName ?? item.label ?? item.userName ?? '')
const userNo = String(item.userNo ?? item.userName ?? '')
return {
userId,
nickName,
userNo,
label: nickName || userNo || userId
}
})
.filter((u) => u.userId)
}
+191
View File
@@ -0,0 +1,191 @@
/**
* 报销模块 API(REST,协议 code===0 / msg / data.rows)
* 路径挂在 /api 下:/reimburses、/advances、/expense-items、/attachments
*/
import { request, parseResponseJson } from '@/utils/request'
function unwrap(res) {
const body = parseResponseJson(res)
if (!body || typeof body !== 'object') {
throw new Error('接口响应异常')
}
if (body.code != null && body.code !== 0) {
throw new Error(body.msg || body.message || '请求失败')
}
return body.data != null ? body.data : body
}
async function get(url, params = {}) {
const res = await request({ url, method: 'GET', data: params, params })
return unwrap(res)
}
async function post(url, data = {}) {
const res = await request({ url, method: 'POST', data })
return unwrap(res)
}
async function put(url, data = {}, params = {}) {
const res = await request({ url, method: 'PUT', data, params })
return unwrap(res)
}
function pageParams(query = {}) {
const { pageNum, page, pageSize, ...rest } = query
const params = {
page: pageNum ?? page ?? 1,
pageSize: pageSize ?? 20
}
Object.keys(rest).forEach((k) => {
if (rest[k] != null && rest[k] !== '') params[k] = rest[k]
})
return params
}
function normalizeList(data) {
if (Array.isArray(data)) return { rows: data, total: data.length }
const d = data || {}
return {
rows: d.rows || d.list || [],
total: d.total ?? 0
}
}
// ── 费用项目 ──
export async function getExpenseItemTree() {
const data = await get('/expense-items/tree')
return Array.isArray(data) ? data : []
}
/** 展平可选叶子费用项目(供 picker) */
export function flattenExpenseLeaves(tree, path = []) {
const out = []
;(tree || []).forEach((node) => {
const name = node.itemName || node.item_name || ''
const nextPath = path.concat(name)
const children = node.children || []
if (children.length) {
out.push(...flattenExpenseLeaves(children, nextPath))
} else {
out.push({
id: String(node.id),
name,
pathLabel: nextPath.join(' / ')
})
}
})
return out
}
// ── 报销 ──
export async function listMyReimburse(query = {}) {
return normalizeList(await get('/reimburses/my', pageParams(query)))
}
export async function listPendingReimburse(query = {}) {
return normalizeList(await get('/reimburses/pending-approval', pageParams(query)))
}
export async function listApprovedByMeReimburse(query = {}) {
return normalizeList(await get('/reimburses/approved-by-me', pageParams(query)))
}
export async function getReimburse(id) {
return get(`/reimburses/${String(id)}`)
}
export async function generateReimburseTitle(reimburseDate) {
const params = {}
if (reimburseDate) params.reimburseDate = reimburseDate
return get('/reimburses/generate-title', params)
}
export function createReimburse(data) {
return post('/reimburses/', data)
}
export function updateReimburse(id, data) {
return put(`/reimburses/${String(id)}`, data)
}
export function submitReimburse(id) {
return post(`/reimburses/${String(id)}/submit`)
}
export function approveReimburse(id, approvalOpinion = '') {
return post(`/reimburses/${String(id)}/approve`, { approvalOpinion })
}
export function rejectReimburse(id, approvalOpinion = '') {
return post(`/reimburses/${String(id)}/reject`, { approvalOpinion })
}
export async function getReimburseApprovalHistory(id) {
const data = await get(`/reimburses/${String(id)}/approval-history`)
return Array.isArray(data) ? data : []
}
// ── 借款(备用金) ──
export async function listMyAdvance(query = {}) {
return normalizeList(await get('/advances/my', pageParams(query)))
}
export async function listPendingAdvance(query = {}) {
return normalizeList(await get('/advances/pending-approval', pageParams(query)))
}
export async function listApprovedByMeAdvance(query = {}) {
return normalizeList(await get('/advances/approved-by-me', pageParams(query)))
}
export async function getAdvance(id) {
return get(`/advances/${String(id)}`)
}
export function createAdvance(data) {
return post('/advances/', data)
}
export function submitAdvance(id) {
return post(`/advances/${String(id)}/submit`)
}
export function approveAdvance(id, approvalOpinion = '') {
return post(`/advances/${String(id)}/approve`, { approvalOpinion })
}
export function rejectAdvance(id, approvalOpinion = '') {
return post(`/advances/${String(id)}/reject`, { approvalOpinion })
}
export async function getAdvanceApprovalHistory(id) {
const data = await get(`/advances/${String(id)}/approval-history`)
return Array.isArray(data) ? data : []
}
// ── 附件 ──
export async function listAttachments(bizType, bizId) {
const data = await get('/attachments/list', {
biz_type: bizType,
biz_id: String(bizId)
})
return Array.isArray(data) ? data : []
}
export async function bindAttachments(ids, bizType, bizId) {
if (!ids?.length) return 0
const q = [
`ids=${encodeURIComponent(ids.map(String).join(','))}`,
`biz_type=${encodeURIComponent(bizType)}`,
`biz_id=${encodeURIComponent(String(bizId))}`
].join('&')
return put(`/attachments/bind-biz?${q}`, {})
}
export async function previewAttachment(id) {
return get(`/attachments/preview/${String(id)}`)
}
+215
View File
@@ -0,0 +1,215 @@
/**
* CRM 管理端接口(复用 /web/crm/*,需 admin JWT)
*/
import { request, parseResponseJson } from '@/utils/request'
function unwrap(res) {
const body = parseResponseJson(res)
if (!body || typeof body !== 'object') {
throw new Error('接口响应异常')
}
if (body.code != null && body.code !== 200) {
throw new Error(body.message || '请求失败')
}
return body.data != null ? body.data : body
}
async function post(url, data = {}) {
const res = await request({ url, method: 'POST', data })
return unwrap(res)
}
async function get(url, params = {}) {
const res = await request({ url, method: 'GET', data: params, params })
return unwrap(res)
}
function pageBody(query = {}) {
const { pageNum, page, pageSize, ...rest } = query
return {
page: pageNum ?? page ?? 1,
pageSize: pageSize ?? 20,
...rest
}
}
function normalizeList(data) {
if (Array.isArray(data)) {
return { rows: data, total: data.length }
}
const d = data || {}
return {
rows: d.list || d.rows || [],
total: d.total ?? (d.list || d.rows || []).length
}
}
// ── 线索 ──
export async function listClue(query = {}) {
const { pageNum, pageSize, ...rest } = query
return normalizeList(await post('/web/crm/clue/list', pageBody({ pageNum, pageSize, ...rest })))
}
export async function listMyClue(query = {}) {
const { pageNum, pageSize, ...rest } = query
return normalizeList(await post('/web/crm/clue/mine', pageBody({ pageNum, pageSize, ...rest })))
}
export async function getClue(id) {
return post('/web/crm/clue/detail', { id: String(id) })
}
export function addClue(data) {
return post('/web/crm/clue/add', data)
}
export function updateClue(data) {
return post('/web/crm/clue/update', data)
}
export function convertClue(data) {
return post('/web/crm/clue/convert', {
id: String(data.clueId ?? data.id),
customerName: data.customerName,
customerType: data.customerType,
contactName: data.contactName,
contactMobile: data.contactMobile,
remark: data.remark
})
}
export function markClueInvalid(data) {
return post('/web/crm/clue/invalid', {
id: data.id != null ? String(data.id) : undefined,
remark: data.remark
})
}
export async function listClueFollow(clueId, pageNum = 1, pageSize = 20) {
return normalizeList(
await post('/web/crm/clue/follow/list', {
clueId: String(clueId),
page: pageNum,
pageSize
})
)
}
export function addClueFollow(data) {
return post('/web/crm/clue/follow/add', {
...data,
clueId: String(data.clueId)
})
}
// ── 商机 ──
export async function listOpportunity(query = {}) {
const { pageNum, pageSize, ...rest } = query
return normalizeList(
await post('/web/crm/opportunity/list', pageBody({ pageNum, pageSize, ...rest }))
)
}
export async function getOpportunity(id) {
return post('/web/crm/opportunity/detail', { id: String(id) })
}
export async function listOpportunityFollow(opportunityId, pageNum = 1, pageSize = 20) {
return normalizeList(
await post('/web/crm/opportunity/follow/list', {
opportunityId: String(opportunityId),
page: pageNum,
pageSize
})
)
}
export function addOpportunityFollow(data) {
return post('/web/crm/opportunity/follow/add', {
...data,
opportunityId: String(data.opportunityId)
})
}
// ── 客户 ──
export async function listCustomer(query = {}) {
const { pageNum, pageSize, ...rest } = query
return normalizeList(
await post('/web/crm/customer/list', pageBody({ pageNum, pageSize, ...rest }))
)
}
export async function listMyCustomer(query = {}) {
const { pageNum, pageSize, ...rest } = query
return normalizeList(
await post('/web/crm/customer/mine', pageBody({ pageNum, pageSize, ...rest }))
)
}
export async function getCustomer(id) {
return post('/web/crm/customer/detail', { id: String(id) })
}
export async function listCustomerFollow(customerId, pageNum = 1, pageSize = 20) {
return normalizeList(
await post('/web/crm/customer/follow/byCustomer', {
customerId: String(customerId),
page: pageNum,
pageSize
})
)
}
export function addCustomerFollow(data) {
return post('/web/crm/customer/follow/add', {
...data,
customerId: String(data.customerId)
})
}
// ── 公海 ──
export async function listPoolCustomer(query = {}) {
const { pageNum, pageSize, ...rest } = query
return normalizeList(
await post('/web/crm/customer/pool/list', pageBody({ pageNum, pageSize, ...rest }))
)
}
export function claimCustomer(ids) {
const idList = Array.isArray(ids) ? ids.map(String) : [String(ids)]
return post('/web/crm/customer/claim', { ids: idList })
}
// ── 报价 ──
export async function listQuote(query = {}) {
const { pageNum, pageSize, ...rest } = query
return normalizeList(await post('/web/crm/quote/list', pageBody({ pageNum, pageSize, ...rest })))
}
export async function getQuote(id) {
return post('/web/crm/quote/detail', { id: String(id) })
}
// ── 合同(GET) ──
export async function listContract(query = {}) {
const params = {
pageNum: query.pageNum ?? 1,
pageSize: query.pageSize ?? 20
}
;['keyword', 'contractNo', 'contractName', 'customerName', 'status', 'approvalStatus'].forEach(
(k) => {
if (query[k] != null && query[k] !== '') params[k] = query[k]
}
)
return normalizeList(await get('/web/crm/contract/list', params))
}
export async function getContract(id) {
return get(`/web/crm/contract/${String(id)}`)
}
+126
View File
@@ -0,0 +1,126 @@
/**
* 地磅模块 API(直出 JSON,对齐原 dibang;路径 /plans /weighing /inventory /dashboard)
*/
import { request, parseResponseJson } from '@/utils/request'
function extractError(body) {
if (!body || typeof body !== 'object') return '请求失败'
const detail = body.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail.map((x) => x?.msg || x?.message || '').filter(Boolean).join(';') || '请求失败'
}
return body.msg || body.message || '请求失败'
}
function unwrap(res) {
if (res.statusCode === 204) return null
if (res.statusCode >= 400) {
throw new Error(extractError(parseResponseJson(res)))
}
const body = parseResponseJson(res)
if (body == null) return body
if (Array.isArray(body)) return body
if (typeof body === 'object' && body.code != null) {
if (body.code !== 200 && body.code !== 0) {
throw new Error(body.message || body.msg || '请求失败')
}
return body.data != null ? body.data : body
}
return body
}
async function get(url, params = {}) {
const res = await request({ url, method: 'GET', data: params, params })
return unwrap(res)
}
async function post(url, data = {}) {
const res = await request({ url, method: 'POST', data })
return unwrap(res)
}
async function patch(url, data = {}) {
const res = await request({ url, method: 'PATCH', data })
return unwrap(res)
}
function asList(data) {
return Array.isArray(data) ? data : []
}
/** 集合根须带尾斜杠,避免 307 丢 Authorization */
function root(path) {
return path.endsWith('/') ? path : `${path}/`
}
// ── 仪表盘 ──
export async function getDashboardStats() {
return (await get('/dashboard/stats')) || {}
}
export async function getInboundByCargo() {
return asList(await get('/dashboard/inbound/by-cargo'))
}
export async function getOutboundByCargo() {
return asList(await get('/dashboard/outbound/by-cargo'))
}
// ── 计划 ──
export async function listPlans(params = {}) {
return asList(await get(root('/plans'), params))
}
export async function getPlan(id) {
return get(`/plans/${String(id)}`)
}
export async function getPlanRelated(id) {
return (await get(`/plans/${String(id)}/related`)) || { inventories: [], weighings: [] }
}
// ── 地磅单 ──
export async function listWeighing(params = {}) {
return asList(await get(root('/weighing'), params))
}
export async function getWeighing(id) {
return get(`/weighing/${String(id)}`)
}
export async function getWeighingRelated(id) {
return (await get(`/weighing/${String(id)}/related`)) || { plan: null, inventory: null }
}
export function updateWeighing(id, data) {
return patch(`/weighing/${String(id)}`, data)
}
export function voidWeighing(id) {
return post(`/weighing/${String(id)}/void`, {})
}
// ── 出入库 ──
export async function listInventory(params = {}) {
return asList(await get(root('/inventory'), params))
}
export async function getInventory(id) {
return get(`/inventory/${String(id)}`)
}
export async function getInventoryRelated(id) {
return (await get(`/inventory/${String(id)}/related`)) || { plan: null, weighing: null }
}
export function createInventoryLinkForPlan(purchasePlanId, remarks) {
return post('/inventory/create-link-for-plan', {
purchasePlanId: String(purchasePlanId),
remarks
})
}
+124
View File
@@ -0,0 +1,124 @@
/**
* 运输模块 API(/transport/*)
* 协议与报销侧 bx HTTP 一致:有信封时 code===0;直出 { list, total } 亦可。
*/
import { request, parseResponseJson } from '@/utils/request'
function unwrap(res) {
const body = parseResponseJson(res)
if (!body || typeof body !== 'object') {
throw new Error('接口响应异常')
}
if (body.code != null && body.code !== 0) {
throw new Error(body.msg || body.message || '请求失败')
}
return body.data != null ? body.data : body
}
async function get(url, params = {}) {
const res = await request({ url, method: 'GET', data: params, params })
return unwrap(res)
}
async function post(url, data = {}) {
const res = await request({ url, method: 'POST', data })
return unwrap(res)
}
async function patch(url, data = {}) {
const res = await request({ url, method: 'PATCH', data })
return unwrap(res)
}
function pageParams(query = {}) {
const { pageNum, page, pageSize, ...rest } = query
const params = {
page: pageNum ?? page ?? 1,
pageSize: pageSize ?? 20
}
Object.keys(rest).forEach((k) => {
if (rest[k] != null && rest[k] !== '') params[k] = rest[k]
})
return params
}
function normalizeList(data) {
if (Array.isArray(data)) return { rows: data, total: data.length }
const d = data || {}
return {
rows: d.rows || d.list || [],
total: d.total ?? 0
}
}
// ── 运输计划 ──
export async function listPlans(query = {}) {
return normalizeList(await get('/transport/plan/', pageParams(query)))
}
export async function getPlan(id) {
return get(`/transport/plan/${String(id)}`)
}
export async function getPlanArrangeInfo(id) {
return get(`/transport/plan/${String(id)}/arrange-info`)
}
export async function updateProductCerts(id, products) {
return patch(`/transport/plan/${String(id)}/product-certs`, { products })
}
export async function setHazardCertReady(id, ready) {
return patch(`/transport/plan/${String(id)}/hazard-cert-ready`, { ready: !!ready })
}
export async function archivePlan(id, body) {
return post(`/transport/plan/${String(id)}/archive`, body || {})
}
export async function arrangeTransport(planId, data) {
return post(`/transport/order/arrange/${String(planId)}`, data)
}
// ── 车辆 / 驾押(安排运输选人)──
export async function listVehicles(query = {}) {
return normalizeList(await get('/transport/vehicle', pageParams(query)))
}
export async function listPersonnel(query = {}) {
return normalizeList(await get('/transport/personnel', pageParams(query)))
}
// ── 运费结算 ──
export async function listFreight(query = {}) {
return normalizeList(await get('/transport/freight/', pageParams(query)))
}
export async function getFreight(id) {
return get(`/transport/freight/${String(id)}`)
}
export async function listUnsettledPlans(query = {}) {
return normalizeList(await get('/transport/freight/unsettled-plans', pageParams(query)))
}
export async function createFreight(data) {
return post('/transport/freight/', data)
}
/** 提交审批(需审批人 ID,复杂选人建议 Web;小程序可传已选 ID) */
export async function submitFreightApproval(id, data) {
return post(`/transport/freight/${String(id)}/submit-approval`, data)
}
export async function processFreightApproval(approvalId, data) {
return post(`/transport/freight/approval/${String(approvalId)}`, data)
}
export async function getFreightApprovalFlow(id) {
const data = await get(`/transport/freight/${String(id)}/approval-flow`)
return Array.isArray(data) ? data : []
}
@@ -0,0 +1,122 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { fetchUserOptions } from '@/api/admin'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
visible: { type: Boolean, default: false },
title: { type: String, default: '选择审批人' },
multiple: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'update:visible', 'confirm'])
const keyword = ref('')
const loading = ref(false)
const users = ref([])
const draftIds = ref([])
const filtered = computed(() => {
const kw = String(keyword.value || '')
.trim()
.toLowerCase()
if (!kw) return users.value
return users.value.filter((u) => {
const blob = `${u.nickName} ${u.userNo} ${u.userId}`.toLowerCase()
return blob.includes(kw)
})
})
const isChecked = (userId) => draftIds.value.includes(String(userId))
const loadUsers = async () => {
loading.value = true
try {
users.value = await fetchUserOptions()
} catch (e) {
uni.showToast({ title: e.message || '加载用户失败', icon: 'none' })
users.value = []
} finally {
loading.value = false
}
}
watch(
() => props.visible,
(v) => {
if (!v) return
draftIds.value = (props.modelValue || []).map(String)
keyword.value = ''
if (!users.value.length) loadUsers()
}
)
const close = () => emit('update:visible', false)
const toggle = (userId) => {
const id = String(userId)
if (!props.multiple) {
draftIds.value = [id]
return
}
const i = draftIds.value.indexOf(id)
if (i >= 0) draftIds.value.splice(i, 1)
else draftIds.value.push(id)
}
const confirm = () => {
if (!draftIds.value.length) {
uni.showToast({ title: '请至少选择一位审批人', icon: 'none' })
return
}
emit('update:modelValue', [...draftIds.value])
emit('confirm', [...draftIds.value])
close()
}
/** 根据已选 ID 解析展示名(父级摘要用) */
function resolveLabels(ids) {
const set = new Set((ids || []).map(String))
return users.value
.filter((u) => set.has(u.userId))
.map((u) => u.nickName || u.userNo || u.userId)
}
defineExpose({ loadUsers, resolveLabels, users })
</script>
<template>
<view v-if="visible" class="mp-sheet-mask" @click="close">
<view class="mp-sheet" @click.stop>
<view class="mp-sheet__head">
<text class="mp-sheet__cancel" @click="close">取消</text>
<text class="mp-sheet__title">{{ title }}</text>
<text class="mp-sheet__ok" @click="confirm">确定</text>
</view>
<view class="mp-sheet__search">
<input
v-model="keyword"
class="mp-sheet__search-input"
placeholder="搜索姓名 / 账号"
/>
</view>
<scroll-view scroll-y class="mp-sheet__body">
<view v-if="loading" class="mp-sheet__hint">加载中…</view>
<view v-else-if="!filtered.length" class="mp-sheet__hint">暂无用户</view>
<view
v-for="u in filtered"
:key="u.userId"
class="mp-sheet__row"
@click="toggle(u.userId)"
>
<view class="mp-sheet__check" :class="{ 'mp-sheet__check--on': isChecked(u.userId) }" />
<view class="mp-sheet__meta">
<text class="mp-sheet__name">{{ u.nickName || u.userNo || u.userId }}</text>
<text class="mp-sheet__sub">{{ u.userNo || u.userId }}</text>
</view>
</view>
</scroll-view>
<view class="mp-sheet__foot">已选 {{ draftIds.length }} 人</view>
</view>
</view>
</template>
+81
View File
@@ -24,6 +24,87 @@ export const AUTH_API = {
REFRESH_TOKEN: '/web/auth/refresh-token'
}
/**
* 上传接口(multipart,须在 API_ENCRYPT_EXCLUDE_PATHS 中)
* 响应字段兼容 data.url / data.fileUrl;P0 复用管理端附件上传。
* 后续可切换为专用 /mini/.../upload/image。
*/
export const UPLOAD_API = {
IMAGE: '/web/admin/attachment/upload'
}
/** Tab / 业务页路径(供 store 跳转) */
export const PAGE_PATHS = {
login: '/pages/login/login',
home: '/pages/home/home',
todo: '/pages/todo/todo',
workbench: '/pages/workbench/workbench',
mine: '/pages/mine/mine',
bizPlaceholder: '/pages/common/biz-placeholder',
crmClueList: '/pages/crm/clue/list',
crmClueDetail: '/pages/crm/clue/detail',
crmClueForm: '/pages/crm/clue/form',
crmOpportunityList: '/pages/crm/opportunity/list',
crmOpportunityDetail: '/pages/crm/opportunity/detail',
crmCustomerList: '/pages/crm/customer/list',
crmCustomerDetail: '/pages/crm/customer/detail',
crmPoolList: '/pages/crm/pool/list',
crmQuoteList: '/pages/crm/quote/list',
crmQuoteDetail: '/pages/crm/quote/detail',
crmContractList: '/pages/crm/contract/list',
crmContractDetail: '/pages/crm/contract/detail',
bxTodoList: '/pages/bx/todo/list',
bxReimburseList: '/pages/bx/reimburse/list',
bxReimburseForm: '/pages/bx/reimburse/form',
bxReimburseDetail: '/pages/bx/reimburse/detail',
bxAdvanceList: '/pages/bx/advance/list',
bxAdvanceForm: '/pages/bx/advance/form',
bxAdvanceDetail: '/pages/bx/advance/detail',
bxApproveList: '/pages/bx/approve/list',
bxMineList: '/pages/bx/mine/list',
dbDashboard: '/pages/db/dashboard/index',
dbPlansList: '/pages/db/plans/list',
dbPlansDetail: '/pages/db/plans/detail',
dbWeighingList: '/pages/db/weighing/list',
dbWeighingDetail: '/pages/db/weighing/detail',
dbInventoryList: '/pages/db/inventory/list',
dbInventoryDetail: '/pages/db/inventory/detail',
plPlanList: '/pages/pl/plan/list',
plPlanDetail: '/pages/pl/plan/detail',
plPlanArrange: '/pages/pl/plan/arrange',
plArchiveList: '/pages/pl/archive/list',
plCertList: '/pages/pl/cert/list',
plCertDetail: '/pages/pl/cert/detail',
plFreightList: '/pages/pl/freight/list',
plFreightDetail: '/pages/pl/freight/detail',
plFreightApproval: '/pages/pl/freight/approval'
}
/** 工作台菜单 code → 页面路径 */
export const WORKBENCH_ROUTE_MAP = {
'crm-clue': PAGE_PATHS.crmClueList,
'crm-opportunity': PAGE_PATHS.crmOpportunityList,
'crm-customer': PAGE_PATHS.crmCustomerList,
'crm-customer-pool': PAGE_PATHS.crmPoolList,
'crm-quote': PAGE_PATHS.crmQuoteList,
'crm-contract': PAGE_PATHS.crmContractList,
'bx-todo': PAGE_PATHS.bxTodoList,
'bx-reimburse-apply': PAGE_PATHS.bxReimburseList,
'bx-reimburse-approve': `${PAGE_PATHS.bxApproveList}?type=reimburse`,
'bx-advance-apply': PAGE_PATHS.bxAdvanceList,
'bx-advance-approve': `${PAGE_PATHS.bxApproveList}?type=advance`,
'bx-mine': PAGE_PATHS.bxMineList,
'db-dashboard': PAGE_PATHS.dbDashboard,
'db-plans': PAGE_PATHS.dbPlansList,
'db-weighing': PAGE_PATHS.dbWeighingList,
'db-inventory': PAGE_PATHS.dbInventoryList,
'pl-plan': PAGE_PATHS.plPlanList,
'pl-plan-archive': PAGE_PATHS.plArchiveList,
'pl-plan-cert': PAGE_PATHS.plCertList,
'pl-freight': PAGE_PATHS.plFreightList,
'pl-freight-approval': PAGE_PATHS.plFreightApproval
}
export const TOKEN_KEY = 'sx_token'
export const REFRESH_TOKEN_KEY = 'sx_refresh_token'
export const USER_INFO_KEY = 'sx_user_info'
+69
View File
@@ -0,0 +1,69 @@
/**
* 工作台菜单(P0 前端写死;与 mobile-ui/shanxia-mp-workbench-demo.html 对齐)
* enabled=false 为灰卡,本期不做
*/
export const WORKBENCH_MODULES = [
{
key: 'crm',
title: 'CRM 管理',
desc: '线索 · 商机 · 客户 · 报价 · 合同',
iconText: 'CRM',
menus: [
{ name: '产品分类', enabled: false },
{ name: '产品管理', enabled: false },
{ name: '线索管理', enabled: true, code: 'crm-clue' },
{ name: '商机管理', enabled: true, code: 'crm-opportunity' },
{ name: '客户管理', enabled: true, code: 'crm-customer' },
{ name: '公海池', enabled: true, code: 'crm-customer-pool' },
{ name: '报价管理', enabled: true, code: 'crm-quote' },
{ name: '合同管理', enabled: true, code: 'crm-contract' },
{ name: '合同审批', enabled: false }
]
},
{
key: 'bx',
title: '报销管理',
desc: '借款 · 报销 · 审批 · 付款',
iconText: '报销',
menus: [
{ name: '待办任务', enabled: true, code: 'bx-todo' },
{ name: '报销申请', enabled: true, code: 'bx-reimburse-apply' },
{ name: '报销审批', enabled: true, code: 'bx-reimburse-approve' },
{ name: '借款申请', enabled: true, code: 'bx-advance-apply' },
{ name: '借款审批', enabled: true, code: 'bx-advance-approve' },
{ name: '借款发放', enabled: false },
{ name: '我的单据', enabled: true, code: 'bx-mine' },
{ name: '全部单据', enabled: false },
{ name: '付款记录', enabled: false }
]
},
{
key: 'db',
title: '地磅管理',
desc: '计划 · 过磅单 · 出入库',
iconText: '地磅',
menus: [
{ name: '仪表盘', enabled: true, code: 'db-dashboard' },
{ name: '地磅计划', enabled: true, code: 'db-plans' },
{ name: '地磅单管理', enabled: true, code: 'db-weighing' },
{ name: '地磅出入库', enabled: true, code: 'db-inventory' }
]
},
{
key: 'pl',
title: '运输管理',
desc: '计划 · 运费 · 车辆事务',
iconText: '运输',
menus: [
{ name: '运输计划', enabled: true, code: 'pl-plan' },
{ name: '计划归档', enabled: true, code: 'pl-plan-archive' },
{ name: '购买证上传', enabled: true, code: 'pl-plan-cert' },
{ name: '运费结算', enabled: true, code: 'pl-freight' },
{ name: '运费审批', enabled: true, code: 'pl-freight-approval' },
{ name: '财务发放', enabled: false },
{ name: '车辆充值', enabled: false },
{ name: '车辆维修', enabled: false },
{ name: '运输地址库', enabled: false }
]
}
]
+208 -5
View File
@@ -22,12 +22,183 @@
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/workbench/workbench",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/home/home",
"style": {
"navigationStyle": "custom",
"disableScroll": true
"navigationStyle": "custom"
}
},
{
"path": "pages/todo/todo",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/mine/mine",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/common/biz-placeholder",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/crm/clue/list",
"style": { "navigationStyle": "custom", "enablePullDownRefresh": false }
},
{
"path": "pages/crm/clue/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/clue/form",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/opportunity/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/opportunity/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/customer/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/customer/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/pool/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/quote/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/quote/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/contract/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/crm/contract/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/todo/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/reimburse/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/reimburse/form",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/reimburse/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/advance/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/advance/form",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/advance/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/approve/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/bx/mine/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/db/dashboard/index",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/db/plans/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/db/plans/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/db/weighing/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/db/weighing/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/db/inventory/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/db/inventory/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/plan/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/plan/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/plan/arrange",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/archive/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/cert/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/cert/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/freight/list",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/freight/detail",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/pl/freight/approval",
"style": { "navigationStyle": "custom" }
}
// #ifdef H5
,
@@ -57,14 +228,46 @@
}
// #endif
],
"tabBar": {
"color": "#6b7b74",
"selectedColor": "#087f57",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/home/home",
"text": "首页",
"iconPath": "static/tabbar/首页.png",
"selectedIconPath": "static/tabbar/首页-active.png"
},
{
"pagePath": "pages/todo/todo",
"text": "待办",
"iconPath": "static/tabbar/订单.png",
"selectedIconPath": "static/tabbar/订单-active.png"
},
{
"pagePath": "pages/workbench/workbench",
"text": "工作台",
"iconPath": "static/tabbar/购物车.png",
"selectedIconPath": "static/tabbar/购物车-active.png"
},
{
"pagePath": "pages/mine/mine",
"text": "我的",
"iconPath": "static/tabbar/我的.png",
"selectedIconPath": "static/tabbar/我的-active.png"
}
]
},
"globalStyle": {
"pageOrientation": "portrait",
"navigationBarTitleText": "山峡化工",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#f3f7f5",
"backgroundColor": "#F8F8F8",
"backgroundColorTop": "#F4F5F6",
"backgroundColorBottom": "#F4F5F6",
"backgroundColor": "#f5f8f7",
"backgroundColorTop": "#f5f8f7",
"backgroundColorBottom": "#f5f8f7",
"mp-360": {
"navigationStyle": "custom"
},
+141
View File
@@ -0,0 +1,141 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import {
getAdvance,
submitAdvance,
approveAdvance,
rejectAdvance,
getAdvanceApprovalHistory
} from '@/api/bx'
import { bxStatusLabel, formatMoney, dash } from '@/utils/bxLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const history = ref([])
const acting = ref(false)
const isDraft = computed(() => detail.value?.status === 'draft')
const isPending = computed(() => detail.value?.status === 'pending')
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.bxAdvanceList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getAdvance(id.value)
history.value = await getAdvanceApprovalHistory(id.value)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const onSubmit = () => {
uni.showModal({
title: '提交审批',
content: '确认提交该借款单?',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
await submitAdvance(id.value)
uni.showToast({ title: '已提交', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
const doApprove = (pass) => {
uni.showModal({
title: pass ? '审批通过' : '审批驳回',
editable: true,
placeholderText: '审批意见(可选)',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
const op = r.content || (pass ? '同意' : '驳回')
if (pass) await approveAdvance(id.value, op)
else await rejectAdvance(id.value, op)
uni.showToast({ title: '操作成功', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--bx">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">借款详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.applyCode) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.reason) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + bxStatusLabel(detail.status).tag">
{{ bxStatusLabel(detail.status).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ formatMoney(detail.amount) }}</text>
<text class="mp-detail-hero__chip">{{ dash(detail.applicantName) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">用途</text><text class="mp-kv__value">{{ dash(detail?.purpose) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">申请时间</text><text class="mp-kv__value">{{ dash(detail?.applyTime) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">当前节点</text><text class="mp-kv__value">{{ dash(detail?.currentNode) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">发放状态</text><text class="mp-kv__value">{{ dash(detail?.issueStatus) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view v-if="history.length" class="mp-block">
<text class="mp-block__title">审批记录</text>
<view class="mp-block__panel">
<view v-for="h in history" :key="h.id" class="mp-kv">
<text class="mp-kv__label">{{ dash(h.approverName || h.nodeName) }}</text>
<text class="mp-kv__value">{{ dash(h.approvalStatus) }} · {{ dash(h.approvalOpinion) }}</text>
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view v-if="isDraft" class="mp-btn mp-btn--primary" @click="onSubmit">
{{ acting ? '…' : '提交审批' }}
</view>
<template v-if="isPending">
<view class="mp-btn mp-btn--danger" @click="doApprove(false)">驳回</view>
<view class="mp-btn mp-btn--primary" @click="doApprove(true)">通过</view>
</template>
</view>
</view>
</template>
+153
View File
@@ -0,0 +1,153 @@
<script setup>
import { reactive, ref, onMounted } from 'vue'
import {
getExpenseItemTree,
flattenExpenseLeaves,
createAdvance,
submitAdvance,
bindAttachments
} from '@/api/bx'
import { chooseAndUploadBxAttachments } from '@/utils/bxUpload'
import { PAGE_PATHS } from '@/config/api'
const submitting = ref(false)
const leaves = ref([])
const photos = ref([])
const form = reactive({
reason: '',
purpose: '',
amount: '',
remark: '',
expenseItemId: '',
expenseLabel: ''
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.bxAdvanceList })
})
}
onMounted(async () => {
try {
leaves.value = flattenExpenseLeaves(await getExpenseItemTree())
} catch (e) {
uni.showToast({ title: e.message || '费用项目加载失败', icon: 'none' })
}
})
const pickExpense = () => {
if (!leaves.value.length) {
uni.showToast({ title: '暂无费用项目', icon: 'none' })
return
}
uni.showActionSheet({
itemList: leaves.value.map((i) => i.pathLabel).slice(0, 20),
success: (res) => {
const item = leaves.value[res.tapIndex]
if (!item) return
form.expenseItemId = item.id
form.expenseLabel = item.pathLabel
}
})
}
const addPhotos = async () => {
const uploaded = await chooseAndUploadBxAttachments({
bizType: 'advance_apply',
bizId: '0',
maxCount: 6,
currentCount: photos.value.length
})
photos.value = photos.value.concat(uploaded)
}
const removePhoto = (idx) => photos.value.splice(idx, 1)
const submit = async (andSubmit) => {
if (!String(form.reason).trim()) {
uni.showToast({ title: '请填写借款事由', icon: 'none' })
return
}
if (!form.amount || Number(form.amount) <= 0) {
uni.showToast({ title: '请填写金额', icon: 'none' })
return
}
submitting.value = true
try {
const created = await createAdvance({
reason: String(form.reason).trim(),
purpose: form.purpose || undefined,
amount: Number(form.amount),
remark: form.remark || undefined,
expenseItemId: form.expenseItemId || undefined
})
const id = created?.id
if (id && photos.value.length) {
await bindAttachments(
photos.value.map((p) => p.id),
'advance_apply',
id
)
}
if (andSubmit && id) {
await submitAdvance(id)
uni.showToast({ title: '已提交审批', icon: 'success' })
} else {
uni.showToast({ title: '已保存草稿', icon: 'success' })
}
setTimeout(() => goBack(), 500)
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--bx">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">新建借款</text>
</view>
<view class="mp-form-panel">
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">借款事由</text>
<textarea v-model="form.reason" class="mp-form-textarea" placeholder="用途说明" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">用途</text>
<input v-model="form.purpose" class="mp-form-input" placeholder="可选" />
</view>
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">金额(元)</text>
<input v-model="form.amount" class="mp-form-input" type="digit" placeholder="0.00" />
</view>
<view class="mp-form-row" @click="pickExpense">
<text class="mp-form-label">费用项目</text>
<view class="mp-form-input">{{ form.expenseLabel || '可选,点击选择' }}</view>
</view>
<view class="mp-form-row">
<text class="mp-form-label">备注</text>
<textarea v-model="form.remark" class="mp-form-textarea" placeholder="可选" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">凭证照片</text>
<view class="mp-photo-row">
<view v-for="(p, idx) in photos" :key="p.id" class="mp-photo-item">
<image :src="p.localPath" mode="aspectFill" />
<view class="mp-photo-del" @click.stop="removePhoto(idx)">×</view>
</view>
<view v-if="photos.length < 6" class="mp-photo-add" @click="addPhotos">+ 拍照</view>
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view class="mp-btn mp-btn--ghost" @click="submit(false)">{{ submitting ? '…' : '存草稿' }}</view>
<view class="mp-btn mp-btn--primary" @click="submit(true)">{{ submitting ? '…' : '提交审批' }}</view>
</view>
</view>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listMyAdvance } from '@/api/bx'
import { bxStatusLabel, formatMoney, dash } from '@/utils/bxLabels'
import { PAGE_PATHS } from '@/config/api'
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goForm = () => uni.navigateTo({ url: PAGE_PATHS.bxAdvanceForm })
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.bxAdvanceDetail}?id=${row.id}` })
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const res = await listMyAdvance({ page: page.value, pageSize })
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--bx">
<view class="mp-header__row">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<view class="mp-header__action" @click="goForm">+ 申请</view>
</view>
<text class="mp-header__title" style="margin-top: 16rpx">借款申请</text>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无借款单</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.applyCode) }}</text>
<text class="mp-card__sub">{{ dash(row.reason) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + bxStatusLabel(row.status).tag">
{{ bxStatusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>申请时间</text>
<text class="mp-card__row-val">{{ dash(row.applyTime) }}</text>
</view>
<view class="mp-card__foot">
<text class="mp-card__amount">{{ formatMoney(row.amount) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+140
View File
@@ -0,0 +1,140 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onShow, onReachBottom } from '@dcloudio/uni-app'
import {
listPendingReimburse,
listPendingAdvance,
approveReimburse,
rejectReimburse,
approveAdvance,
rejectAdvance
} from '@/api/bx'
import { bxStatusLabel, formatMoney, dash } from '@/utils/bxLabels'
import { PAGE_PATHS } from '@/config/api'
/** type: reimburse | advance */
const bizType = ref('reimburse')
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const pageSize = 20
const titleText = ref('报销审批')
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) => {
if (bizType.value === 'advance') {
uni.navigateTo({ url: `${PAGE_PATHS.bxAdvanceDetail}?id=${row.id}` })
} else {
uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseDetail}?id=${row.id}` })
}
}
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const q = { page: page.value, pageSize }
const res =
bizType.value === 'advance'
? await listPendingAdvance(q)
: await listPendingReimburse(q)
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const doApprove = (row, pass) => {
uni.showModal({
title: pass ? '通过' : '驳回',
editable: !pass,
placeholderText: '驳回意见',
success: async (r) => {
if (!r.confirm) return
try {
const op = r.content || (pass ? '同意' : '驳回')
if (bizType.value === 'advance') {
if (pass) await approveAdvance(row.id, op)
else await rejectAdvance(row.id, op)
} else if (pass) await approveReimburse(row.id, op)
else await rejectReimburse(row.id, op)
uni.showToast({ title: '操作成功', icon: 'success' })
loadList(true)
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
})
}
onLoad((q) => {
bizType.value = q?.type === 'advance' ? 'advance' : 'reimburse'
titleText.value = bizType.value === 'advance' ? '借款审批' : '报销审批'
})
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--bx">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">{{ titleText }}</text>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无待审批</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.applyCode) }}</text>
<text class="mp-card__sub">
{{ bizType === 'advance' ? dash(row.reason) : dash(row.expenseName || row.title) }}
</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + bxStatusLabel(row.status).tag">
{{ bxStatusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>申请人</text>
<text class="mp-card__row-val">{{ dash(row.applicantName) }}</text>
</view>
<view class="mp-card__foot">
<text class="mp-card__amount">{{ formatMoney(row.amount) }}</text>
<view style="display: flex; gap: 16rpx" @click.stop>
<view class="mp-btn mp-btn--danger mp-btn--sm" @click="doApprove(row, false)">驳回</view>
<view class="mp-btn mp-btn--primary mp-btn--sm" @click="doApprove(row, true)">通过</view>
</view>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+144
View File
@@ -0,0 +1,144 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listMyReimburse, listMyAdvance } from '@/api/bx'
import { bxStatusLabel, formatMoney, dash } from '@/utils/bxLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'all', label: '全部' },
{ key: 'reimburse', label: '报销' },
{ key: 'advance', label: '借款' }
]
const activeTab = ref('all')
const list = ref([])
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) => {
if (row._biz === 'advance') {
uni.navigateTo({ url: `${PAGE_PATHS.bxAdvanceDetail}?id=${row.id}` })
} else {
uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseDetail}?id=${row.id}` })
}
}
const mark = (rows, biz) =>
(rows || []).map((r) => ({
...r,
_biz: biz,
_title: biz === 'advance' ? `借款 ${r.applyCode || ''}` : `报销 ${r.applyCode || ''}`,
_sub: biz === 'advance' ? dash(r.reason) : dash(r.expenseName || r.title)
}))
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const q = { page: page.value, pageSize }
let rows = []
if (activeTab.value === 'reimburse') {
const res = await listMyReimburse(q)
rows = mark(res.rows, 'reimburse')
list.value = reset ? rows : list.value.concat(rows)
if (list.value.length >= (res.total || 0) || rows.length < pageSize) finished.value = true
else page.value += 1
} else if (activeTab.value === 'advance') {
const res = await listMyAdvance(q)
rows = mark(res.rows, 'advance')
list.value = reset ? rows : list.value.concat(rows)
if (list.value.length >= (res.total || 0) || rows.length < pageSize) finished.value = true
else page.value += 1
} else {
const [a, b] = await Promise.all([listMyReimburse(q), listMyAdvance(q)])
rows = mark(a.rows, 'reimburse').concat(mark(b.rows, 'advance'))
rows.sort((x, y) => String(y.applyTime || '').localeCompare(String(x.applyTime || '')))
list.value = reset ? rows : list.value.concat(rows)
const total = (a.total || 0) + (b.total || 0)
if (list.value.length >= total || (a.rows.length < pageSize && b.rows.length < pageSize)) {
finished.value = true
} else page.value += 1
}
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
if (activeTab.value === key) return
activeTab.value = key
loadList(true)
}
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--bx">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">我的单据</text>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无单据</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row._biz + '-' + row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ row._title }}</text>
<text class="mp-card__sub">{{ row._sub }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + bxStatusLabel(row.status).tag">
{{ bxStatusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>申请时间</text>
<text class="mp-card__row-val">{{ dash(row.applyTime) }}</text>
</view>
<view class="mp-card__foot">
<text class="mp-card__amount">{{ formatMoney(row.amount) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+181
View File
@@ -0,0 +1,181 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import {
getReimburse,
submitReimburse,
approveReimburse,
rejectReimburse,
getReimburseApprovalHistory,
listAttachments,
previewAttachment
} from '@/api/bx'
import { bxStatusLabel, formatMoney, dash } from '@/utils/bxLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const history = ref([])
const attachUrls = ref([])
const acting = ref(false)
const isDraft = computed(() => detail.value?.status === 'draft')
const isPending = computed(() => detail.value?.status === 'pending')
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.bxReimburseList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getReimburse(id.value)
history.value = await getReimburseApprovalHistory(id.value)
const files = await listAttachments('reimburse_apply', id.value)
const urls = []
for (const f of files) {
try {
const prev = await previewAttachment(f.id)
if (prev?.url) urls.push(prev.url)
} catch {
/* skip */
}
}
attachUrls.value = urls
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const onSubmit = () => {
uni.showModal({
title: '提交审批',
content: '确认提交该报销单?',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
await submitReimburse(id.value)
uni.showToast({ title: '已提交', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
const doApprove = (pass) => {
uni.showModal({
title: pass ? '审批通过' : '审批驳回',
editable: true,
placeholderText: '审批意见(可选)',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
const op = r.content || (pass ? '同意' : '驳回')
if (pass) await approveReimburse(id.value, op)
else await rejectReimburse(id.value, op)
uni.showToast({ title: '操作成功', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
const preview = (url) => {
uni.previewImage({ urls: attachUrls.value, current: url })
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--bx">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">报销详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.applyCode) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.title) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + bxStatusLabel(detail.status).tag">
{{ bxStatusLabel(detail.status).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ formatMoney(detail.amount) }}</text>
<text class="mp-detail-hero__chip">{{ dash(detail.applicantName) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">费用项目</text><text class="mp-kv__value">{{ dash(detail?.expenseName) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">报销日期</text><text class="mp-kv__value">{{ dash(detail?.reimburseDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">申请时间</text><text class="mp-kv__value">{{ dash(detail?.applyTime) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">当前节点</text><text class="mp-kv__value">{{ dash(detail?.currentNode) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view v-if="detail?.details?.length" class="mp-block">
<text class="mp-block__title">明细</text>
<view class="mp-list">
<view v-for="d in detail.details" :key="d.id" class="mp-card">
<view class="mp-card__head">
<text class="mp-card__title">{{ dash(d.expenseName) }}</text>
<text class="mp-card__amount">{{ formatMoney(d.amount) }}</text>
</view>
<text style="font-size: 24rpx; color: var(--mp-muted)">{{ dash(d.remark) }}</text>
</view>
</view>
</view>
<view v-if="attachUrls.length" class="mp-block">
<text class="mp-block__title">票据</text>
<view class="mp-photo-row" style="padding: 0 0 8rpx">
<view v-for="(url, i) in attachUrls" :key="i" class="mp-photo-item" @click="preview(url)">
<image :src="url" mode="aspectFill" />
</view>
</view>
</view>
<view v-if="history.length" class="mp-block">
<text class="mp-block__title">审批记录</text>
<view class="mp-block__panel">
<view v-for="h in history" :key="h.id" class="mp-kv">
<text class="mp-kv__label">{{ dash(h.approverName || h.nodeName) }}</text>
<text class="mp-kv__value">{{ dash(h.approvalStatus) }} · {{ dash(h.approvalOpinion) }}</text>
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view v-if="isDraft" class="mp-btn mp-btn--primary" @click="onSubmit">
{{ acting ? '…' : '提交审批' }}
</view>
<template v-if="isPending">
<view class="mp-btn mp-btn--danger" @click="doApprove(false)">驳回</view>
<view class="mp-btn mp-btn--primary" @click="doApprove(true)">通过</view>
</template>
</view>
</view>
</template>
+190
View File
@@ -0,0 +1,190 @@
<script setup>
import { reactive, ref, onMounted } from 'vue'
import {
getExpenseItemTree,
flattenExpenseLeaves,
createReimburse,
submitReimburse,
generateReimburseTitle,
bindAttachments
} from '@/api/bx'
import { chooseAndUploadBxAttachments } from '@/utils/bxUpload'
import { todayStr } from '@/utils/bxLabels'
import { PAGE_PATHS } from '@/config/api'
const submitting = ref(false)
const leaves = ref([])
const photos = ref([])
const form = reactive({
title: '',
reimburseDate: todayStr(),
remark: '',
expenseItemId: '',
expenseLabel: '',
amount: '',
detailRemark: ''
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.bxReimburseList })
})
}
const loadTree = async () => {
try {
const tree = await getExpenseItemTree()
leaves.value = flattenExpenseLeaves(tree)
} catch (e) {
uni.showToast({ title: e.message || '费用项目加载失败', icon: 'none' })
}
}
const refreshTitle = async () => {
try {
const t = await generateReimburseTitle(form.reimburseDate)
if (typeof t === 'string') form.title = t
else if (t) form.title = String(t)
} catch {
/* optional */
}
}
const pickExpense = () => {
if (!leaves.value.length) {
uni.showToast({ title: '暂无费用项目,请先在 Web 配置', icon: 'none' })
return
}
const names = leaves.value.map((i) => i.pathLabel).slice(0, 20)
uni.showActionSheet({
itemList: names,
success: (res) => {
const item = leaves.value[res.tapIndex]
if (!item) return
form.expenseItemId = item.id
form.expenseLabel = item.pathLabel
}
})
}
const addPhotos = async () => {
const uploaded = await chooseAndUploadBxAttachments({
bizType: 'reimburse_apply',
bizId: '0',
maxCount: 6,
currentCount: photos.value.length
})
photos.value = photos.value.concat(uploaded)
}
const removePhoto = (idx) => {
photos.value.splice(idx, 1)
}
const submit = async (andSubmit) => {
if (!form.expenseItemId) {
uni.showToast({ title: '请选择费用项目', icon: 'none' })
return
}
if (!form.amount || Number(form.amount) <= 0) {
uni.showToast({ title: '请填写金额', icon: 'none' })
return
}
if (!String(form.detailRemark || '').trim()) {
uni.showToast({ title: '请填写明细说明', icon: 'none' })
return
}
submitting.value = true
try {
const created = await createReimburse({
title: form.title || undefined,
reimburseDate: form.reimburseDate,
remark: form.remark,
details: [
{
expenseItemId: form.expenseItemId,
amount: Number(form.amount),
remark: String(form.detailRemark).trim()
}
]
})
const id = created?.id
if (id && photos.value.length) {
await bindAttachments(
photos.value.map((p) => p.id),
'reimburse_apply',
id
)
}
if (andSubmit && id) {
await submitReimburse(id)
uni.showToast({ title: '已提交审批', icon: 'success' })
} else {
uni.showToast({ title: '已保存草稿', icon: 'success' })
}
setTimeout(() => goBack(), 500)
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
submitting.value = false
}
}
onMounted(async () => {
await loadTree()
await refreshTitle()
})
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--bx">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">新建报销</text>
</view>
<view class="mp-form-panel">
<view class="mp-form-row">
<text class="mp-form-label">标题</text>
<input v-model="form.title" class="mp-form-input" placeholder="自动生成,可修改" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">报销日期</text>
<picker mode="date" :value="form.reimburseDate" @change="(e) => { form.reimburseDate = e.detail.value; refreshTitle() }">
<view class="mp-form-input">{{ form.reimburseDate }}</view>
</picker>
</view>
<view class="mp-form-row" @click="pickExpense">
<text class="mp-form-label mp-form-label--req">费用项目</text>
<view class="mp-form-input">{{ form.expenseLabel || '点击选择' }}</view>
</view>
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">金额(元)</text>
<input v-model="form.amount" class="mp-form-input" type="digit" placeholder="0.00" />
</view>
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">明细说明</text>
<textarea v-model="form.detailRemark" class="mp-form-textarea" placeholder="费用事由" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">备注</text>
<textarea v-model="form.remark" class="mp-form-textarea" placeholder="可选" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">票据照片</text>
<view class="mp-photo-row">
<view v-for="(p, idx) in photos" :key="p.id" class="mp-photo-item">
<image :src="p.localPath" mode="aspectFill" />
<view class="mp-photo-del" @click.stop="removePhoto(idx)">×</view>
</view>
<view v-if="photos.length < 6" class="mp-photo-add" @click="addPhotos">+ 拍照</view>
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view class="mp-btn mp-btn--ghost" @click="submit(false)">{{ submitting ? '…' : '存草稿' }}</view>
<view class="mp-btn mp-btn--primary" @click="submit(true)">{{ submitting ? '…' : '提交审批' }}</view>
</view>
</view>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listMyReimburse } from '@/api/bx'
import { bxStatusLabel, formatMoney, dash } from '@/utils/bxLabels'
import { PAGE_PATHS } from '@/config/api'
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goForm = () => uni.navigateTo({ url: PAGE_PATHS.bxReimburseForm })
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseDetail}?id=${row.id}` })
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const res = await listMyReimburse({ page: page.value, pageSize })
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--bx">
<view class="mp-header__row">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<view class="mp-header__action" @click="goForm">+ 申请</view>
</view>
<text class="mp-header__title" style="margin-top: 16rpx">报销申请</text>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无报销单</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.applyCode) }}</text>
<text class="mp-card__sub">{{ dash(row.expenseName || row.title) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + bxStatusLabel(row.status).tag">
{{ bxStatusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>申请时间</text>
<text class="mp-card__row-val">{{ dash(row.applyTime) }}</text>
</view>
<view class="mp-card__foot">
<text class="mp-card__amount">{{ formatMoney(row.amount) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+210
View File
@@ -0,0 +1,210 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import {
listPendingReimburse,
listPendingAdvance,
listMyReimburse,
listMyAdvance,
listApprovedByMeReimburse,
listApprovedByMeAdvance,
approveReimburse,
rejectReimburse,
approveAdvance,
rejectAdvance
} from '@/api/bx'
import { bxStatusLabel, formatMoney, dash } from '@/utils/bxLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'pending', label: '待审批' },
{ key: 'mine', label: '我的申请' },
{ key: 'done', label: '已办' }
]
const activeTab = ref('pending')
const list = ref([])
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const pageSize = 10
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goApply = () => {
uni.showActionSheet({
itemList: ['报销申请', '借款申请'],
success: (res) => {
if (res.tapIndex === 0) uni.navigateTo({ url: PAGE_PATHS.bxReimburseForm })
if (res.tapIndex === 1) uni.navigateTo({ url: PAGE_PATHS.bxAdvanceForm })
}
})
}
const goDetail = (row) => {
if (row._biz === 'advance') {
uni.navigateTo({ url: `${PAGE_PATHS.bxAdvanceDetail}?id=${row.id}` })
} else {
uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseDetail}?id=${row.id}` })
}
}
const markBiz = (rows, biz) =>
(rows || []).map((r) => ({
...r,
_biz: biz,
_title: biz === 'advance' ? `借款单 ${r.applyCode || ''}` : `报销单 ${r.applyCode || ''}`,
_sub:
biz === 'advance'
? dash(r.reason || r.purpose)
: dash(r.expenseName || r.title)
}))
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const q = { page: page.value, pageSize }
let rows = []
if (activeTab.value === 'pending') {
const [a, b] = await Promise.all([listPendingReimburse(q), listPendingAdvance(q)])
rows = markBiz(a.rows, 'reimburse').concat(markBiz(b.rows, 'advance'))
rows.sort((x, y) => String(y.applyTime || '').localeCompare(String(x.applyTime || '')))
finished.value = true
} else if (activeTab.value === 'mine') {
const [a, b] = await Promise.all([listMyReimburse(q), listMyAdvance(q)])
rows = markBiz(a.rows, 'reimburse').concat(markBiz(b.rows, 'advance'))
rows.sort((x, y) => String(y.applyTime || '').localeCompare(String(x.applyTime || '')))
const total = (a.total || 0) + (b.total || 0)
list.value = reset ? rows : list.value.concat(rows)
if (list.value.length >= total || (a.rows.length < pageSize && b.rows.length < pageSize)) {
finished.value = true
} else page.value += 1
return
} else {
const [a, b] = await Promise.all([
listApprovedByMeReimburse(q),
listApprovedByMeAdvance(q)
])
rows = markBiz(a.rows, 'reimburse').concat(markBiz(b.rows, 'advance'))
rows.sort((x, y) => String(y.applyTime || '').localeCompare(String(x.applyTime || '')))
finished.value = true
}
list.value = reset ? rows : list.value.concat(rows)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
if (activeTab.value === key) return
activeTab.value = key
loadList(true)
}
const doApprove = (row, pass) => {
const title = pass ? '通过' : '驳回'
uni.showModal({
title: `确认${title}`,
editable: !pass,
placeholderText: pass ? '' : '驳回意见(可选)',
success: async (r) => {
if (!r.confirm) return
try {
const opinion = r.content || (pass ? '同意' : '驳回')
if (row._biz === 'advance') {
if (pass) await approveAdvance(row.id, opinion)
else await rejectAdvance(row.id, opinion)
} else {
if (pass) await approveReimburse(row.id, opinion)
else await rejectReimburse(row.id, opinion)
}
uni.showToast({ title: `${title}成功`, icon: 'success' })
loadList(true)
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
})
}
onShow(() => loadList(true))
onReachBottom(() => {
if (activeTab.value === 'mine') loadList(false)
})
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--bx">
<view class="mp-header__row">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<view class="mp-header__action" @click="goApply">+ 申请</view>
</view>
<text class="mp-header__title" style="margin-top: 16rpx">待办任务</text>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无单据</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row._biz + '-' + row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ row._title }}</text>
<text class="mp-card__sub">{{ row._sub }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + bxStatusLabel(row.status).tag">
{{ bxStatusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>申请人</text>
<text class="mp-card__row-val">{{ dash(row.applicantName) }}</text>
</view>
<view class="mp-card__row">
<text>申请时间</text>
<text class="mp-card__row-val">{{ dash(row.applyTime) }}</text>
</view>
<view class="mp-card__foot">
<text class="mp-card__amount">{{ formatMoney(row.amount) }}</text>
<view v-if="activeTab === 'pending'" style="display: flex; gap: 16rpx" @click.stop>
<view class="mp-btn mp-btn--danger mp-btn--sm" @click="doApprove(row, false)">驳回</view>
<view class="mp-btn mp-btn--primary mp-btn--sm" @click="doApprove(row, true)">通过</view>
</view>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
@@ -0,0 +1,59 @@
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const title = ref('业务页')
const moduleTitle = ref('')
const code = ref('')
onLoad((query) => {
title.value = decodeURIComponent(query?.title || '业务页')
moduleTitle.value = decodeURIComponent(query?.module || '')
code.value = decodeURIComponent(query?.code || '')
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: '/pages/workbench/workbench' })
})
}
</script>
<template>
<view class="mp-page">
<view class="mp-header">
<view class="biz-placeholder__nav" @click="goBack">
<text class="biz-placeholder__back">← 工作台</text>
</view>
<text class="mp-header__title" style="margin-top: 16rpx">{{ title }}</text>
<text v-if="moduleTitle" class="mp-header__meta">{{ moduleTitle }}</text>
</view>
<view class="mp-placeholder">
<view class="mp-placeholder__icon" />
<text class="mp-placeholder__title">{{ title }}</text>
<text class="mp-placeholder__desc">
P0 壳层已就绪,业务列表/详情将在后续阶段(P1–P4)实现。
</text>
<text v-if="code" class="biz-placeholder__code">菜单编码:{{ code }}</text>
</view>
</view>
</template>
<style lang="scss" scoped>
.biz-placeholder__nav {
min-height: 44rpx;
}
.biz-placeholder__back {
color: #fff;
font-size: 26rpx;
opacity: 0.95;
}
.biz-placeholder__code {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: #9aa8a1;
}
</style>
@@ -0,0 +1,11 @@
# 小程序 UI Pattern(P0 占位说明)
完整 Pattern 对照 Demo:
- `mobile-ui/shanxia-mp-pattern-list.html` — 列表
- `mobile-ui/shanxia-mp-pattern-view.html` — 查看详情
- `mobile-ui/shanxia-mp-pattern-form.html` — 表单
- `mobile-ui/shanxia-mp-pattern-feedback.html` — 弹窗反馈
P0 已提供壳层样式 `styles/mp-shell.scss` 与业务占位页 `biz-placeholder.vue`。
列表/详情/表单可复用组件将在 **P1 首个业务页** 落地时抽取,避免空壳组件无调用方。
+207
View File
@@ -0,0 +1,207 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import {
getClue,
listClueFollow,
addClueFollow,
convertClue,
markClueInvalid
} from '@/api/crm'
import { labelOf, dash } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const follows = ref([])
const loading = ref(false)
const followContent = ref('')
const followType = ref('电话')
const showFollow = ref(false)
const submitting = ref(false)
const statusLabel = (s) => labelOf('clueStatus', s)
const levelLabel = (s) => labelOf('level', s)
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.crmClueList })
})
}
const load = async () => {
if (!id.value) return
loading.value = true
try {
detail.value = await getClue(id.value)
const fl = await listClueFollow(id.value, 1, 50)
follows.value = fl.rows || []
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const submitFollow = async () => {
if (!followContent.value.trim()) {
uni.showToast({ title: '请填写跟进内容', icon: 'none' })
return
}
submitting.value = true
try {
await addClueFollow({
clueId: id.value,
followType: followType.value,
followContent: followContent.value.trim()
})
uni.showToast({ title: '已保存', icon: 'success' })
showFollow.value = false
followContent.value = ''
await load()
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
submitting.value = false
}
}
const onConvert = () => {
const d = detail.value || {}
uni.showModal({
title: '转化为客户',
content: `将「${d.clueName || ''}」转为客户,是否继续?`,
success: async (r) => {
if (!r.confirm) return
try {
await convertClue({
id: id.value,
customerName: d.company || d.clueName,
customerType: d.customerType || 'company',
contactName: d.clueName,
contactMobile: d.mobile
})
uni.showToast({ title: '转化成功', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '转化失败', icon: 'none' })
}
}
})
}
const onInvalid = () => {
uni.showModal({
title: '标记无效',
editable: true,
placeholderText: '无效原因(可选)',
success: async (r) => {
if (!r.confirm) return
try {
await markClueInvalid({ id: id.value, remark: r.content || '' })
uni.showToast({ title: '已标记', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
})
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">线索详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.clueName) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.company) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(detail.status).tag">
{{ statusLabel(detail.status).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ levelLabel(detail.level).text }}</text>
<text class="mp-detail-hero__chip">{{ dash(detail.ownerUserName) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">手机</text><text class="mp-kv__value">{{ dash(detail?.mobile) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">职位</text><text class="mp-kv__value">{{ dash(detail?.position) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">来源</text><text class="mp-kv__value">{{ dash(detail?.source) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">行业</text><text class="mp-kv__value">{{ dash(detail?.industry) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">园区</text><text class="mp-kv__value">{{ dash(detail?.parkArea) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">跟进记录</text>
<view v-if="!follows.length" class="mp-empty" style="padding: 40rpx 0">
<text class="mp-empty__text">暂无跟进</text>
</view>
<view v-else class="mp-list">
<view v-for="f in follows" :key="f.id" class="mp-card">
<view class="mp-card__head">
<text class="mp-card__title">{{ dash(f.followType) }}</text>
<text class="mp-card__sub">{{ dash(f.createTime) }}</text>
</view>
<text style="font-size: 26rpx; color: var(--mp-text)">{{ dash(f.followContent) }}</text>
<view class="mp-card__row" style="margin-top: 12rpx; margin-bottom: 0">
<text>跟进人</text>
<text class="mp-card__row-val">{{ dash(f.createByName) }}</text>
</view>
</view>
</view>
</view>
<view v-if="showFollow" class="mp-form-panel">
<view class="mp-form-row">
<text class="mp-form-label">跟进方式</text>
<input v-model="followType" class="mp-form-input" placeholder="如:电话 / 微信 / 拜访" />
</view>
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">跟进内容</text>
<textarea v-model="followContent" class="mp-form-textarea" placeholder="填写本次沟通要点" />
</view>
<view class="mp-form-row" style="display: flex; gap: 16rpx">
<view class="mp-btn mp-btn--ghost" style="flex: 1" @click="showFollow = false">取消</view>
<view class="mp-btn mp-btn--primary" style="flex: 1" @click="submitFollow">
{{ submitting ? '提交中…' : '提交跟进' }}
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view class="mp-btn mp-btn--ghost" @click="showFollow = true">跟进</view>
<view
v-if="detail && detail.status !== 'converted' && detail.status !== 'invalid'"
class="mp-btn mp-btn--primary"
@click="onConvert"
>
转客户
</view>
<view
v-if="detail && detail.status !== 'converted' && detail.status !== 'invalid'"
class="mp-btn mp-btn--danger"
@click="onInvalid"
>
无效
</view>
</view>
</view>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup>
import { reactive, ref } from 'vue'
import { addClue } from '@/api/crm'
import { PAGE_PATHS } from '@/config/api'
const submitting = ref(false)
const form = reactive({
clueName: '',
mobile: '',
company: '',
customerType: 'company',
position: '',
source: '其它',
level: 'C',
industry: '',
parkArea: '',
province: '',
city: '',
remark: ''
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.crmClueList })
})
}
const submit = async () => {
if (!form.clueName.trim()) {
uni.showToast({ title: '请填写线索姓名', icon: 'none' })
return
}
if (!form.mobile.trim()) {
uni.showToast({ title: '请填写手机号', icon: 'none' })
return
}
if (!form.company.trim()) {
uni.showToast({ title: '请填写公司名称', icon: 'none' })
return
}
submitting.value = true
try {
await addClue({
...form,
position: form.position || '-',
industry: form.industry || '-',
parkArea: form.parkArea || '-',
province: form.province || '-',
city: form.city || '-'
})
uni.showToast({ title: '创建成功', icon: 'success' })
setTimeout(() => goBack(), 500)
} catch (e) {
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">新增线索</text>
</view>
<view class="mp-form-panel">
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">线索姓名</text>
<input v-model="form.clueName" class="mp-form-input" placeholder="联系人姓名" />
</view>
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">手机号</text>
<input v-model="form.mobile" class="mp-form-input" type="number" placeholder="手机号" />
</view>
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">公司名称</text>
<input v-model="form.company" class="mp-form-input" placeholder="公司 / 单位" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">职位</text>
<input v-model="form.position" class="mp-form-input" placeholder="职位" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">来源</text>
<input v-model="form.source" class="mp-form-input" placeholder="线索来源" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">行业</text>
<input v-model="form.industry" class="mp-form-input" placeholder="所属行业" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">园区</text>
<input v-model="form.parkArea" class="mp-form-input" placeholder="所在园区" />
</view>
<view class="mp-form-row">
<text class="mp-form-label">备注</text>
<textarea v-model="form.remark" class="mp-form-textarea" placeholder="备注" />
</view>
</view>
<view class="mp-bottom-bar">
<view class="mp-btn mp-btn--ghost" @click="goBack">取消</view>
<view class="mp-btn mp-btn--primary" @click="submit">
{{ submitting ? '提交中…' : '保存' }}
</view>
</view>
</view>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listClue, listMyClue } from '@/api/crm'
import { labelOf, dash } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'all', label: '全部' },
{ key: 'mine', label: '我的' },
{ key: 'new', label: '新线索', status: 'new' },
{ key: 'follow', label: '跟进中', status: 'follow' },
{ key: 'converted', label: '已转化', status: 'converted' }
]
const activeTab = ref('all')
const keyword = ref('')
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goForm = () => {
uni.navigateTo({ url: PAGE_PATHS.crmClueForm })
}
const goDetail = (row) => {
uni.navigateTo({ url: `${PAGE_PATHS.crmClueDetail}?id=${row.id}` })
}
const statusLabel = (status) => labelOf('clueStatus', status)
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const tab = tabs.find((t) => t.key === activeTab.value)
const query = {
pageNum: page.value,
pageSize,
keyword: keyword.value.trim() || undefined
}
if (tab?.status) query.status = tab.status
if (activeTab.value === 'converted') query.viewType = 'converted'
const res = activeTab.value === 'mine' ? await listMyClue(query) : await listClue(query)
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) {
finished.value = true
} else {
page.value += 1
}
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
if (activeTab.value === key) return
activeTab.value = key
loadList(true)
}
const onSearch = () => loadList(true)
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header">
<view class="mp-header__row">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<view class="mp-header__action" @click="goForm">新增</view>
</view>
<text class="mp-header__title" style="margin-top: 16rpx">线索管理</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="搜索姓名 / 公司 / 手机"
confirm-type="search"
@confirm="onSearch"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无线索</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.clueName) }}</text>
<text class="mp-card__sub">{{ dash(row.company) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(row.status).tag">
{{ statusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>手机</text>
<text class="mp-card__row-val">{{ dash(row.mobile) }}</text>
</view>
<view class="mp-card__row">
<text>负责人</text>
<text class="mp-card__row-val">{{ dash(row.ownerUserName) }}</text>
</view>
<view class="mp-card__row">
<text>来源</text>
<text class="mp-card__row-val">{{ dash(row.source) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getContract } from '@/api/crm'
import { labelOf, dash, formatMoney } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const statusLabel = (s) => labelOf('contractStatus', s)
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.crmContractList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getContract(id.value)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">合同详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.contractName) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.customerName) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(detail.status).tag">
{{ statusLabel(detail.status).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ formatMoney(detail.totalAmount ?? detail.amount) }}</text>
<text class="mp-detail-hero__chip">{{ dash(detail.ownerUserName) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">合同编号</text><text class="mp-kv__value">{{ dash(detail?.contractNo) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">合同类型</text><text class="mp-kv__value">{{ dash(detail?.contractType) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">签订日期</text><text class="mp-kv__value">{{ dash(detail?.signDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">开始日期</text><text class="mp-kv__value">{{ dash(detail?.startDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">结束日期</text><text class="mp-kv__value">{{ dash(detail?.endDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">审批状态</text><text class="mp-kv__value">{{ dash(detail?.approvalStatus) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">说明</text>
<view class="mp-block__panel" style="padding: 28rpx">
<text style="font-size: 26rpx; color: var(--mp-muted)">
合同审批流与附件签署请在 Web 管理端处理;小程序提供现场查阅。
</text>
</view>
</view>
</view>
</template>
+143
View File
@@ -0,0 +1,143 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listContract } from '@/api/crm'
import { labelOf, dash, formatMoney } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'all', label: '全部' },
{ key: 'draft', label: '草稿', status: 'draft' },
{ key: 'pending', label: '待审批', status: 'pending' },
{ key: 'approved', label: '已生效', status: 'approved' },
{ key: 'executing', label: '执行中', status: 'executing' }
]
const activeTab = ref('all')
const keyword = ref('')
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) => {
uni.navigateTo({ url: `${PAGE_PATHS.crmContractDetail}?id=${row.id}` })
}
const statusLabel = (s) => labelOf('contractStatus', s)
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const tab = tabs.find((t) => t.key === activeTab.value)
const query = {
pageNum: page.value,
pageSize,
contractName: keyword.value.trim() || undefined,
customerName: keyword.value.trim() || undefined
}
if (tab?.status) query.status = tab.status
const res = await listContract(query)
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
if (activeTab.value === key) return
activeTab.value = key
loadList(true)
}
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">合同管理</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="搜索合同 / 客户"
confirm-type="search"
@confirm="loadList(true)"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无合同</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.contractName) }}</text>
<text class="mp-card__sub">{{ dash(row.contractNo) }} · {{ dash(row.customerName) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(row.status).tag">
{{ statusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>金额</text>
<text class="mp-card__row-val">{{ formatMoney(row.totalAmount ?? row.amount) }}</text>
</view>
<view class="mp-card__row">
<text>负责人</text>
<text class="mp-card__row-val">{{ dash(row.ownerUserName) }}</text>
</view>
<view class="mp-card__row">
<text>签订日期</text>
<text class="mp-card__row-val">{{ dash(row.signDate) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+137
View File
@@ -0,0 +1,137 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getCustomer, listCustomerFollow, addCustomerFollow } from '@/api/crm'
import { labelOf, dash } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const follows = ref([])
const showFollow = ref(false)
const followContent = ref('')
const followType = ref('电话')
const submitting = ref(false)
const statusLabel = (s) => labelOf('customerStatus', s)
const levelLabel = (s) => labelOf('level', s)
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.crmCustomerList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getCustomer(id.value)
const fl = await listCustomerFollow(id.value, 1, 50)
follows.value = fl.rows || []
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const submitFollow = async () => {
if (!followContent.value.trim()) {
uni.showToast({ title: '请填写跟进内容', icon: 'none' })
return
}
submitting.value = true
try {
await addCustomerFollow({
customerId: id.value,
followType: followType.value,
followContent: followContent.value.trim()
})
uni.showToast({ title: '已保存', icon: 'success' })
showFollow.value = false
followContent.value = ''
await load()
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
submitting.value = false
}
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">客户详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.customerName) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.customerNo) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(detail.status).tag">
{{ statusLabel(detail.status).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ levelLabel(detail.level).text }}</text>
<text class="mp-detail-hero__chip">{{ dash(detail.ownerUserName) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">类型</text><text class="mp-kv__value">{{ dash(detail?.customerType) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">来源</text><text class="mp-kv__value">{{ dash(detail?.source) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">行业</text><text class="mp-kv__value">{{ dash(detail?.industry) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">手机</text><text class="mp-kv__value">{{ dash(detail?.mobile) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">地址</text><text class="mp-kv__value">{{ dash(detail?.address) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">跟进记录</text>
<view v-if="!follows.length" class="mp-empty" style="padding: 40rpx 0">
<text class="mp-empty__text">暂无跟进</text>
</view>
<view v-else class="mp-list">
<view v-for="f in follows" :key="f.id" class="mp-card">
<view class="mp-card__head">
<text class="mp-card__title">{{ dash(f.followType) }}</text>
<text class="mp-card__sub">{{ dash(f.createTime) }}</text>
</view>
<text style="font-size: 26rpx">{{ dash(f.followContent) }}</text>
</view>
</view>
</view>
<view v-if="showFollow" class="mp-form-panel">
<view class="mp-form-row">
<text class="mp-form-label">跟进方式</text>
<input v-model="followType" class="mp-form-input" />
</view>
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">跟进内容</text>
<textarea v-model="followContent" class="mp-form-textarea" />
</view>
<view class="mp-form-row" style="display: flex; gap: 16rpx">
<view class="mp-btn mp-btn--ghost" style="flex: 1" @click="showFollow = false">取消</view>
<view class="mp-btn mp-btn--primary" style="flex: 1" @click="submitFollow">
{{ submitting ? '提交中…' : '提交' }}
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view class="mp-btn mp-btn--primary" @click="showFollow = true">写跟进</view>
</view>
</view>
</template>
+140
View File
@@ -0,0 +1,140 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listCustomer, listMyCustomer } from '@/api/crm'
import { labelOf, dash } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'mine', label: '我的' },
{ key: 'all', label: '全部' }
]
const activeTab = ref('mine')
const keyword = ref('')
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) => {
uni.navigateTo({ url: `${PAGE_PATHS.crmCustomerDetail}?id=${row.id}` })
}
const statusLabel = (s) => labelOf('customerStatus', s)
const levelLabel = (s) => labelOf('level', s)
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const query = {
pageNum: page.value,
pageSize,
keyword: keyword.value.trim() || undefined,
customerName: keyword.value.trim() || undefined
}
const res =
activeTab.value === 'mine' ? await listMyCustomer(query) : await listCustomer(query)
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
if (activeTab.value === key) return
activeTab.value = key
loadList(true)
}
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">客户管理</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="搜索客户名称"
confirm-type="search"
@confirm="loadList(true)"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无客户</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.customerName) }}</text>
<text class="mp-card__sub">{{ dash(row.customerNo) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(row.status).tag">
{{ statusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>级别</text>
<text class="mp-card__row-val">{{ levelLabel(row.level).text }}</text>
</view>
<view class="mp-card__row">
<text>联系人</text>
<text class="mp-card__row-val">{{ dash(row.contactName || row.mobile) }}</text>
</view>
<view class="mp-card__row">
<text>负责人</text>
<text class="mp-card__row-val">{{ dash(row.ownerUserName) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
@@ -0,0 +1,136 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getOpportunity, listOpportunityFollow, addOpportunityFollow } from '@/api/crm'
import { labelOf, dash, formatMoney } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const follows = ref([])
const showFollow = ref(false)
const followContent = ref('')
const followType = ref('电话')
const submitting = ref(false)
const stageLabel = (s) => labelOf('opportunityStage', s)
const statusLabel = (s) => labelOf('opportunityStatus', s)
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.crmOpportunityList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getOpportunity(id.value)
const fl = await listOpportunityFollow(id.value, 1, 50)
follows.value = fl.rows || []
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const submitFollow = async () => {
if (!followContent.value.trim()) {
uni.showToast({ title: '请填写跟进内容', icon: 'none' })
return
}
submitting.value = true
try {
await addOpportunityFollow({
opportunityId: id.value,
followType: followType.value,
followContent: followContent.value.trim()
})
uni.showToast({ title: '已保存', icon: 'success' })
showFollow.value = false
followContent.value = ''
await load()
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
submitting.value = false
}
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">商机详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.opportunityName) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.customerName) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(detail.status).tag">
{{ statusLabel(detail.status).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ stageLabel(detail.stage).text }}</text>
<text class="mp-detail-hero__chip">{{ formatMoney(detail.amount ?? detail.expectedAmount) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">商机编号</text><text class="mp-kv__value">{{ dash(detail?.opportunityNo) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">负责人</text><text class="mp-kv__value">{{ dash(detail?.ownerUserName) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">来源</text><text class="mp-kv__value">{{ dash(detail?.source) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">预计成交</text><text class="mp-kv__value">{{ dash(detail?.expectedDate || detail?.dealDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">跟进记录</text>
<view v-if="!follows.length" class="mp-empty" style="padding: 40rpx 0">
<text class="mp-empty__text">暂无跟进</text>
</view>
<view v-else class="mp-list">
<view v-for="f in follows" :key="f.id" class="mp-card">
<view class="mp-card__head">
<text class="mp-card__title">{{ dash(f.followType) }}</text>
<text class="mp-card__sub">{{ dash(f.createTime) }}</text>
</view>
<text style="font-size: 26rpx">{{ dash(f.followContent) }}</text>
</view>
</view>
</view>
<view v-if="showFollow" class="mp-form-panel">
<view class="mp-form-row">
<text class="mp-form-label">跟进方式</text>
<input v-model="followType" class="mp-form-input" />
</view>
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">跟进内容</text>
<textarea v-model="followContent" class="mp-form-textarea" />
</view>
<view class="mp-form-row" style="display: flex; gap: 16rpx">
<view class="mp-btn mp-btn--ghost" style="flex: 1" @click="showFollow = false">取消</view>
<view class="mp-btn mp-btn--primary" style="flex: 1" @click="submitFollow">
{{ submitting ? '提交中…' : '提交' }}
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view class="mp-btn mp-btn--primary" @click="showFollow = true">写跟进</view>
</view>
</view>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listOpportunity } from '@/api/crm'
import { labelOf, dash, formatMoney } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'all', label: '全部' },
{ key: 'following', label: '跟进中', status: 'following' },
{ key: 'won', label: '赢单', status: 'won' },
{ key: 'lost', label: '输单', status: 'lost' }
]
const activeTab = ref('all')
const keyword = ref('')
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) => {
uni.navigateTo({ url: `${PAGE_PATHS.crmOpportunityDetail}?id=${row.id}` })
}
const stageLabel = (s) => labelOf('opportunityStage', s)
const statusLabel = (s) => labelOf('opportunityStatus', s)
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const tab = tabs.find((t) => t.key === activeTab.value)
const query = {
pageNum: page.value,
pageSize,
keyword: keyword.value.trim() || undefined
}
if (tab?.status) query.status = tab.status
const res = await listOpportunity(query)
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
if (activeTab.value === key) return
activeTab.value = key
loadList(true)
}
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">商机管理</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="搜索商机 / 客户"
confirm-type="search"
@confirm="loadList(true)"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无商机</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.opportunityName) }}</text>
<text class="mp-card__sub">{{ dash(row.customerName) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(row.status).tag">
{{ statusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>阶段</text>
<text class="mp-card__row-val">{{ stageLabel(row.stage).text }}</text>
</view>
<view class="mp-card__row">
<text>金额</text>
<text class="mp-card__row-val">{{ formatMoney(row.amount ?? row.expectedAmount) }}</text>
</view>
<view class="mp-card__row">
<text>负责人</text>
<text class="mp-card__row-val">{{ dash(row.ownerUserName) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+140
View File
@@ -0,0 +1,140 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listPoolCustomer, claimCustomer, getCustomer } from '@/api/crm'
import { labelOf, dash } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const keyword = ref('')
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const claimingId = ref('')
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const levelLabel = (s) => labelOf('level', s)
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const query = {
pageNum: page.value,
pageSize,
customerName: keyword.value.trim() || undefined,
keyword: keyword.value.trim() || undefined
}
const res = await listPoolCustomer(query)
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const onClaim = (row) => {
uni.showModal({
title: '领取客户',
content: `确认领取「${row.customerName || ''}」?`,
success: async (r) => {
if (!r.confirm) return
claimingId.value = row.id
try {
await claimCustomer(row.id)
uni.showToast({ title: '领取成功', icon: 'success' })
loadList(true)
} catch (e) {
uni.showToast({ title: e.message || '领取失败', icon: 'none' })
} finally {
claimingId.value = ''
}
}
})
}
const goDetail = async (row) => {
try {
await getCustomer(row.id)
uni.navigateTo({ url: `${PAGE_PATHS.crmCustomerDetail}?id=${row.id}` })
} catch (e) {
uni.showToast({ title: e.message || '暂无法查看详情', icon: 'none' })
}
}
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">公海池</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="搜索客户名称"
confirm-type="search"
@confirm="loadList(true)"
/>
</view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">公海暂无客户</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card">
<view class="mp-card__head" @click="goDetail(row)">
<view>
<text class="mp-card__title">{{ dash(row.customerName) }}</text>
<text class="mp-card__sub">入池:{{ dash(row.poolTime || row.updateTime) }}</text>
</view>
<text class="mp-tag mp-tag--muted">{{ levelLabel(row.level).text }}</text>
</view>
<view class="mp-card__row">
<text>来源</text>
<text class="mp-card__row-val">{{ dash(row.source) }}</text>
</view>
<view class="mp-card__row">
<text>原负责人</text>
<text class="mp-card__row-val">{{ dash(row.ownerUserName || row.prevOwnerName) }}</text>
</view>
<view class="mp-card__foot">
<view
class="mp-btn mp-btn--primary mp-btn--sm"
@click.stop="onClaim(row)"
>
{{ claimingId === row.id ? '领取中…' : '领取' }}
</view>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getQuote } from '@/api/crm'
import { labelOf, dash, formatMoney } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const statusLabel = (s) => labelOf('quoteStatus', s)
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.crmQuoteList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getQuote(id.value)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">报价详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.quoteTitle || detail.quoteNo) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.customerName) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(detail.status).tag">
{{ statusLabel(detail.status).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ formatMoney(detail.totalAmount ?? detail.amount) }}</text>
<text class="mp-detail-hero__chip">{{ dash(detail.ownerUserName) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">报价单号</text><text class="mp-kv__value">{{ dash(detail?.quoteNo) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">报价日期</text><text class="mp-kv__value">{{ dash(detail?.quoteDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">有效期至</text><text class="mp-kv__value">{{ dash(detail?.validDate || detail?.expireDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">关联商机</text><text class="mp-kv__value">{{ dash(detail?.opportunityName) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">说明</text>
<view class="mp-block__panel" style="padding: 28rpx">
<text style="font-size: 26rpx; color: var(--mp-muted)">
审批、改价、转合同等复杂操作请在 Web 管理端完成;小程序侧重查看与现场跟进。
</text>
</view>
</view>
</view>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup>
import { ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { listQuote } from '@/api/crm'
import { labelOf, dash, formatMoney } from '@/utils/crmLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'all', label: '全部' },
{ key: 'draft', label: '草稿', status: 'draft' },
{ key: 'pending', label: '待审批', status: 'pending' },
{ key: 'approved', label: '已通过', status: 'approved' }
]
const activeTab = ref('all')
const keyword = ref('')
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const finished = ref(false)
const pageSize = 20
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) => {
uni.navigateTo({ url: `${PAGE_PATHS.crmQuoteDetail}?id=${row.id}` })
}
const statusLabel = (s) => labelOf('quoteStatus', s)
const loadList = async (reset = false) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
list.value = []
}
if (finished.value) return
loading.value = true
try {
const tab = tabs.find((t) => t.key === activeTab.value)
const query = {
pageNum: page.value,
pageSize,
keyword: keyword.value.trim() || undefined
}
if (tab?.status) query.status = tab.status
const res = await listQuote(query)
const rows = res.rows || []
list.value = reset ? rows : list.value.concat(rows)
total.value = res.total || 0
if (list.value.length >= total.value || rows.length < pageSize) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
if (activeTab.value === key) return
activeTab.value = key
loadList(true)
}
onShow(() => loadList(true))
onReachBottom(() => loadList(false))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">报价管理</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="搜索报价单号 / 客户"
confirm-type="search"
@confirm="loadList(true)"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无报价</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.quoteTitle || row.quoteNo) }}</text>
<text class="mp-card__sub">{{ dash(row.quoteNo) }} · {{ dash(row.customerName) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + statusLabel(row.status).tag">
{{ statusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>金额</text>
<text class="mp-card__row-val">{{ formatMoney(row.totalAmount ?? row.amount) }}</text>
</view>
<view class="mp-card__row">
<text>负责人</text>
<text class="mp-card__row-val">{{ dash(row.ownerUserName) }}</text>
</view>
<view class="mp-card__row">
<text>报价日期</text>
<text class="mp-card__row-val">{{ dash(row.quoteDate || row.createTime) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
<view v-else-if="finished && list.length" class="mp-list-end">已加载全部</view>
</view>
</view>
</template>
+134
View File
@@ -0,0 +1,134 @@
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import {
getDashboardStats,
listWeighing,
getInboundByCargo,
getOutboundByCargo
} from '@/api/db'
import { weighingPhase, todayYmd, dash, formatWeight } from '@/utils/dbLabels'
import { PAGE_PATHS } from '@/config/api'
const stats = ref({
todayInbound: 0,
todayOutbound: 0,
todayWeighing: 0,
needSecond: 0
})
const topCargo = ref([])
const loading = ref(false)
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goWeighing = () => uni.navigateTo({ url: PAGE_PATHS.dbWeighingList })
const goPlans = () => uni.navigateTo({ url: PAGE_PATHS.dbPlansList })
const goInventory = () => uni.navigateTo({ url: PAGE_PATHS.dbInventoryList })
const load = async () => {
loading.value = true
try {
const [s, weighings, inCargo, outCargo] = await Promise.all([
getDashboardStats(),
listWeighing({ dateFrom: todayYmd(), dateTo: todayYmd() }),
getInboundByCargo(),
getOutboundByCargo()
])
const todayList = weighings || []
const needSecond = todayList.filter((w) => {
const p = weighingPhase(w)
return p.text === '一次过磅'
}).length
stats.value = {
todayInbound: s.todayInbound ?? s.today_inbound ?? 0,
todayOutbound: s.todayOutbound ?? s.today_outbound ?? 0,
todayWeighing: todayList.filter((w) => Number(w.isVoided) !== 1).length,
needSecond
}
const merged = []
.concat(inCargo || [])
.concat(outCargo || [])
.slice(0, 6)
topCargo.value = merged
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--db">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">地磅仪表盘</text>
<text class="mp-header__meta">今日出入库与过磅概况</text>
</view>
<view class="mp-stats mp-stats--4">
<view class="mp-stat">
<text class="mp-stat__num">{{ stats.todayWeighing }}</text>
<text class="mp-stat__label">今日过磅</text>
</view>
<view class="mp-stat">
<text class="mp-stat__num">{{ stats.needSecond }}</text>
<text class="mp-stat__label">待二次过磅</text>
</view>
<view class="mp-stat">
<text class="mp-stat__num">{{ Number(stats.todayInbound).toFixed(0) }}</text>
<text class="mp-stat__label">今日入库</text>
</view>
<view class="mp-stat">
<text class="mp-stat__num">{{ Number(stats.todayOutbound).toFixed(0) }}</text>
<text class="mp-stat__label">今日出库</text>
</view>
</view>
<view class="mp-section">
<view class="mp-list">
<view class="mp-card" @click="goWeighing">
<view class="mp-card__head">
<text class="mp-card__title">地磅单管理</text>
<text class="mp-tag mp-tag--warning">现场</text>
</view>
<text class="mp-card__sub">查看过磅记录,办理二次过磅</text>
</view>
<view class="mp-card" @click="goPlans">
<view class="mp-card__head">
<text class="mp-card__title">地磅计划</text>
<text class="mp-tag mp-tag--info">计划</text>
</view>
<text class="mp-card__sub">查看入/出库计划执行进度</text>
</view>
<view class="mp-card" @click="goInventory">
<view class="mp-card__head">
<text class="mp-card__title">出入库单</text>
<text class="mp-tag mp-tag--success">单据</text>
</view>
<text class="mp-card__sub">查看出入库及关联过磅</text>
</view>
</view>
</view>
<view v-if="topCargo.length" class="mp-block">
<text class="mp-block__title">货品计划 vs 实绩(节选)</text>
<view class="mp-block__panel">
<view v-for="(c, i) in topCargo" :key="i" class="mp-kv">
<text class="mp-kv__label">{{ dash(c.label) }}</text>
<text class="mp-kv__value">
计划 {{ formatWeight(c.planned, '') }} / 实绩 {{ formatWeight(c.actual, '') }}
</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
</view>
</template>
+121
View File
@@ -0,0 +1,121 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getInventory, getInventoryRelated } from '@/api/db'
import { inventoryStatusLabel, dash, formatWeight } from '@/utils/dbLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const related = ref({ plan: null, weighing: null })
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.dbInventoryList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getInventory(id.value)
related.value = await getInventoryRelated(id.value)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const openWeighing = () => {
if (!related.value?.weighing?.id) {
uni.showToast({ title: '暂无关联地磅单', icon: 'none' })
return
}
uni.navigateTo({
url: `${PAGE_PATHS.dbWeighingDetail}?id=${related.value.weighing.id}`
})
}
const openPlan = () => {
if (!related.value?.plan?.id) {
uni.showToast({ title: '暂无关联计划', icon: 'none' })
return
}
uni.navigateTo({
url: `${PAGE_PATHS.dbPlansDetail}?id=${related.value.plan.id}`
})
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--db">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">出入库详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.batchNo || detail.id) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.product) }} · {{ dash(detail.businessType) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + inventoryStatusLabel(detail.status).tag">
{{ inventoryStatusLabel(detail.status).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ dash(detail.vehicleNo) }}</text>
<text class="mp-detail-hero__chip">{{ formatWeight(detail.quantity) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">日期</text><text class="mp-kv__value">{{ dash(detail?.date) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">往来单位</text><text class="mp-kv__value">{{ dash(detail?.partnerCompany) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">规格</text><text class="mp-kv__value">{{ dash(detail?.specification) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">计划号</text><text class="mp-kv__value">{{ dash(detail?.planNo) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remarks) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">过磅数据</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">毛重</text><text class="mp-kv__value">{{ formatWeight(detail?.grossWeight) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">皮重</text><text class="mp-kv__value">{{ formatWeight(detail?.tareWeight) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">净重</text><text class="mp-kv__value">{{ formatWeight(detail?.netWeight) }}</text></view>
</view>
</view>
<view v-if="detail?.siteFeedbackRemark || detail?.siteFeedbackImages?.length" class="mp-block">
<text class="mp-block__title">现场反馈</text>
<view class="mp-block__panel" style="padding: 28rpx">
<text style="font-size: 26rpx">{{ dash(detail.siteFeedbackRemark) }}</text>
</view>
</view>
<view class="mp-section">
<view class="mp-list">
<view class="mp-card" @click="openWeighing">
<view class="mp-card__head">
<text class="mp-card__title">关联地磅单</text>
<text class="mp-tag mp-tag--info">{{ related.weighing ? '查看' : '无' }}</text>
</view>
</view>
<view class="mp-card" @click="openPlan">
<view class="mp-card__head">
<text class="mp-card__title">关联计划</text>
<text class="mp-tag mp-tag--info">{{ related.plan ? '查看' : '无' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
+144
View File
@@ -0,0 +1,144 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listInventory } from '@/api/db'
import {
inventoryStatusLabel,
dash,
formatWeight,
filterByKeyword,
todayYmd
} from '@/utils/dbLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'all', label: '全部' },
{ key: '入库', label: '入库' },
{ key: '出库', label: '出库' },
{ key: 'pending', label: '待处理' }
]
const activeTab = ref('all')
const keyword = ref('')
const rawList = ref([])
const loading = ref(false)
const list = computed(() => {
let rows = rawList.value
if (activeTab.value === '入库' || activeTab.value === '出库') {
rows = rows.filter((r) => r.businessType === activeTab.value)
} else if (activeTab.value === 'pending') {
rows = rows.filter((r) => String(r.status || '').includes('待'))
}
return filterByKeyword(rows, keyword.value, [
'batchNo',
'planNo',
'vehicleNo',
'product',
'partnerCompany',
'status'
]).slice(0, 80)
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.dbInventoryDetail}?id=${row.id}` })
const load = async () => {
loading.value = true
try {
// 默认拉今日,减轻全量压力;切「全部」再无日期
if (activeTab.value === 'all' && !keyword.value) {
const today = todayYmd()
rawList.value = await listInventory({ dateFrom: today, dateTo: today })
if (rawList.value.length < 5) {
rawList.value = await listInventory()
}
} else {
rawList.value = await listInventory()
}
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
activeTab.value = key
load()
}
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--db">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">地磅出入库</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="单号 / 车牌 / 产品 / 往来单位"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无出入库单</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.batchNo || row.id) }}</text>
<text class="mp-card__sub">
{{ dash(row.vehicleNo) }} · {{ dash(row.product) }} · {{ dash(row.businessType) }}
</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + inventoryStatusLabel(row.status).tag">
{{ inventoryStatusLabel(row.status).text }}
</text>
</view>
<view class="mp-card__row">
<text>数量</text>
<text class="mp-card__row-val">{{ formatWeight(row.quantity) }}</text>
</view>
<view class="mp-card__row">
<text>往来单位</text>
<text class="mp-card__row-val">{{ dash(row.partnerCompany) }}</text>
</view>
<view class="mp-card__row">
<text>日期</text>
<text class="mp-card__row-val">{{ dash(row.date) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
</view>
</view>
</template>
+143
View File
@@ -0,0 +1,143 @@
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getPlan, getPlanRelated, createInventoryLinkForPlan } from '@/api/db'
import { planStatusLabel, dash, formatWeight } from '@/utils/dbLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const related = ref({ inventories: [], weighings: [] })
const acting = ref(false)
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.dbPlansList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getPlan(id.value)
related.value = await getPlanRelated(id.value)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const createLink = () => {
uni.showModal({
title: '生成出入库单',
content: '为该计划创建可填写的出入库关联单?',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
const inv = await createInventoryLinkForPlan(id.value)
uni.showToast({ title: '已生成', icon: 'success' })
if (inv?.id) {
uni.navigateTo({ url: `${PAGE_PATHS.dbInventoryDetail}?id=${inv.id}` })
} else {
load()
}
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
const openWeighing = (w) =>
uni.navigateTo({ url: `${PAGE_PATHS.dbWeighingDetail}?id=${w.id}` })
const openInventory = (inv) =>
uni.navigateTo({ url: `${PAGE_PATHS.dbInventoryDetail}?id=${inv.id}` })
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--db">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">计划详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.planNo) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.product) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + planStatusLabel(detail.executionStatus).tag">
{{ planStatusLabel(detail.executionStatus).text }}
</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ dash(detail.businessType) }}</text>
<text class="mp-detail-hero__chip">
{{ formatWeight(detail.executedQuantity) }} / {{ formatWeight(detail.plannedQuantity) }}
</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">计划日期</text><text class="mp-kv__value">{{ dash(detail?.planDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">起止</text><text class="mp-kv__value">{{ dash(detail?.startDate) }} ~ {{ dash(detail?.endDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">规格</text><text class="mp-kv__value">{{ dash(detail?.specification) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">车牌</text><text class="mp-kv__value">{{ dash(detail?.vehicleNo) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">客户</text><text class="mp-kv__value">{{ dash(detail?.customerName) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remarks) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">关联出入库({{ related.inventories?.length || 0 }})</text>
<view v-if="!related.inventories?.length" class="mp-empty" style="padding: 32rpx 0">
<text class="mp-empty__text">暂无出入库单</text>
</view>
<view v-else class="mp-list">
<view
v-for="inv in related.inventories"
:key="inv.id"
class="mp-card"
@click="openInventory(inv)"
>
<text class="mp-card__title">{{ dash(inv.batchNo || inv.id) }}</text>
<text class="mp-card__sub">{{ dash(inv.vehicleNo) }} · {{ dash(inv.status) }}</text>
</view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">关联地磅单({{ related.weighings?.length || 0 }})</text>
<view v-if="!related.weighings?.length" class="mp-empty" style="padding: 32rpx 0">
<text class="mp-empty__text">暂无地磅单</text>
</view>
<view v-else class="mp-list">
<view
v-for="w in related.weighings"
:key="w.id"
class="mp-card"
@click="openWeighing(w)"
>
<text class="mp-card__title">{{ dash(w.vehicleNo) }}</text>
<text class="mp-card__sub">净重 {{ formatWeight(w.netWeight) }}</text>
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view class="mp-btn mp-btn--primary" @click="createLink">
{{ acting ? '…' : '生成出入库关联单' }}
</view>
</view>
</view>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listPlans } from '@/api/db'
import { planStatusLabel, dash, formatWeight, filterByKeyword } from '@/utils/dbLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'all', label: '全部' },
{ key: '入库', label: '入库' },
{ key: '出库', label: '出库' }
]
const activeTab = ref('all')
const keyword = ref('')
const rawList = ref([])
const loading = ref(false)
const list = computed(() => {
let rows = rawList.value
if (activeTab.value !== 'all') {
rows = rows.filter((r) => r.businessType === activeTab.value)
}
return filterByKeyword(rows, keyword.value, [
'planNo',
'product',
'customerName',
'vehicleNo',
'executionStatus'
]).slice(0, 80)
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.dbPlansDetail}?id=${row.id}` })
const load = async () => {
loading.value = true
try {
rawList.value = await listPlans()
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
activeTab.value = key
}
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--db">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">地磅计划</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="计划号 / 产品 / 客户 / 车牌"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无计划</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.planNo) }}</text>
<text class="mp-card__sub">{{ dash(row.product) }} · {{ dash(row.businessType) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + planStatusLabel(row.executionStatus).tag">
{{ planStatusLabel(row.executionStatus).text }}
</text>
</view>
<view class="mp-card__row">
<text>计划量</text>
<text class="mp-card__row-val">{{ formatWeight(row.plannedQuantity) }}</text>
</view>
<view class="mp-card__row">
<text>已执行</text>
<text class="mp-card__row-val">{{ formatWeight(row.executedQuantity) }}</text>
</view>
<view class="mp-card__row">
<text>客户</text>
<text class="mp-card__row-val">{{ dash(row.customerName) }}</text>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
</view>
</view>
</template>
+157
View File
@@ -0,0 +1,157 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getWeighing, updateWeighing, voidWeighing } from '@/api/db'
import { weighingPhase, dash, formatWeight, nowYmdHms, todayYmd } from '@/utils/dbLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const showSecond = ref(false)
const tareWeight = ref('')
const acting = ref(false)
const phase = computed(() => weighingPhase(detail.value))
const canSecond = computed(() => phase.value.text === '一次过磅')
const canVoid = computed(() => {
if (!detail.value || Number(detail.value.isVoided) === 1) return false
const t = String(detail.value.grossWeightTime || detail.value.createdAt || '')
return t.startsWith(todayYmd())
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.dbWeighingList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getWeighing(id.value)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const openSecond = () => {
tareWeight.value = ''
showSecond.value = true
}
const submitSecond = async () => {
const tare = Number(tareWeight.value)
if (!tare || tare <= 0) {
uni.showToast({ title: '请填写皮重', icon: 'none' })
return
}
const gross = Number(detail.value?.grossWeight) || 0
const deduction = Number(detail.value?.deduction) || 0
const net = Math.max(gross - tare - deduction, 0)
acting.value = true
try {
await updateWeighing(id.value, {
tareWeight: tare,
tareWeightTime: nowYmdHms(),
netWeight: net
})
uni.showToast({ title: '二次过磅已保存', icon: 'success' })
showSecond.value = false
load()
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
acting.value = false
}
}
const onVoid = () => {
uni.showModal({
title: '作废地磅单',
content: '仅支持作废当天单据,确认作废?',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
await voidWeighing(id.value)
uni.showToast({ title: '已作废', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '作废失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--db">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">地磅单详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.vehicleNo) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.cargoName) }} · {{ dash(detail.businessType) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + phase.tag">{{ phase.text }}</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">净重 {{ formatWeight(detail.netWeight) }}</text>
<text class="mp-detail-hero__chip">{{ dash(detail.batchNo) }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">过磅数据</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">毛重</text><text class="mp-kv__value">{{ formatWeight(detail?.grossWeight) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">毛重时间</text><text class="mp-kv__value">{{ dash(detail?.grossWeightTime) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">皮重</text><text class="mp-kv__value">{{ formatWeight(detail?.tareWeight) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">皮重时间</text><text class="mp-kv__value">{{ dash(detail?.tareWeightTime) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">扣重</text><text class="mp-kv__value">{{ formatWeight(detail?.deduction) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">净重</text><text class="mp-kv__value">{{ formatWeight(detail?.netWeight) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">业务信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">计划号</text><text class="mp-kv__value">{{ dash(detail?.planNo) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">规格</text><text class="mp-kv__value">{{ dash(detail?.specification) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">供货单位</text><text class="mp-kv__value">{{ dash(detail?.supplier) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">收货单位</text><text class="mp-kv__value">{{ dash(detail?.consignee) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">过磅点</text><text class="mp-kv__value">{{ dash(detail?.weighingPoint) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remarks) }}</text></view>
</view>
</view>
<view v-if="showSecond" class="mp-form-panel">
<view class="mp-form-row">
<text class="mp-form-label mp-form-label--req">皮重(kg)</text>
<input v-model="tareWeight" class="mp-form-input" type="digit" placeholder="二次过磅皮重" />
</view>
<view class="mp-form-row" style="display: flex; gap: 16rpx">
<view class="mp-btn mp-btn--ghost" style="flex: 1" @click="showSecond = false">取消</view>
<view class="mp-btn mp-btn--primary" style="flex: 1" @click="submitSecond">
{{ acting ? '…' : '确认保存' }}
</view>
</view>
</view>
<view class="mp-bottom-bar">
<view v-if="canSecond" class="mp-btn mp-btn--primary" @click="openSecond">二次过磅</view>
<view v-if="canVoid" class="mp-btn mp-btn--danger" @click="onVoid">作废</view>
</view>
</view>
</template>
+171
View File
@@ -0,0 +1,171 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listWeighing } from '@/api/db'
import {
weighingPhase,
dash,
formatWeight,
filterByKeyword,
todayYmd
} from '@/utils/dbLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'today', label: '今日' },
{ key: 'second', label: '待二次' },
{ key: 'all', label: '全部' }
]
const activeTab = ref('today')
const keyword = ref('')
const rawList = ref([])
const loading = ref(false)
const stats = computed(() => {
const today = todayYmd()
const todayRows = rawList.value.filter((w) => {
const t = String(w.grossWeightTime || w.createdAt || '')
return t.startsWith(today) && Number(w.isVoided) !== 1
})
const needSecond = todayRows.filter((w) => weighingPhase(w).text === '一次过磅').length
return {
today: todayRows.length,
needSecond
}
})
const list = computed(() => {
let rows = rawList.value
const today = todayYmd()
if (activeTab.value === 'today') {
rows = rows.filter((w) => String(w.grossWeightTime || w.createdAt || '').startsWith(today))
} else if (activeTab.value === 'second') {
rows = rows.filter((w) => weighingPhase(w).text === '一次过磅')
}
return filterByKeyword(rows, keyword.value, [
'vehicleNo',
'cargoName',
'planNo',
'batchNo',
'supplier',
'consignee'
]).slice(0, 80)
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.dbWeighingDetail}?id=${row.id}` })
const load = async () => {
loading.value = true
try {
// 今日优先带日期参数;全部再拉全量(数据量大时可能慢)
if (activeTab.value === 'all') {
rawList.value = await listWeighing()
} else {
const today = todayYmd()
rawList.value = await listWeighing({ dateFrom: today, dateTo: today })
// 待二次若今日无,再尝试不带日期限制的一次过磅(限制体验:仅今日+已加载)
if (activeTab.value === 'second' && !rawList.value.some((w) => weighingPhase(w).text === '一次过磅')) {
const all = await listWeighing()
rawList.value = all
}
}
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
activeTab.value = key
load()
}
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--db">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">地磅单管理</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="车牌 / 货物 / 计划号"
/>
</view>
</view>
<view class="mp-stats">
<view class="mp-stat">
<text class="mp-stat__num">{{ stats.today }}</text>
<text class="mp-stat__label">今日过磅</text>
</view>
<view class="mp-stat">
<text class="mp-stat__num">{{ stats.needSecond }}</text>
<text class="mp-stat__label">待二次过磅</text>
</view>
<view class="mp-stat">
<text class="mp-stat__num">{{ list.length }}</text>
<text class="mp-stat__label">当前列表</text>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0; padding-top: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无地磅单</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.batchNo || row.id) }}</text>
<text class="mp-card__sub">{{ dash(row.vehicleNo) }} · {{ dash(row.cargoName) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + weighingPhase(row).tag">
{{ weighingPhase(row).text }}
</text>
</view>
<view class="mp-card__row">
<text>净重</text>
<text class="mp-card__row-val">{{ formatWeight(row.netWeight) }}</text>
</view>
<view class="mp-card__row">
<text>过磅时间</text>
<text class="mp-card__row-val">{{ dash(row.grossWeightTime) }}</text>
</view>
<view v-if="weighingPhase(row).text === '一次过磅'" class="mp-card__foot">
<view class="mp-btn mp-btn--primary mp-btn--sm">二次过磅</view>
</view>
</view>
</view>
<view v-if="loading" class="mp-list-loading">加载中…</view>
</view>
</view>
</template>
+15 -103
View File
@@ -1,123 +1,35 @@
<script setup>
import { computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import AppSafeHeader from '@/components/app-safe-header.vue'
import { PAGE_PATHS } from '@/config/api'
const userStore = useUserStore()
const pageStyle = computed(() => ({ '--theme-color': '#0a9f6d' }))
const displayName = computed(() => {
const info = userStore.userInfo
if (!info) return '未登录'
return info.realName || info.username || '用户'
})
onShow(() => {
userStore.checkLoginStatus()
if (!userStore.isLoggedIn) {
uni.reLaunch({ url: '/pages/login/login' })
uni.reLaunch({ url: PAGE_PATHS.login })
}
})
const handleLogout = async () => {
await userStore.logout()
const goWorkbench = () => {
uni.switchTab({ url: PAGE_PATHS.workbench })
}
</script>
<template>
<view class="home-page" :style="pageStyle">
<AppSafeHeader title="山峡化工" />
<view class="home-body">
<view class="welcome-card">
<text class="welcome-title">小程序基础框架已就绪</text>
<text class="welcome-desc">当前为占位首页,后续在此目录下建设业务页面。</text>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-label">当前用户</text>
<text class="info-value">{{ displayName }}</text>
</view>
<view class="info-row">
<text class="info-label">角色</text>
<text class="info-value">{{ userStore.userRole || '-' }}</text>
</view>
</view>
<button class="logout-btn" :loading="userStore.isLoading" @click="handleLogout">
退出登录
<view class="mp-page mp-page--tab">
<view class="mp-header">
<text class="mp-header__title">首页</text>
<text class="mp-header__meta">概览与快捷入口(本期占位)</text>
</view>
<view class="mp-placeholder">
<view class="mp-placeholder__icon" />
<text class="mp-placeholder__title">首页功能建设中</text>
<text class="mp-placeholder__desc">请从底部「工作台」进入 CRM、报销、地磅、运输业务办理。</text>
<button class="mp-btn mp-btn--primary" style="margin-top: 40rpx; padding: 0 48rpx" @click="goWorkbench">
前往工作台
</button>
</view>
</view>
</template>
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
background: #f3f7f5;
}
.home-body {
padding: 24rpx 32rpx 48rpx;
}
.welcome-card,
.info-card {
background: #fff;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
}
.welcome-title {
display: block;
font-size: 34rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 12rpx;
}
.welcome-desc {
display: block;
font-size: 26rpx;
line-height: 1.6;
color: #64748b;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
}
.info-row + .info-row {
border-top: 1px solid #eef2f7;
}
.info-label {
font-size: 28rpx;
color: #64748b;
}
.info-value {
font-size: 28rpx;
color: #1e293b;
font-weight: 500;
}
.logout-btn {
margin-top: 16rpx;
height: 88rpx;
line-height: 88rpx;
border-radius: 16rpx;
background: #fff;
color: #dc2626;
font-size: 30rpx;
border: 1px solid #fecaca;
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<script setup>
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { PAGE_PATHS } from '@/config/api'
import { resolveUserAvatarUrl } from '@/utils/avatar'
const userStore = useUserStore()
const loggingOut = ref(false)
const displayName = computed(() => {
const info = userStore.userInfo
if (!info) return '未登录'
return info.realName || info.username || '用户'
})
const username = computed(() => userStore.userInfo?.username || '-')
const phone = computed(() => userStore.userInfo?.phone || userStore.userInfo?.mobile || '-')
const email = computed(() => userStore.userInfo?.email || '-')
const deptName = computed(
() => userStore.userInfo?.deptName || userStore.userInfo?.dept_name || '-'
)
const roleLabel = computed(() => userStore.userRole || '-')
const avatarUrl = computed(() =>
resolveUserAvatarUrl(userStore.userInfo?.avatarUrl, userStore.userRole)
)
onShow(() => {
userStore.checkLoginStatus()
if (!userStore.isLoggedIn) {
uni.reLaunch({ url: PAGE_PATHS.login })
return
}
userStore.refreshUserInfo()
})
const handleSaveHint = () => {
uni.showToast({ title: '个人信息修改将在后续开放', icon: 'none' })
}
const handleLogout = async () => {
if (loggingOut.value) return
loggingOut.value = true
try {
await userStore.logout()
} finally {
loggingOut.value = false
}
}
</script>
<template>
<view class="mp-page mp-page--tab">
<view class="mp-profile-hero">
<view class="mp-profile-hero__user">
<view class="mp-profile-hero__avatar">
<image v-if="avatarUrl" :src="avatarUrl" mode="aspectFill" />
</view>
<view>
<text class="mp-profile-hero__name">{{ displayName }}</text>
<text class="mp-profile-hero__dept">山峡化工 · {{ deptName }}</text>
</view>
</view>
</view>
<view class="mp-profile-panel">
<view class="mp-form-item">
<text class="mp-form-item__label">姓名</text>
<text class="mp-form-item__value">{{ displayName }}</text>
</view>
<view class="mp-form-item">
<text class="mp-form-item__label">账号</text>
<text class="mp-form-item__value">{{ username }}</text>
</view>
<view class="mp-form-item">
<text class="mp-form-item__label">手机号</text>
<text class="mp-form-item__value">{{ phone }}</text>
</view>
<view class="mp-form-item">
<text class="mp-form-item__label">邮箱</text>
<text class="mp-form-item__value">{{ email }}</text>
</view>
<view class="mp-form-item">
<text class="mp-form-item__label">所属部门</text>
<text class="mp-form-item__value">{{ deptName }}</text>
</view>
<view class="mp-form-item">
<text class="mp-form-item__label">角色</text>
<text class="mp-form-item__value">{{ roleLabel }}</text>
</view>
</view>
<view class="mp-profile-actions">
<button class="mp-btn mp-btn--primary mp-btn--block" @click="handleSaveHint">保存修改</button>
<button
class="mp-btn mp-btn--ghost mp-btn--block"
:loading="loggingOut || userStore.isLoading"
@click="handleLogout"
>
退出登录
</button>
</view>
<text class="mp-hint">本期支持查看个人信息与退出登录;资料编辑后续开放</text>
</view>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listPlans } from '@/api/pl'
import { archiveStatusLabel, planCardSub, dash, planTypeLabel } from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
const keyword = ref('')
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const list = computed(() => {
const kw = String(keyword.value || '')
.trim()
.toLowerCase()
if (!kw) return rows.value
return rows.value.filter((row) => {
const blob = [row.planNo, row.customerName, planCardSub(row)].join(' ').toLowerCase()
return blob.includes(kw)
})
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.plPlanDetail}?id=${row.id}&readonly=1` })
const load = async (reset = true) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
rows.value = []
}
if (finished.value) return
loading.value = true
try {
const res = await listPlans({
archiveStatus: 'archived',
q: keyword.value.trim() || undefined,
page: page.value,
pageSize: 20
})
const next = res.rows || []
rows.value = reset ? next : rows.value.concat(next)
total.value = res.total || 0
if (!next.length || rows.value.length >= total.value) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onShow(() => load(true))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">计划归档</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="计划号 / 客户"
confirm-type="search"
@confirm="load(true)"
/>
</view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无已归档计划</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.planNo) }}</text>
<text class="mp-card__sub">{{ planCardSub(row) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + archiveStatusLabel(row.archiveStatus).tag">
{{ archiveStatusLabel(row.archiveStatus).text }}
</text>
</view>
<view class="mp-card__row">
<text>{{ planTypeLabel(row.planType) }}</text>
<text>{{ dash(row.planDate) }}</text>
</view>
</view>
</view>
<view v-if="!finished && list.length" class="mp-load-more" @click="load(false)">
{{ loading ? '加载中…' : '加载更多' }}
</view>
</view>
</view>
</template>
+165
View File
@@ -0,0 +1,165 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getPlan, updateProductCerts, setHazardCertReady } from '@/api/pl'
import { chooseAndUploadImages } from '@/utils/upload'
import { dash, planCardSub, hasPurchaseCert, certReadyLabel } from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const acting = ref(false)
const products = computed(() =>
Array.isArray(detail.value?.products) ? detail.value.products : []
)
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.plCertList })
})
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getPlan(id.value)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const certUrls = (p) => {
const raw = p.purchaseCertUrl || p.purchase_cert_url || ''
return String(raw)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
const preview = (urls, index) => {
uni.previewImage({ urls, current: urls[index] || urls[0] })
}
const uploadForProduct = async (product) => {
const current = certUrls(product)
acting.value = true
try {
const added = await chooseAndUploadImages({
folder: 'pl-purchase-cert',
maxCount: 6,
currentUrls: current
})
if (!added.length) return
const nextUrls = current.concat(added)
await updateProductCerts(id.value, [
{
id: product.id,
purchaseCertUrl: nextUrls.join(',')
}
])
uni.showToast({ title: '已上传', icon: 'success' })
await load()
} catch (e) {
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
} finally {
acting.value = false
}
}
const markReady = (ready) => {
uni.showModal({
title: ready ? '标记证书齐备' : '取消齐备',
content: ready ? '确认该计划危化品证书已齐备?' : '确认取消证书齐备标记?',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
await setHazardCertReady(id.value, ready)
uni.showToast({ title: '已更新', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">购买证上传</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.planNo) }}</text>
<text class="mp-detail-hero__sub">{{ planCardSub(detail) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + certReadyLabel(detail.hazardCertReady).tag">
{{ certReadyLabel(detail.hazardCertReady).text }}
</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">产品购买证</text>
<view v-if="!products.length" class="mp-empty" style="padding: 32rpx 0">
<text class="mp-empty__text">暂无产品明细</text>
</view>
<view v-for="p in products" :key="p.id" class="mp-block__panel" style="margin-bottom: 24rpx">
<view class="mp-kv">
<text class="mp-kv__label">{{ dash(p.productName) }}</text>
<text class="mp-kv__value">{{ hasPurchaseCert(p) ? '已上传' : '未上传' }}</text>
</view>
<view class="mp-photo-row" style="margin-top: 16rpx">
<view
v-for="(url, idx) in certUrls(p)"
:key="url + idx"
class="mp-photo-item"
@click="preview(certUrls(p), idx)"
>
<image :src="url" mode="aspectFill" />
</view>
<view
class="mp-photo-add"
:class="{ 'mp-photo-add--disabled': acting }"
@click="uploadForProduct(p)"
>
<text>+</text>
<text style="font-size: 22rpx">拍照</text>
</view>
</view>
</view>
</view>
<view class="mp-bottom-bar">
<button
v-if="!detail?.hazardCertReady"
class="mp-btn mp-btn--primary mp-btn--block"
:disabled="acting"
@click="markReady(true)"
>
标记证书齐备
</button>
<button
v-else
class="mp-btn mp-btn--ghost mp-btn--block"
:disabled="acting"
@click="markReady(false)"
>
取消齐备
</button>
</view>
</view>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listPlans } from '@/api/pl'
import {
dash,
planCardSub,
certReadyLabel,
hasPurchaseCert
} from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
const keyword = ref('')
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const tab = ref('pending') // pending | ready
const list = computed(() => {
let data = rows.value
if (tab.value === 'pending') {
data = data.filter((r) => !r.hazardCertReady)
} else if (tab.value === 'ready') {
data = data.filter((r) => !!r.hazardCertReady)
}
const kw = String(keyword.value || '')
.trim()
.toLowerCase()
if (!kw) return data
return data.filter((row) => {
const blob = [row.planNo, row.customerName, planCardSub(row)].join(' ').toLowerCase()
return blob.includes(kw)
})
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.plCertDetail}?id=${row.id}` })
const certHint = (row) => {
const products = Array.isArray(row.products) ? row.products : []
if (!products.length) return '无产品'
const ok = products.filter(hasPurchaseCert).length
return `购买证 ${ok}/${products.length}`
}
const load = async (reset = true) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
rows.value = []
}
if (finished.value) return
loading.value = true
try {
const res = await listPlans({
followStatus: 'processing',
q: keyword.value.trim() || undefined,
page: page.value,
pageSize: 20
})
const next = res.rows || []
rows.value = reset ? next : rows.value.concat(next)
total.value = res.total || 0
if (!next.length || rows.value.length >= total.value) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
tab.value = key
}
onShow(() => load(true))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">购买证上传</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="计划号 / 客户"
confirm-type="search"
@confirm="load(true)"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0; padding-top: 0">
<scroll-view scroll-x class="mp-tabs">
<view class="mp-tab" :class="{ 'mp-tab--active': tab === 'pending' }" @click="switchTab('pending')">
待齐备
</view>
<view class="mp-tab" :class="{ 'mp-tab--active': tab === 'ready' }" @click="switchTab('ready')">
已齐备
</view>
<view class="mp-tab" :class="{ 'mp-tab--active': tab === 'all' }" @click="switchTab('all')">
全部
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无计划</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.planNo) }}</text>
<text class="mp-card__sub">{{ planCardSub(row) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + certReadyLabel(row.hazardCertReady).tag">
{{ certReadyLabel(row.hazardCertReady).text }}
</text>
</view>
<view class="mp-card__row">
<text>{{ certHint(row) }}</text>
<text>{{ dash(row.planDate) }}</text>
</view>
<view class="mp-card__foot">
<button class="mp-btn mp-btn--primary" @click.stop="goDetail(row)">上传证书</button>
</view>
</view>
</view>
<view v-if="!finished && rows.length" class="mp-load-more" @click="load(false)">
{{ loading ? '加载中…' : '加载更多' }}
</view>
</view>
</view>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listFreight } from '@/api/pl'
import { dash, formatMoney, settlementStatusLabel } from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'approval_pending', label: '待我审批' },
{ key: 'approval_done', label: '已审批' }
]
const activeTab = ref('approval_pending')
const keyword = ref('')
const rows = ref([])
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const list = computed(() => {
const kw = String(keyword.value || '')
.trim()
.toLowerCase()
if (!kw) return rows.value
return rows.value.filter((row) => {
const blob = [row.settlementNo, row.licensePlateSummary, row.submitterName]
.join(' ')
.toLowerCase()
return blob.includes(kw)
})
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.plFreightDetail}?id=${row.id}` })
const load = async (reset = true) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
rows.value = []
}
if (finished.value) return
loading.value = true
try {
const res = await listFreight({
tab: activeTab.value,
q: keyword.value.trim() || undefined,
page: page.value,
pageSize: 20
})
const next = res.rows || []
rows.value = reset ? next : rows.value.concat(next)
if (!next.length || rows.value.length >= (res.total || 0)) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
activeTab.value = key
load(true)
}
onShow(() => load(true))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">运费审批</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="结算号 / 车牌"
confirm-type="search"
@confirm="load(true)"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0; padding-top: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无审批单据</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.settlementNo) }}</text>
<text class="mp-card__sub">
{{ dash(row.licensePlateSummary) }} · {{ dash(row.submitterName) }}
</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + settlementStatusLabel(row.settlementStatus).tag">
{{ settlementStatusLabel(row.settlementStatus).text }}
</text>
</view>
<view class="mp-card__row">
<text>运费合计</text>
<text class="mp-card__amount">{{ formatMoney(row.totalFreight) }}</text>
</view>
<view class="mp-card__foot">
<button class="mp-btn mp-btn--primary" @click.stop="goDetail(row)">
{{ row.canApprove ? '去审批' : '详情' }}
</button>
</view>
</view>
</view>
<view v-if="!finished && rows.length" class="mp-load-more" @click="load(false)">
{{ loading ? '加载中…' : '加载更多' }}
</view>
</view>
</view>
</template>
+301
View File
@@ -0,0 +1,301 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import {
getFreight,
getFreightApprovalFlow,
processFreightApproval,
submitFreightApproval
} from '@/api/pl'
import { fetchUserOptions } from '@/api/admin'
import {
dash,
formatMoney,
settlementStatusLabel,
isEditableDraftSettlement
} from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
import MpApproverPicker from '@/components/mp-approver-picker/mp-approver-picker.vue'
const id = ref('')
const detail = ref(null)
const flow = ref([])
const acting = ref(false)
const pickerVisible = ref(false)
const approverIds = ref([])
const userMap = ref({})
const approvalType = ref('or')
const remark = ref('')
const approvalTypes = [
{ value: 'or', label: '或签(任一人)' },
{ value: 'sequential', label: '顺序审批' },
{ value: 'parallel', label: '会签(全员)' }
]
const status = computed(() => settlementStatusLabel(detail.value?.settlementStatus))
const canApprove = computed(() => !!detail.value?.canApprove && detail.value?.currentApprovalId)
const canSubmit = computed(() => isEditableDraftSettlement(detail.value?.settlementStatus))
const plans = computed(() =>
Array.isArray(detail.value?.plans) ? detail.value.plans : []
)
const transports = computed(() =>
Array.isArray(detail.value?.transports) ? detail.value.transports : []
)
const approvalTypeIndex = computed(() =>
Math.max(
0,
approvalTypes.findIndex((t) => t.value === approvalType.value)
)
)
const approverSummary = computed(() => {
if (!approverIds.value.length) return '点击选择审批人'
return approverIds.value
.map((uid) => {
const u = userMap.value[uid]
return u ? u.nickName || u.userNo || uid : uid
})
.join('、')
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: PAGE_PATHS.plFreightList })
})
}
const preloadUsers = async () => {
try {
const list = await fetchUserOptions()
const map = {}
list.forEach((u) => {
map[u.userId] = u
})
userMap.value = map
} catch {
/* 选人弹层内会再拉 */
}
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getFreight(id.value)
try {
flow.value = await getFreightApprovalFlow(id.value)
} catch {
flow.value = detail.value?.approvalFlow || detail.value?.approvalHistory || []
}
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const openPicker = () => {
pickerVisible.value = true
}
const onApproverConfirm = (ids) => {
approverIds.value = ids
// 同步 map(弹层内可能已加载)
preloadUsers()
}
const onApprovalTypeChange = (e) => {
const idx = Number(e.detail.value)
approvalType.value = approvalTypes[idx]?.value || 'or'
}
const onSubmitApproval = () => {
if (!approverIds.value.length) {
uni.showToast({ title: '请选择审批人', icon: 'none' })
openPicker()
return
}
const names = approverSummary.value
uni.showModal({
title: '提交审批',
content: `审批类型:${approvalTypes[approvalTypeIndex.value].label}\n审批人:${names}`,
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
await submitFreightApproval(id.value, {
approvalType: approvalType.value,
approverIds: approverIds.value.map(String),
remark: remark.value.trim() || undefined
})
uni.showToast({ title: '已提交', icon: 'success' })
approverIds.value = []
remark.value = ''
load()
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
const doApprove = (pass) => {
const approvalId = detail.value?.currentApprovalId
if (!approvalId) {
uni.showToast({ title: '无待审节点', icon: 'none' })
return
}
uni.showModal({
title: pass ? '审批通过' : '审批驳回',
editable: true,
placeholderText: '审批意见(可选)',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
await processFreightApproval(approvalId, {
action: pass ? 'approve' : 'reject',
comment: r.content || undefined
})
uni.showToast({ title: pass ? '已通过' : '已驳回', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
onLoad((q) => {
id.value = q?.id || ''
})
onShow(() => {
load()
preloadUsers()
})
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">运费结算详情</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.settlementNo) }}</text>
<text class="mp-detail-hero__sub">{{ dash(detail.licensePlateSummary) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + status.tag">{{ status.text }}</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ formatMoney(detail.totalFreight) }}</text>
<text v-if="detail.currentApproverName" class="mp-detail-hero__chip">
审批人 {{ detail.currentApproverName }}
</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">结算信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">发票号</text><text class="mp-kv__value">{{ dash(detail?.invoiceNo) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">发票金额</text><text class="mp-kv__value">{{ formatMoney(detail?.invoiceAmount) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">提交人</text><text class="mp-kv__value">{{ dash(detail?.submitterName) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view v-if="plans.length" class="mp-block">
<text class="mp-block__title">关联计划</text>
<view class="mp-block__panel">
<view v-for="p in plans" :key="p.id || p.planId" class="mp-kv">
<text class="mp-kv__label">{{ dash(p.planNo) }}</text>
<text class="mp-kv__value">{{ formatMoney(p.freightAmount) }}</text>
</view>
</view>
</view>
<view v-if="transports.length" class="mp-block">
<text class="mp-block__title">关联运输</text>
<view class="mp-block__panel">
<view v-for="t in transports" :key="t.id" class="mp-kv">
<text class="mp-kv__label">{{ dash(t.licensePlate || t.transportNo) }}</text>
<text class="mp-kv__value">{{ formatMoney(t.freightAmount) }}</text>
</view>
</view>
</view>
<view v-if="flow.length" class="mp-block">
<text class="mp-block__title">审批流</text>
<view class="mp-block__panel">
<view v-for="(n, i) in flow" :key="n.id || i" class="mp-kv">
<text class="mp-kv__label">{{ dash(n.stepName || n.approverName || `步骤${n.step}`) }}</text>
<text class="mp-kv__value">{{ dash(n.approvalResult) }}</text>
</view>
</view>
</view>
<view v-if="canSubmit" class="mp-block">
<text class="mp-block__title">提交审批</text>
<view class="mp-form">
<view class="mp-form-item">
<text class="mp-form-label">审批类型</text>
<picker
:range="approvalTypes.map((t) => t.label)"
:value="approvalTypeIndex"
@change="onApprovalTypeChange"
>
<view class="mp-form-picker">{{ approvalTypes[approvalTypeIndex].label }}</view>
</picker>
</view>
<view class="mp-form-item" @click="openPicker">
<text class="mp-form-label">审批人</text>
<view class="mp-form-picker mp-form-picker--link">
{{ approverSummary }}
</view>
</view>
<view class="mp-form-item">
<text class="mp-form-label">提交说明</text>
<textarea
v-model="remark"
class="mp-form-textarea"
placeholder="可选"
maxlength="500"
/>
</view>
</view>
</view>
<view v-if="canSubmit || canApprove" class="mp-bottom-bar">
<button
v-if="canSubmit"
class="mp-btn mp-btn--primary mp-btn--block"
:disabled="acting"
@click="onSubmitApproval"
>
提交审批
</button>
<view v-if="canApprove" style="display: flex; gap: 16rpx; width: 100%">
<button class="mp-btn mp-btn--ghost" style="flex: 1" :disabled="acting" @click="doApprove(false)">
驳回
</button>
<button class="mp-btn mp-btn--primary" style="flex: 1" :disabled="acting" @click="doApprove(true)">
通过
</button>
</view>
</view>
<MpApproverPicker
v-model="approverIds"
v-model:visible="pickerVisible"
@confirm="onApproverConfirm"
/>
</view>
</template>
+232
View File
@@ -0,0 +1,232 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listFreight, listUnsettledPlans, createFreight } from '@/api/pl'
import {
dash,
formatMoney,
settlementStatusLabel,
isEditableDraftSettlement
} from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'bills', label: '结算单' },
{ key: 'unsettled', label: '待结算' }
]
const activeTab = ref('bills')
const keyword = ref('')
const rows = ref([])
const unsettled = ref([])
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const acting = ref(false)
const list = computed(() => {
const kw = String(keyword.value || '')
.trim()
.toLowerCase()
const data = activeTab.value === 'unsettled' ? unsettled.value : rows.value
if (!kw) return data
return data.filter((row) => {
const blob = [
row.settlementNo,
row.planNo,
row.customerName,
row.licensePlateSummary,
row.carrierName
]
.join(' ')
.toLowerCase()
return blob.includes(kw)
})
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.plFreightDetail}?id=${row.id}` })
const loadBills = async (reset = true) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
rows.value = []
}
if (finished.value) return
loading.value = true
try {
const res = await listFreight({
tab: 'bills',
q: keyword.value.trim() || undefined,
page: page.value,
pageSize: 20
})
const next = res.rows || []
rows.value = reset ? next : rows.value.concat(next)
if (!next.length || rows.value.length >= (res.total || 0)) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const loadUnsettled = async () => {
loading.value = true
try {
const res = await listUnsettledPlans({
q: keyword.value.trim() || undefined,
page: 1,
pageSize: 50
})
unsettled.value = res.rows || []
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const load = (reset = true) => {
if (activeTab.value === 'unsettled') return loadUnsettled()
return loadBills(reset)
}
const switchTab = (key) => {
activeTab.value = key
load(true)
}
const createFromPlan = (row) => {
const planId = row.planId || row.id
if (!planId) {
uni.showToast({ title: '计划无效', icon: 'none' })
return
}
uni.showModal({
title: '生成结算单',
content: `确认根据计划 ${dash(row.planNo)} 创建运费结算?`,
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
const created = await createFreight({ planIds: [String(planId)] })
uni.showToast({ title: '已创建', icon: 'success' })
if (created?.id) {
setTimeout(() => {
uni.navigateTo({ url: `${PAGE_PATHS.plFreightDetail}?id=${created.id}` })
}, 400)
} else {
activeTab.value = 'bills'
loadBills(true)
}
} catch (e) {
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
onShow(() => load(true))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">运费结算</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="结算号 / 计划 / 车牌"
confirm-type="search"
@confirm="load(true)"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0; padding-top: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">{{ activeTab === 'unsettled' ? '暂无待结算计划' : '暂无结算单' }}</text>
</view>
<view v-else class="mp-list">
<!-- 结算单 -->
<template v-if="activeTab === 'bills'">
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.settlementNo) }}</text>
<text class="mp-card__sub">{{ dash(row.licensePlateSummary) }} · {{ dash(row.planCount) }} 单</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + settlementStatusLabel(row.settlementStatus).tag">
{{ settlementStatusLabel(row.settlementStatus).text }}
</text>
</view>
<view class="mp-card__row">
<text>运费合计</text>
<text class="mp-card__amount">{{ formatMoney(row.totalFreight) }}</text>
</view>
<view v-if="isEditableDraftSettlement(row.settlementStatus)" class="mp-card__foot">
<button class="mp-btn mp-btn--ghost" @click.stop="goDetail(row)">详情</button>
</view>
</view>
</template>
<!-- 待结算计划 -->
<template v-else>
<view v-for="row in list" :key="row.planId || row.id" class="mp-card">
<view class="mp-card__head">
<view>
<text class="mp-card__title">{{ dash(row.planNo) }}</text>
<text class="mp-card__sub">{{ dash(row.customerName || row.carrierName) }}</text>
</view>
</view>
<view class="mp-card__row">
<text>运费</text>
<text class="mp-card__amount">{{ formatMoney(row.freightAmount || row.totalFreight) }}</text>
</view>
<view class="mp-card__foot">
<button class="mp-btn mp-btn--primary" :disabled="acting" @click="createFromPlan(row)">
生成结算单
</button>
</view>
</view>
</template>
</view>
<view
v-if="activeTab === 'bills' && !finished && rows.length"
class="mp-load-more"
@click="loadBills(false)"
>
{{ loading ? '加载中…' : '加载更多' }}
</view>
</view>
</view>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getPlan, arrangeTransport, listVehicles, listPersonnel } from '@/api/pl'
import { dash, planCardSub } from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const detail = ref(null)
const vehicles = ref([])
const drivers = ref([])
const vehicleIndex = ref(-1)
const driverIndex = ref(-1)
const freightTypeIndex = ref(0)
const freightPrice = ref('')
const freightAmount = ref('')
const distanceKm = ref('')
const acting = ref(false)
const freightTypes = [
{ value: 'by_weight', label: '按重量' },
{ value: 'by_trip', label: '按趟次' }
]
const vehicleLabels = computed(() =>
vehicles.value.map((v) => v.licensePlate || v.plateNo || v.id)
)
const driverLabels = computed(() =>
drivers.value.map((d) => `${d.personnelName || d.name || ''}${d.phoneNo ? ` ${d.phoneNo}` : ''}`)
)
const freightTypeLabels = computed(() => freightTypes.map((t) => t.label))
const goBack = () => {
uni.navigateBack({
fail: () => uni.navigateTo({ url: `${PAGE_PATHS.plPlanDetail}?id=${id.value}` })
})
}
const loadOptions = async () => {
try {
const [vRes, pRes] = await Promise.all([
listVehicles({ page: 1, pageSize: 100, vehicleStatus: 'active' }),
listPersonnel({ page: 1, pageSize: 100, personnelType: 'driver' })
])
vehicles.value = vRes.rows || []
drivers.value = pRes.rows || []
// 若无 driver 类型,再拉全量人员
if (!drivers.value.length) {
const all = await listPersonnel({ page: 1, pageSize: 100 })
drivers.value = all.rows || []
}
} catch (e) {
uni.showToast({ title: e.message || '加载车辆/司机失败', icon: 'none' })
}
}
const load = async () => {
if (!id.value) return
try {
detail.value = await getPlan(id.value)
await loadOptions()
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const onVehicleChange = (e) => {
vehicleIndex.value = Number(e.detail.value)
}
const onDriverChange = (e) => {
driverIndex.value = Number(e.detail.value)
}
const onFreightTypeChange = (e) => {
freightTypeIndex.value = Number(e.detail.value)
}
const submit = async () => {
if (vehicleIndex.value < 0 || !vehicles.value[vehicleIndex.value]) {
uni.showToast({ title: '请选择车辆', icon: 'none' })
return
}
if (driverIndex.value < 0 || !drivers.value[driverIndex.value]) {
uni.showToast({ title: '请选择司机', icon: 'none' })
return
}
const vehicle = vehicles.value[vehicleIndex.value]
const driver = drivers.value[driverIndex.value]
const body = {
vehicleId: String(vehicle.id),
driverId: String(driver.id),
carrierId: vehicle.carrierId || undefined,
freightType: freightTypes[freightTypeIndex.value].value,
pickupAddress: detail.value?.pickupAddress || undefined,
destinationAddress: detail.value?.destinationAddress || undefined
}
if (freightPrice.value !== '') body.freightPrice = Number(freightPrice.value)
if (freightAmount.value !== '') body.freightAmount = Number(freightAmount.value)
if (distanceKm.value !== '') body.distanceKm = Number(distanceKm.value)
acting.value = true
try {
await arrangeTransport(id.value, body)
uni.showToast({ title: '已安排运输', icon: 'success' })
setTimeout(() => {
uni.redirectTo({ url: `${PAGE_PATHS.plPlanDetail}?id=${id.value}` })
}, 500)
} catch (e) {
uni.showToast({ title: e.message || '安排失败', icon: 'none' })
} finally {
acting.value = false
}
}
onLoad((q) => {
id.value = q?.id || ''
load()
})
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">安排运输</text>
<text v-if="detail" class="mp-header__meta">{{ dash(detail.planNo) }} · {{ planCardSub(detail) }}</text>
</view>
<view class="mp-block">
<text class="mp-block__title">选择资源</text>
<view class="mp-form">
<view class="mp-form-item">
<text class="mp-form-label">车辆</text>
<picker :range="vehicleLabels" @change="onVehicleChange">
<view class="mp-form-picker">
{{ vehicleIndex >= 0 ? vehicleLabels[vehicleIndex] : '请选择车辆' }}
</view>
</picker>
</view>
<view class="mp-form-item">
<text class="mp-form-label">司机</text>
<picker :range="driverLabels" @change="onDriverChange">
<view class="mp-form-picker">
{{ driverIndex >= 0 ? driverLabels[driverIndex] : '请选择司机' }}
</view>
</picker>
</view>
<view class="mp-form-item">
<text class="mp-form-label">运费方式</text>
<picker :range="freightTypeLabels" :value="freightTypeIndex" @change="onFreightTypeChange">
<view class="mp-form-picker">{{ freightTypeLabels[freightTypeIndex] }}</view>
</picker>
</view>
<view class="mp-form-item">
<text class="mp-form-label">运费单价</text>
<input v-model="freightPrice" class="mp-form-input" type="digit" placeholder="可选" />
</view>
<view class="mp-form-item">
<text class="mp-form-label">运费金额</text>
<input v-model="freightAmount" class="mp-form-input" type="digit" placeholder="可选" />
</view>
<view class="mp-form-item">
<text class="mp-form-label">公里数</text>
<input v-model="distanceKm" class="mp-form-input" type="digit" placeholder="可选" />
</view>
</view>
</view>
<view class="mp-bottom-bar">
<button class="mp-btn mp-btn--primary mp-btn--block" :disabled="acting" @click="submit">
{{ acting ? '提交中…' : '确认安排' }}
</button>
</view>
</view>
</template>
+192
View File
@@ -0,0 +1,192 @@
<script setup>
import { ref, computed } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getPlan, getPlanArrangeInfo, archivePlan } from '@/api/pl'
import {
dash,
planTypeLabel,
followStatusLabel,
transportStatusLabel,
archiveStatusLabel,
planCardSub,
formatQty
} from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
const id = ref('')
const readonly = ref(false)
const detail = ref(null)
const arrange = ref(null)
const acting = ref(false)
const phaseFollow = computed(() => followStatusLabel(detail.value?.followStatus))
const phaseTransport = computed(() => transportStatusLabel(detail.value?.transportStatus))
const phaseArchive = computed(() => archiveStatusLabel(detail.value?.archiveStatus))
const products = computed(() =>
Array.isArray(detail.value?.products) ? detail.value.products : []
)
const canArrange = computed(
() =>
!readonly.value &&
detail.value?.transportStatus === 'pending' &&
detail.value?.followStatus !== 'cancelled' &&
detail.value?.followStatus !== 'completed'
)
const canArchive = computed(
() =>
!readonly.value &&
detail.value?.archiveStatus !== 'archived' &&
detail.value?.transportStatus === 'arranged' &&
detail.value?.followStatus !== 'cancelled'
)
const goBack = () => {
uni.navigateBack({
fail: () =>
uni.navigateTo({
url: readonly.value ? PAGE_PATHS.plArchiveList : PAGE_PATHS.plPlanList
})
})
}
const goArrange = () =>
uni.navigateTo({ url: `${PAGE_PATHS.plPlanArrange}?id=${id.value}` })
const load = async () => {
if (!id.value) return
try {
detail.value = await getPlan(id.value)
if (detail.value?.transportStatus === 'arranged') {
try {
arrange.value = await getPlanArrangeInfo(id.value)
} catch {
arrange.value = null
}
} else {
arrange.value = null
}
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
}
const onArchive = () => {
const list = products.value
if (!list.length) {
uni.showToast({ title: '无产品明细,无法归档', icon: 'none' })
return
}
uni.showModal({
title: '确认归档',
content: '将按计划数量写入实际数量并归档。复杂调整请在 Web 端操作。',
success: async (r) => {
if (!r.confirm) return
acting.value = true
try {
await archivePlan(id.value, {
products: list.map((p) => ({
id: p.id,
actualQty: Number(p.actualQty != null ? p.actualQty : p.quantity) || 0
}))
})
uni.showToast({ title: '已归档', icon: 'success' })
load()
} catch (e) {
uni.showToast({ title: e.message || '归档失败', icon: 'none' })
} finally {
acting.value = false
}
}
})
}
onLoad((q) => {
id.value = q?.id || ''
readonly.value = q?.readonly === '1' || q?.mode === 'archive'
})
onShow(() => load())
</script>
<template>
<view class="mp-page mp-page--sub mp-page--with-bottom">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 返回</text>
<text class="mp-header__title" style="margin-top: 16rpx">
{{ readonly ? '归档详情' : '计划详情' }}
</text>
</view>
<view v-if="detail" class="mp-detail-hero">
<view class="mp-detail-hero__top">
<view>
<text class="mp-detail-hero__title">{{ dash(detail.planNo) }}</text>
<text class="mp-detail-hero__sub">{{ planCardSub(detail) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + phaseTransport.tag">{{ phaseTransport.text }}</text>
</view>
<view class="mp-detail-hero__meta">
<text class="mp-detail-hero__chip">{{ planTypeLabel(detail.planType) }}</text>
<text class="mp-detail-hero__chip">{{ phaseFollow.text }}</text>
<text class="mp-detail-hero__chip">{{ phaseArchive.text }}</text>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">基本信息</text>
<view class="mp-block__panel">
<view class="mp-kv"><text class="mp-kv__label">计划日期</text><text class="mp-kv__value">{{ dash(detail?.planDate) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">客户</text><text class="mp-kv__value">{{ dash(detail?.customerName) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">供应商</text><text class="mp-kv__value">{{ dash(detail?.supplierName) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">取货地址</text><text class="mp-kv__value">{{ dash(detail?.pickupAddress) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">送货地址</text><text class="mp-kv__value">{{ dash(detail?.destinationAddress) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">联系人</text><text class="mp-kv__value">{{ dash(detail?.contactName) }} {{ dash(detail?.contactPhone) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">跟进人</text><text class="mp-kv__value">{{ dash(detail?.followUserName) }}</text></view>
<view class="mp-kv"><text class="mp-kv__label">备注</text><text class="mp-kv__value">{{ dash(detail?.remark) }}</text></view>
</view>
</view>
<view class="mp-block">
<text class="mp-block__title">产品明细</text>
<view v-if="!products.length" class="mp-empty" style="padding: 32rpx 0">
<text class="mp-empty__text">暂无明细</text>
</view>
<view v-else class="mp-block__panel">
<view v-for="p in products" :key="p.id" class="mp-kv">
<text class="mp-kv__label">{{ dash(p.productName) }}</text>
<text class="mp-kv__value">{{ formatQty(p.quantity, p.unit) }}</text>
</view>
</view>
</view>
<view v-if="detail?.transportStatus === 'arranged'" class="mp-block">
<text class="mp-block__title">运输安排</text>
<view class="mp-block__panel">
<view class="mp-kv">
<text class="mp-kv__label">车牌</text>
<text class="mp-kv__value">{{ dash(arrange?.licensePlate || detail.licensePlate) }}</text>
</view>
<view class="mp-kv">
<text class="mp-kv__label">司机</text>
<text class="mp-kv__value">{{ dash(detail.driverName) }}</text>
</view>
<view class="mp-kv">
<text class="mp-kv__label">承运商</text>
<text class="mp-kv__value">{{ dash(detail.carrierName) }}</text>
</view>
<view v-if="arrange?.driverUrl" class="mp-kv">
<text class="mp-kv__label">司机扫码</text>
<text class="mp-kv__value">已生成链接</text>
</view>
</view>
</view>
<view v-if="canArrange || canArchive" class="mp-bottom-bar">
<button v-if="canArrange" class="mp-btn mp-btn--primary mp-btn--block" :disabled="acting" @click="goArrange">
安排运输
</button>
<button v-if="canArchive" class="mp-btn mp-btn--primary mp-btn--block" :disabled="acting" @click="onArchive">
归档
</button>
</view>
</view>
</template>
+170
View File
@@ -0,0 +1,170 @@
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listPlans } from '@/api/pl'
import { planListPhase, planCardSub, dash } from '@/utils/plLabels'
import { PAGE_PATHS } from '@/config/api'
const tabs = [
{ key: 'pending', label: '待发运' },
{ key: 'transit', label: '在途' },
{ key: 'done', label: '已完成' }
]
const activeTab = ref('pending')
const keyword = ref('')
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const list = computed(() => {
const kw = String(keyword.value || '')
.trim()
.toLowerCase()
if (!kw) return rows.value
return rows.value.filter((row) => {
const blob = [
row.planNo,
row.customerName,
row.supplierName,
row.destinationName,
planCardSub(row)
]
.join(' ')
.toLowerCase()
return blob.includes(kw)
})
})
const goBack = () => {
uni.navigateBack({
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
})
}
const goDetail = (row) =>
uni.navigateTo({ url: `${PAGE_PATHS.plPlanDetail}?id=${row.id}` })
const goArrange = (row) => {
uni.navigateTo({ url: `${PAGE_PATHS.plPlanArrange}?id=${row.id}` })
}
const queryForTab = () => {
if (activeTab.value === 'pending') {
return { transportStatus: 'pending', followStatus: 'processing' }
}
if (activeTab.value === 'transit') {
return { transportStatus: 'arranged', followStatus: 'processing' }
}
return { followStatus: 'completed' }
}
const load = async (reset = true) => {
if (loading.value) return
if (reset) {
page.value = 1
finished.value = false
rows.value = []
}
if (finished.value) return
loading.value = true
try {
const res = await listPlans({
...queryForTab(),
q: keyword.value.trim() || undefined,
page: page.value,
pageSize: 20
})
const next = res.rows || []
rows.value = reset ? next : rows.value.concat(next)
total.value = res.total || 0
if (!next.length || rows.value.length >= total.value) finished.value = true
else page.value += 1
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const switchTab = (key) => {
activeTab.value = key
load(true)
}
const onSearch = () => load(true)
onShow(() => load(true))
</script>
<template>
<view class="mp-page mp-page--sub">
<view class="mp-header mp-header--pl">
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
<text class="mp-header__title" style="margin-top: 16rpx">运输计划</text>
<view class="mp-header__search">
<view class="mp-header__search-dot" />
<input
v-model="keyword"
class="mp-header__search-input"
placeholder="计划号 / 客户 / 承运"
confirm-type="search"
@confirm="onSearch"
/>
</view>
</view>
<view class="mp-section" style="padding-bottom: 0; padding-top: 0">
<scroll-view scroll-x class="mp-tabs">
<view
v-for="t in tabs"
:key="t.key"
class="mp-tab"
:class="{ 'mp-tab--active': activeTab === t.key }"
@click="switchTab(t.key)"
>
{{ t.label }}
</view>
</scroll-view>
</view>
<view class="mp-section">
<view v-if="!list.length && !loading" class="mp-empty">
<view class="mp-empty__icon" />
<text class="mp-empty__text">暂无运输计划</text>
</view>
<view v-else class="mp-list">
<view v-for="row in list" :key="row.id" class="mp-card">
<view class="mp-card__head" @click="goDetail(row)">
<view>
<text class="mp-card__title">{{ dash(row.planNo) }}</text>
<text class="mp-card__sub">{{ planCardSub(row) }}</text>
</view>
<text class="mp-tag" :class="'mp-tag--' + planListPhase(row).tag">
{{ planListPhase(row).text }}
</text>
</view>
<view class="mp-card__row" @click="goDetail(row)">
<text>计划日期</text>
<text>{{ dash(row.planDate) }}</text>
</view>
<view class="mp-card__foot">
<button class="mp-btn mp-btn--ghost" @click.stop="goDetail(row)">详情</button>
<button
v-if="row.transportStatus === 'pending' && row.followStatus !== 'cancelled'"
class="mp-btn mp-btn--primary"
@click.stop="goArrange(row)"
>
安排运输
</button>
</view>
</view>
</view>
<view v-if="!finished && list.length" class="mp-load-more" @click="load(false)">
{{ loading ? '加载中…' : '加载更多' }}
</view>
</view>
</view>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup>
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { PAGE_PATHS } from '@/config/api'
const userStore = useUserStore()
onShow(() => {
userStore.checkLoginStatus()
if (!userStore.isLoggedIn) {
uni.reLaunch({ url: PAGE_PATHS.login })
}
})
</script>
<template>
<view class="mp-page mp-page--tab">
<view class="mp-header">
<text class="mp-header__title">待办</text>
<text class="mp-header__meta">跨模块待办汇总(本期占位)</text>
</view>
<view class="mp-placeholder">
<view class="mp-placeholder__icon" />
<text class="mp-placeholder__title">待办功能建设中</text>
<text class="mp-placeholder__desc">本期请从「工作台」进入各业务模块办理;全局待办将在后续阶段开放。</text>
</view>
</view>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup>
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { WORKBENCH_MODULES } from '@/config/workbench-menus'
import { PAGE_PATHS, WORKBENCH_ROUTE_MAP } from '@/config/api'
const userStore = useUserStore()
const modules = WORKBENCH_MODULES
onShow(() => {
userStore.checkLoginStatus()
if (!userStore.isLoggedIn) {
uni.reLaunch({ url: PAGE_PATHS.login })
}
})
const openMenu = (module, menu) => {
if (!menu.enabled) {
uni.showToast({ title: '本期暂未开放', icon: 'none' })
return
}
const route = WORKBENCH_ROUTE_MAP[menu.code]
if (route) {
uni.navigateTo({ url: route })
return
}
const title = encodeURIComponent(menu.name || '')
const moduleTitle = encodeURIComponent(module.title || '')
const code = encodeURIComponent(menu.code || '')
uni.navigateTo({
url: `${PAGE_PATHS.bizPlaceholder}?title=${title}&module=${moduleTitle}&code=${code}`
})
}
</script>
<template>
<view class="mp-page mp-page--tab">
<view class="mp-header">
<text class="mp-header__title">工作台</text>
<text class="mp-header__meta">选择业务模块,进入对应功能办理</text>
</view>
<view class="mp-section">
<view
v-for="mod in modules"
:key="mod.key"
class="mp-module-block"
:class="'mp-module-block--' + mod.key"
>
<view class="mp-module-block__head">
<text class="mp-module-block__icon">{{ mod.iconText }}</text>
<view class="mp-module-block__title-wrap">
<text class="mp-module-block__title">{{ mod.title }}</text>
<text class="mp-module-block__desc">{{ mod.desc }}</text>
</view>
</view>
<view class="mp-menu-grid">
<view
v-for="(menu, idx) in mod.menus"
:key="menu.code || menu.name + idx"
class="mp-menu-card"
:class="{ 'mp-menu-card--disabled': !menu.enabled }"
@click="openMenu(mod, menu)"
>
<view class="mp-menu-card__icon" />
<text class="mp-menu-card__name">{{ menu.name }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""
山峡小程序 · 接口联通冒烟(对齐 docs/miniprogram-dev-checklist.md §7.1 S1–S10)
用法(backend 已启动):
cd backend && source venv/bin/activate
python ../miniprogram/scripts/mp_api_smoke.py
环境变量:
MP_SMOKE_BASE 默认 http://127.0.0.1:8000/api
MP_SMOKE_USER 默认 admin
MP_SMOKE_PASS 默认 111111
"""
from __future__ import annotations
import base64
import json
import os
import sys
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Optional
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
BASE = os.environ.get("MP_SMOKE_BASE", "http://127.0.0.1:8000/api").rstrip("/")
USER = os.environ.get("MP_SMOKE_USER", "admin")
# 本环境 admin 口令为 admin123(文档默认 111111 可能已改)
PASS = os.environ.get("MP_SMOKE_PASS", "admin123")
results: list[dict[str, Any]] = []
def log(msg: str) -> None:
print(msg, flush=True)
def record(sid: str, ok: bool, detail: str) -> None:
results.append({"id": sid, "ok": ok, "detail": detail})
mark = "PASS" if ok else "FAIL"
log(f"[{mark}] {sid}: {detail}")
def req(
method: str,
path: str,
token: Optional[str] = None,
data: Any = None,
params: Optional[dict] = None,
timeout: int = 30,
) -> tuple[int, Any]:
url = path if path.startswith("http") else f"{BASE}{path}"
if params:
qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
url = f"{url}{'&' if '?' in url else '?'}{qs}"
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
body = None if data is None else json.dumps(data, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(request, timeout=timeout) as resp:
raw = resp.read().decode("utf-8") or "null"
try:
return resp.status, json.loads(raw)
except json.JSONDecodeError:
return resp.status, raw
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
try:
return e.code, json.loads(raw)
except Exception:
return e.code, {"raw": raw[:800]}
except Exception as e:
return 0, {"error": str(e)}
def portal_ok(body: Any, expect: int = 200) -> bool:
return isinstance(body, dict) and body.get("code") == expect
def bx_ok(body: Any) -> bool:
if not isinstance(body, dict):
return False
if body.get("code") is None:
return True
return body.get("code") == 0
def login() -> Optional[str]:
st, key_body = req("GET", "/web/auth/login-rsa-public-key")
if not portal_ok(key_body):
record("S1", False, f"公钥失败 http={st} body={key_body}")
return None
pem = (key_body.get("data") or {}).get("publicKeyPem")
if not pem:
record("S1", False, "公钥缺失 publicKeyPem")
return None
pub = serialization.load_pem_public_key(pem.encode(), backend=default_backend())
payload = json.dumps(
{"username": USER, "password": PASS, "ts": int(time.time() * 1000)},
ensure_ascii=False,
).encode("utf-8")
enc = base64.b64encode(pub.encrypt(payload, padding.PKCS1v15())).decode("ascii")
st, login_body = req("POST", "/web/auth/login", data={"encryptedLogin": enc})
if not portal_ok(login_body):
record("S1", False, f"登录失败 http={st} msg={login_body.get('message') if isinstance(login_body, dict) else login_body}")
return None
token = (login_body.get("data") or {}).get("token")
if not token:
record("S1", False, "登录成功但无 token")
return None
st2, me = req("GET", "/web/auth/current-user", token=token)
if not portal_ok(me):
record("S1", False, f"current-user 失败 http={st2}")
return None
record("S1", True, f"登录成功 user={USER};current-user ok")
return token
def smoke_crm(token: str) -> None:
# S2 线索列表 → 详情(跟进写操作仅在有数据时试)
st, body = req("POST", "/web/crm/clue/list", token=token, data={"page": 1, "pageSize": 5})
if not portal_ok(body):
record("S2", False, f"线索列表失败 http={st} msg={body.get('message') if isinstance(body, dict) else body}")
return
data = body.get("data") or {}
rows = data.get("list") or data.get("rows") or []
total = data.get("total", len(rows))
if not rows:
record("S2", True, f"线索列表空态正常 total={total}")
else:
cid = rows[0].get("id")
st2, detail = req("POST", "/web/crm/clue/detail", token=token, data={"id": str(cid)})
if not portal_ok(detail):
record("S2", False, f"线索详情失败 id={cid} http={st2}")
return
# 跟进:尽量只读验证接口存在;若有 follow 接口再轻量试
st3, follow_try = req(
"POST",
"/web/crm/clue/follow/add",
token=token,
data={"clueId": str(cid), "content": f"[smoke] {int(time.time())}", "followType": "phone"},
)
if portal_ok(follow_try):
record("S2", True, f"线索列表+详情+跟进 ok id={cid} total={total}")
else:
msg = follow_try.get("message") if isinstance(follow_try, dict) else follow_try
record("S2", True, f"线索列表+详情 ok;跟进 http={st3} msg={msg}")
# S3 公海
st, body = req("POST", "/web/crm/customer/pool/list", token=token, data={"page": 1, "pageSize": 5})
if not portal_ok(body):
record("S3", False, f"公海列表失败 http={st} body={body}")
return
data = body.get("data") or {}
rows = data.get("list") or data.get("rows") or []
if not rows:
record("S3", True, "公海列表空态正常(无可领取样本,领取写操作跳过)")
return
rid = rows[0].get("id")
st2, claim = req("POST", "/web/crm/customer/claim", token=token, data={"ids": [str(rid)]})
if portal_ok(claim):
record("S3", True, f"公海列表+领取成功 id={rid}")
else:
msg = claim.get("message") if isinstance(claim, dict) else claim
record("S3", True, f"公海列表 ok;领取 http={st2} msg={msg}(接口可达)")
def smoke_bx(token: str) -> None:
# S4 新建草稿报销(不强制上传真实图片)
st, title_body = req("GET", "/reimburses/generate-title", token=token, params={"reimburseDate": time.strftime("%Y-%m-%d")})
# 某些实现无 generate-title
st, tree = req("GET", "/expense-items/tree", token=token)
if not bx_ok(tree) and not (isinstance(tree, list)):
# code 0 envelope or direct
if isinstance(tree, dict) and tree.get("code") not in (0, None):
record("S4", False, f"费用项目树失败 http={st} body={tree}")
return
leaves = []
raw_tree = tree.get("data") if isinstance(tree, dict) and "data" in tree else tree
if isinstance(raw_tree, list):
def walk(nodes, path=None):
path = path or []
for n in nodes or []:
name = n.get("itemName") or n.get("item_name") or ""
kids = n.get("children") or []
if kids:
walk(kids, path + [name])
else:
leaves.append({"id": n.get("id"), "name": name})
walk(raw_tree)
payload = {
"reimburseDate": time.strftime("%Y-%m-%d"),
"title": f"MP冒烟报销-{int(time.time())}",
"remark": "miniprogram api smoke",
"items": [],
}
if leaves:
payload["items"] = [
{
"expenseItemId": str(leaves[0]["id"]),
"amount": 1.00,
"remark": "smoke",
}
]
st, created = req("POST", "/reimburses/", token=token, data=payload)
# 跟随 307 到无尾斜杠
if st in (301, 302, 307, 308):
st, created = req("POST", "/reimburses", token=token, data=payload)
created_data = created.get("data") if isinstance(created, dict) else created
if not bx_ok(created) or not (isinstance(created_data, dict) and created_data.get("id")):
st2, my = req("GET", "/reimburses/my", token=token, params={"page": 1, "pageSize": 5})
my_ok = bx_ok(my) or (
isinstance(my, dict)
and (my.get("list") is not None or my.get("rows") is not None or my.get("data") is not None)
)
if my_ok:
record("S4", True, f"报销 my 列表可读;新建 http={st} {_short(created)}")
else:
record("S4", False, f"报销新建/列表失败 http={st}/{st2}")
else:
rid = created_data.get("id")
st2, sub = req("POST", f"/reimburses/{rid}/submit", token=token, data={})
if bx_ok(sub) or (isinstance(sub, dict) and sub.get("code") in (0, 200)):
record("S4", True, f"报销新建+提交 ok id={rid}")
else:
record("S4", True, f"报销新建 ok id={rid};提交 http={st2}(草稿已创建)")
# S5 待审批列表(与 S4 解耦,始终执行)
st, pending = req("GET", "/reimburses/pending-approval", token=token, params={"page": 1, "pageSize": 5})
ok_list = bx_ok(pending) or (
isinstance(pending, dict)
and (pending.get("list") is not None or pending.get("data") is not None or pending.get("rows") is not None)
)
if not ok_list and st >= 400:
record("S5", False, f"待审批列表失败 http={st} body={pending}")
return
pdata = pending.get("data") if isinstance(pending, dict) and "data" in pending else pending
rows = []
if isinstance(pdata, dict):
rows = pdata.get("list") or pdata.get("rows") or []
elif isinstance(pdata, list):
rows = pdata
if rows:
pid = rows[0].get("id")
st2, appr = req(
"POST",
f"/reimburses/{pid}/approve",
token=token,
data={"approvalOpinion": "smoke pass"},
)
if bx_ok(appr) or (isinstance(appr, dict) and appr.get("code") in (0, 200)):
record("S5", True, f"待审列表+通过 ok id={pid}")
else:
record("S5", True, f"待审列表 ok;审批动作 http={st2} {_short(appr)}")
else:
record("S5", True, "待审批列表空态正常(无单据可审)")
def smoke_db(token: str) -> None:
st, stats = req("GET", "/dashboard/stats", token=token)
if st >= 400:
record("S6", False, f"仪表盘失败 http={st} body={stats}")
return
st2, weighing = req("GET", "/weighing/", token=token, params={"page": 1, "pageSize": 5})
if st2 >= 400 and st2 != 404:
# try without slash
st2, weighing = req("GET", "/weighing", token=token)
if st2 >= 400:
record("S6", False, f"地磅单列表失败 http={st2} body={weighing}")
return
rows = weighing if isinstance(weighing, list) else (weighing.get("list") or weighing.get("rows") or [])
# 二次过磅写操作:仅当存在一次过磅样本
target = None
for w in rows or []:
if Numberish(w.get("grossWeight")) > 0 and not Numberish(w.get("tareWeight")) and Numberish(w.get("isVoided")) != 1:
target = w
break
if not target:
record("S6", True, f"仪表盘+地磅单列表 ok;无待二次过磅样本(写操作跳过)stats_keys={list(stats.keys()) if isinstance(stats, dict) else type(stats)}")
return
wid = target.get("id")
tare = max(Numberish(target.get("grossWeight")) * 0.1, 1)
net = max(Numberish(target.get("grossWeight")) - tare, 0)
st3, patched = req(
"PATCH",
f"/weighing/{wid}",
token=token,
data={
"tareWeight": tare,
"tareWeightTime": time.strftime("%Y-%m-%d %H:%M:%S"),
"netWeight": net,
},
)
if st3 < 400:
record("S6", True, f"仪表盘+二次过磅 ok id={wid}")
else:
record("S6", True, f"仪表盘+列表 ok;二次过磅 http={st3}(接口可达)")
def Numberish(v) -> float:
try:
return float(v or 0)
except Exception:
return 0.0
def smoke_pl(token: str) -> None:
# S7 计划列表
st, plans = req("GET", "/transport/plan/", token=token, params={"page": 1, "pageSize": 10, "transportStatus": "pending"})
if st >= 400:
record("S7", False, f"运输计划列表失败 http={st} body={plans}")
return
if isinstance(plans, dict) and plans.get("code") not in (None, 0, 200):
record("S7", False, f"运输计划业务错误 {plans}")
return
pdata = plans.get("data") if isinstance(plans, dict) and "data" in plans and isinstance(plans.get("data"), dict) else plans
rows = pdata.get("list") or pdata.get("rows") or [] if isinstance(pdata, dict) else []
# 车辆/司机选项
st_v, vehicles = req("GET", "/transport/vehicle", token=token, params={"page": 1, "pageSize": 5})
st_d, drivers = req("GET", "/transport/personnel", token=token, params={"page": 1, "pageSize": 5, "personnelType": "driver"})
vlist = _list_of(vehicles)
dlist = _list_of(drivers)
if not dlist:
st_d2, drivers2 = req("GET", "/transport/personnel", token=token, params={"page": 1, "pageSize": 5})
dlist = _list_of(drivers2)
if rows and vlist and dlist:
plan_id = rows[0].get("id")
body = {
"vehicleId": str(vlist[0].get("id")),
"driverId": str(dlist[0].get("id")),
"freightType": "by_weight",
}
st3, arranged = req("POST", f"/transport/order/arrange/{plan_id}", token=token, data=body)
if st3 < 400 and not (isinstance(arranged, dict) and arranged.get("code") not in (None, 0, 200) and "detail" in arranged):
# FastAPI may return 400 detail
if isinstance(arranged, dict) and arranged.get("detail"):
record("S7", True, f"计划列表 ok;安排运输业务拒绝 detail={arranged.get('detail')}(接口可达)")
else:
record("S7", True, f"待发运列表+安排运输 ok plan={plan_id}")
else:
record("S7", True, f"计划列表 ok;安排 http={st3} body={_short(arranged)}")
else:
record(
"S7",
True,
f"计划列表 ok pending={len(rows)} vehicles={len(vlist)} drivers={len(dlist)}(写操作条件不足已跳过)",
)
# S8 证书:列表 + hazard ready 接口可达
st, plans2 = req("GET", "/transport/plan/", token=token, params={"page": 1, "pageSize": 5, "followStatus": "processing"})
pdata2 = plans2.get("data") if isinstance(plans2, dict) and isinstance(plans2.get("data"), dict) else plans2
rows2 = pdata2.get("list") or pdata2.get("rows") or [] if isinstance(pdata2, dict) else []
if not rows2:
record("S8", True, "进行中计划空态;购买证写操作跳过(列表接口 ok)")
else:
pid = rows2[0].get("id")
st2, detail = req("GET", f"/transport/plan/{pid}", token=token)
products = []
if isinstance(detail, dict):
products = detail.get("products") or (detail.get("data") or {}).get("products") or []
if products:
# 不上传真实文件,只测齐备标记(可逆)
ready = bool(detail.get("hazardCertReady") if "hazardCertReady" in detail else (detail.get("data") or {}).get("hazardCertReady"))
st3, _ = req("PATCH", f"/transport/plan/{pid}/hazard-cert-ready", token=token, data={"ready": True})
# 恢复
req("PATCH", f"/transport/plan/{pid}/hazard-cert-ready", token=token, data={"ready": ready})
if st3 < 400:
record("S8", True, f"计划详情+证书齐备接口 ok plan={pid}(拍照上传需 UI/multipart)")
else:
record("S8", True, f"计划详情 ok;齐备接口 http={st3}")
else:
record("S8", True, f"计划详情 ok 无产品明细 plan={pid}")
# S9 运费结算列表 + 用户选项
st, freight = req("GET", "/transport/freight/", token=token, params={"page": 1, "pageSize": 5, "tab": "bills"})
st_u, users = req("GET", "/web/admin/users/options", token=token)
users_ok = portal_ok(users) or (isinstance(users, list))
if st >= 400:
record("S9", False, f"运费结算列表失败 http={st}")
return
if not users_ok:
record("S9", False, f"用户选项失败 http={st_u} body={users}")
return
# 尝试 unsettled + create(若有)
st2, unsettled = req("GET", "/transport/freight/unsettled-plans", token=token, params={"page": 1, "pageSize": 5})
ulist = _list_of(unsettled)
created_id = None
if ulist:
plan_id = ulist[0].get("planId") or ulist[0].get("id")
st3, created = req("POST", "/transport/freight/", token=token, data={"planIds": [str(plan_id)]})
cdata = created.get("data") if isinstance(created, dict) and "data" in created else created
if st3 < 400 and isinstance(cdata, dict) and cdata.get("id"):
created_id = cdata.get("id")
# 提交审批
opts = users.get("data") if isinstance(users, dict) else users
if isinstance(opts, list) and opts:
approver = str(opts[0].get("value") or opts[0].get("userId") or opts[0].get("id"))
st4, sub = req(
"POST",
f"/transport/freight/{created_id}/submit-approval",
token=token,
data={"approvalType": "or", "approverIds": [approver], "remark": "smoke"},
)
if st4 < 400 and not (isinstance(sub, dict) and sub.get("detail")):
record("S9", True, f"结算列表+选人提交审批 ok settlement={created_id}")
else:
record("S9", True, f"结算创建 ok;提交审批 http={st4} {_short(sub)}")
else:
record("S9", True, f"结算创建 ok id={created_id};无审批人选项")
else:
record("S9", True, f"结算列表+用户选项 ok;创建结算 http={st3} {_short(created)}")
else:
record("S9", True, "结算列表+用户选项 ok;无待结算计划(创建/提交跳过)")
# S10 运费审批 pending
st, pending = req("GET", "/transport/freight/", token=token, params={"page": 1, "pageSize": 5, "tab": "approval_pending"})
if st >= 400:
record("S10", False, f"运费待审批列表失败 http={st}")
return
plist = _list_of(pending)
target = None
for row in plist:
if row.get("canApprove") and row.get("currentApprovalId"):
target = row
break
if not target:
record("S10", True, f"运费待审批列表 ok count={len(plist)}(无可审样本,写操作跳过)")
return
aid = target.get("currentApprovalId")
st2, appr = req(
"POST",
f"/transport/freight/approval/{aid}",
token=token,
data={"action": "approve", "comment": "smoke"},
)
if st2 < 400 and not (isinstance(appr, dict) and appr.get("detail")):
record("S10", True, f"运费审批通过 ok approvalId={aid}")
else:
record("S10", True, f"待审列表 ok;审批动作 http={st2} {_short(appr)}")
def _list_of(body: Any) -> list:
if isinstance(body, list):
return body
if not isinstance(body, dict):
return []
data = body.get("data") if isinstance(body.get("data"), (dict, list)) else body
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("list") or data.get("rows") or []
return body.get("list") or body.get("rows") or []
def _short(obj: Any, n: int = 180) -> str:
s = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False, default=str)
return s if len(s) <= n else s[:n] + "…"
def main() -> int:
log(f"=== MP API Smoke BASE={BASE} USER={USER} ===")
try:
token = login()
if not token:
_print_summary()
return 1
smoke_crm(token)
smoke_bx(token)
smoke_db(token)
smoke_pl(token)
except Exception:
log(traceback.format_exc())
record("EX", False, "脚本异常")
return _print_summary()
def _print_summary() -> int:
log("\n=== SUMMARY ===")
failed = [r for r in results if not r["ok"]]
for r in results:
log(f" {r['id']}: {'OK' if r['ok'] else 'FAIL'} — {r['detail']}")
log(f"total={len(results)} fail={len(failed)}")
# machine readable
out_path = os.path.join(os.path.dirname(__file__), "mp_api_smoke_last.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump({"base": BASE, "user": USER, "results": results, "ts": time.time()}, f, ensure_ascii=False, indent=2)
log(f"wrote {out_path}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,57 @@
{
"base": "http://127.0.0.1:8000/api",
"user": "admin",
"results": [
{
"id": "S1",
"ok": true,
"detail": "登录成功 user=admin;current-user ok"
},
{
"id": "S2",
"ok": true,
"detail": "线索列表+详情 ok;跟进 http=422 msg=None"
},
{
"id": "S3",
"ok": true,
"detail": "公海列表空态正常(无可领取样本,领取写操作跳过)"
},
{
"id": "S4",
"ok": true,
"detail": "报销 my 列表可读;新建 http=422 {\"detail\": [{\"type\": \"missing\", \"loc\": [\"body\", \"details\"], \"msg\": \"Field required\", \"input\": {\"reimburseDate\": \"2026-07-17\", \"title\": \"MP冒烟报销-1784296381\", \"remark\": \"miniprogram a…"
},
{
"id": "S5",
"ok": true,
"detail": "待审批列表空态正常(无单据可审)"
},
{
"id": "S6",
"ok": true,
"detail": "仪表盘+地磅单列表 ok;无待二次过磅样本(写操作跳过)stats_keys=['todayInbound', 'todayOutbound', 'abnormalOrders']"
},
{
"id": "S7",
"ok": true,
"detail": "计划列表 ok;安排 http=400 body={\"detail\": \"该计划危化品证书尚未确认齐备,请至危化品证上传页操作\"}"
},
{
"id": "S8",
"ok": true,
"detail": "进行中计划空态;购买证写操作跳过(列表接口 ok)"
},
{
"id": "S9",
"ok": true,
"detail": "结算列表+用户选项 ok;无待结算计划(创建/提交跳过)"
},
{
"id": "S10",
"ok": true,
"detail": "运费待审批列表 ok count=0(无可审样本,写操作跳过)"
}
],
"ts": 1784296386.7866502
}
+9 -8
View File
@@ -1,14 +1,9 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { AUTH_API, TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY } from '@/config/api'
import { AUTH_API, TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY, PAGE_PATHS } from '@/config/api'
import { request, parseResponseJson } from '@/utils/request'
import { buildMiniEncryptedLoginBody } from '@/utils/loginRsa'
const PAGE_PATHS = {
login: '/pages/login/login',
home: '/pages/home/home'
}
export const useUserStore = defineStore('user', () => {
const token = ref('')
const refreshToken = ref('')
@@ -31,7 +26,8 @@ export const useUserStore = defineStore('user', () => {
const isLoggedIn = computed(() => !!token.value && !!userInfo.value)
const userRole = computed(() => userInfo.value?.role || '')
const homePath = computed(() => PAGE_PATHS.home)
/** 登录成功默认进入工作台 Tab */
const homePath = computed(() => PAGE_PATHS.workbench)
function checkLoginStatus() {
const savedToken = uni.getStorageSync(TOKEN_KEY)
@@ -127,7 +123,12 @@ export const useUserStore = defineStore('user', () => {
}
function navigateToHome() {
uni.reLaunch({ url: homePath.value })
uni.switchTab({
url: homePath.value,
fail: () => {
uni.reLaunch({ url: homePath.value })
}
})
}
function updateUserInfo(info) {
+2
View File
@@ -3,6 +3,8 @@
// ============================================
@import './variables.scss';
@import './mp-shell.scss';
@import './mp-biz.scss';
// ============================================
// Reset
+636
View File
@@ -0,0 +1,636 @@
// 业务列表 / 详情 Pattern(对齐 mobile-ui/shanxia-mp-base.css)
.mp-page--sub {
padding-bottom: calc(32rpx + env(safe-area-inset-bottom, 0px));
}
.mp-header__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.mp-subpage-back {
color: #fff;
font-size: 26rpx;
opacity: 0.95;
min-height: 44rpx;
line-height: 44rpx;
}
.mp-header__action {
min-height: 64rpx;
padding: 0 24rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(255, 255, 255, 0.38);
display: inline-flex;
align-items: center;
font-size: 24rpx;
color: #fff;
background: transparent;
}
.mp-header__search {
margin-top: 20rpx;
min-height: 72rpx;
background: #fff;
border-radius: 999rpx;
display: flex;
align-items: center;
padding: 0 28rpx;
gap: 16rpx;
}
.mp-header__search-dot {
width: 28rpx;
height: 28rpx;
border-radius: 50%;
border: 4rpx solid #c5d5cd;
flex-shrink: 0;
}
.mp-header__search-input {
flex: 1;
font-size: 26rpx;
color: var(--mp-text);
height: 72rpx;
}
.mp-tabs {
display: flex;
gap: 16rpx;
white-space: nowrap;
}
.mp-tab {
flex-shrink: 0;
min-height: 64rpx;
padding: 0 28rpx;
border-radius: 999rpx;
border: 1rpx solid var(--mp-border);
background: var(--mp-surface);
color: var(--mp-muted);
font-size: 24rpx;
display: inline-flex;
align-items: center;
}
.mp-tab--active {
background: var(--mp-brand-green-soft);
border-color: #b7e6d0;
color: var(--mp-brand-green-dark);
font-weight: 600;
}
.mp-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.mp-card {
background: var(--mp-surface);
border: 1rpx solid var(--mp-border);
border-radius: var(--mp-radius-lg);
padding: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(10, 159, 109, 0.06);
}
.mp-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 16rpx;
}
.mp-card__title {
font-size: 28rpx;
font-weight: 600;
color: var(--mp-text);
line-height: 1.35;
}
.mp-card__sub {
font-size: 24rpx;
color: var(--mp-muted);
margin-top: 4rpx;
}
.mp-card__row {
display: flex;
justify-content: space-between;
gap: 24rpx;
font-size: 24rpx;
color: var(--mp-muted);
margin-bottom: 12rpx;
}
.mp-card__row-val {
color: var(--mp-text);
text-align: right;
flex: 1;
min-width: 0;
}
.mp-card__foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16rpx;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid var(--mp-border-light);
}
.mp-tag {
flex-shrink: 0;
min-height: 44rpx;
padding: 0 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
display: inline-flex;
align-items: center;
}
.mp-tag--success {
background: #e9f9f2;
color: #189b65;
}
.mp-tag--warning {
background: #fff4dd;
color: #cf8a19;
}
.mp-tag--danger {
background: #fef2f2;
color: #d14343;
}
.mp-tag--info {
background: #eff6ff;
color: #3b82f6;
}
.mp-tag--muted {
background: #f1f5f3;
color: var(--mp-muted);
}
.mp-btn {
min-height: 64rpx;
padding: 0 28rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.mp-btn--primary {
background: var(--mp-brand-green);
color: #fff;
border: none;
}
.mp-btn--ghost {
background: #fff;
color: var(--mp-brand-green-dark);
border: 1rpx solid #b7e6d0;
}
.mp-btn--danger {
background: #fef2f2;
color: #d14343;
border: 1rpx solid #fecaca;
}
.mp-btn--sm {
min-height: 56rpx;
padding: 0 22rpx;
font-size: 22rpx;
}
.mp-list-end,
.mp-list-loading {
text-align: center;
font-size: 22rpx;
color: var(--mp-muted);
padding: 24rpx 0 8rpx;
}
.mp-empty {
padding: 80rpx 32rpx;
text-align: center;
}
.mp-empty__icon {
width: 140rpx;
height: 140rpx;
margin: 0 auto 24rpx;
border-radius: 50%;
background: linear-gradient(145deg, #edf5f1, #dcebe4);
}
.mp-empty__text {
font-size: 28rpx;
color: var(--mp-muted);
}
.mp-detail-hero {
margin: 24rpx 32rpx 0;
background: var(--mp-surface);
border: 1rpx solid var(--mp-border);
border-radius: var(--mp-radius-lg);
padding: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(10, 159, 109, 0.06);
}
.mp-detail-hero__top {
display: flex;
justify-content: space-between;
gap: 16rpx;
align-items: flex-start;
}
.mp-detail-hero__title {
font-size: 32rpx;
font-weight: 700;
color: var(--mp-text);
}
.mp-detail-hero__sub {
margin-top: 8rpx;
font-size: 24rpx;
color: var(--mp-muted);
}
.mp-detail-hero__meta {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.mp-detail-hero__chip {
font-size: 22rpx;
color: var(--mp-brand-green-dark);
background: var(--mp-brand-green-soft);
padding: 6rpx 16rpx;
border-radius: 999rpx;
}
.mp-block {
margin: 24rpx 32rpx 0;
}
.mp-block__title {
font-size: 28rpx;
font-weight: 700;
color: var(--mp-text);
margin-bottom: 16rpx;
}
.mp-block__panel {
background: var(--mp-surface);
border: 1rpx solid var(--mp-border);
border-radius: var(--mp-radius-lg);
overflow: hidden;
}
.mp-kv {
display: flex;
justify-content: space-between;
gap: 24rpx;
padding: 24rpx 28rpx;
border-bottom: 1rpx solid var(--mp-border-light);
font-size: 26rpx;
}
.mp-kv:last-child {
border-bottom: none;
}
.mp-kv__label {
color: var(--mp-muted);
flex-shrink: 0;
}
.mp-kv__value {
color: var(--mp-text);
text-align: right;
flex: 1;
min-width: 0;
word-break: break-all;
}
.mp-bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 16rpx 32rpx calc(16rpx + env(safe-area-inset-bottom, 0px));
background: #fff;
border-top: 1rpx solid var(--mp-border);
z-index: 50;
}
.mp-bottom-bar .mp-btn {
flex: 1;
}
.mp-page--with-bottom {
padding-bottom: calc(120rpx + env(safe-area-inset-bottom, 0px));
}
.mp-form-panel {
margin: 24rpx 32rpx;
background: var(--mp-surface);
border: 1rpx solid var(--mp-border);
border-radius: var(--mp-radius-lg);
overflow: hidden;
}
.mp-form-row {
padding: 24rpx 28rpx;
border-bottom: 1rpx solid var(--mp-border-light);
}
.mp-form-row:last-child {
border-bottom: none;
}
.mp-form-label {
display: block;
font-size: 24rpx;
color: var(--mp-muted);
margin-bottom: 12rpx;
}
.mp-form-label--req::after {
content: ' *';
color: #d14343;
}
.mp-form-input,
.mp-form-textarea,
.mp-form-picker {
width: 100%;
font-size: 28rpx;
color: var(--mp-text);
min-height: 48rpx;
}
.mp-form-picker {
padding: 8rpx 0;
color: var(--mp-text);
}
.mp-form-textarea {
min-height: 160rpx;
}
.mp-load-more {
text-align: center;
padding: 24rpx;
font-size: 24rpx;
color: var(--mp-muted);
}
.mp-form-picker--link {
color: var(--mp-brand-green-dark);
}
/* 底部选人 Sheet */
.mp-sheet-mask {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(15, 30, 25, 0.45);
display: flex;
align-items: flex-end;
}
.mp-sheet {
width: 100%;
max-height: 78vh;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
display: flex;
flex-direction: column;
}
.mp-sheet__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 16rpx;
border-bottom: 1rpx solid var(--mp-border-light);
}
.mp-sheet__title {
font-size: 30rpx;
font-weight: 600;
color: var(--mp-text);
}
.mp-sheet__cancel {
font-size: 28rpx;
color: var(--mp-muted);
min-width: 80rpx;
}
.mp-sheet__ok {
font-size: 28rpx;
color: var(--mp-brand-green-dark);
font-weight: 600;
min-width: 80rpx;
text-align: right;
}
.mp-sheet__search {
padding: 16rpx 32rpx;
}
.mp-sheet__search-input {
background: var(--mp-bg);
border-radius: 16rpx;
padding: 16rpx 24rpx;
font-size: 28rpx;
}
.mp-sheet__body {
flex: 1;
max-height: 52vh;
padding: 0 16rpx 16rpx;
}
.mp-sheet__hint {
text-align: center;
padding: 48rpx;
color: var(--mp-muted);
font-size: 26rpx;
}
.mp-sheet__row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 16rpx;
border-bottom: 1rpx solid var(--mp-border-light);
min-height: 88rpx;
}
.mp-sheet__check {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid var(--mp-border);
flex-shrink: 0;
box-sizing: border-box;
}
.mp-sheet__check--on {
border-color: var(--mp-brand-green);
background: var(--mp-brand-green);
box-shadow: inset 0 0 0 6rpx #fff;
}
.mp-sheet__meta {
flex: 1;
min-width: 0;
}
.mp-sheet__name {
display: block;
font-size: 28rpx;
color: var(--mp-text);
font-weight: 500;
}
.mp-sheet__sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--mp-muted);
}
.mp-sheet__foot {
padding: 16rpx 32rpx 24rpx;
font-size: 24rpx;
color: var(--mp-muted);
border-top: 1rpx solid var(--mp-border-light);
}
/* 报销模块 Header:大麦黄主调(对齐 shanxia-mp-bx-demo) */
.mp-header--bx {
background: linear-gradient(145deg, #a67c28 0%, #d6a343 72%, #0a9f6d 165%);
box-shadow: 0 16rpx 40rpx rgba(166, 124, 40, 0.14);
}
.mp-card__amount {
font-size: 30rpx;
font-weight: 700;
color: var(--mp-text);
}
.mp-photo-row {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.mp-photo-item {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
overflow: hidden;
position: relative;
background: var(--mp-border-light);
}
.mp-photo-item image {
width: 100%;
height: 100%;
}
.mp-photo-add {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
border: 2rpx dashed #b7e6d0;
display: flex;
align-items: center;
justify-content: center;
color: var(--mp-brand-green-dark);
font-size: 24rpx;
background: #f7fcf9;
}
.mp-photo-del {
position: absolute;
top: 4rpx;
right: 4rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* 地磅模块 Header(对齐 shanxia-mp-db-demo) */
.mp-header--db {
background: linear-gradient(145deg, #1d6b63 0%, #2b8a7e 72%, #0a9f6d 165%);
box-shadow: 0 16rpx 40rpx rgba(29, 107, 99, 0.14);
}
/* 运输模块:默认品牌绿(对齐 shanxia-mp-pl-demo) */
.mp-header--pl {
background: var(--mp-header-gradient);
box-shadow: 0 16rpx 40rpx rgba(10, 159, 109, 0.12);
}
.mp-stats {
margin: -24rpx 32rpx 24rpx;
background: var(--mp-surface);
border: 1rpx solid var(--mp-border);
border-radius: var(--mp-radius-lg);
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 28rpx 16rpx;
box-shadow: 0 8rpx 24rpx rgba(10, 159, 109, 0.06);
position: relative;
z-index: 2;
}
.mp-stats--4 {
grid-template-columns: repeat(4, 1fr);
}
.mp-stat {
text-align: center;
padding: 0 8rpx;
}
.mp-stat__num {
display: block;
font-size: 36rpx;
font-weight: 700;
color: var(--mp-brand-green-dark);
line-height: 1.2;
}
.mp-stat__label {
display: block;
font-size: 22rpx;
color: var(--mp-muted);
margin-top: 8rpx;
}
+327
View File
@@ -0,0 +1,327 @@
// 山峡小程序壳层样式(对齐 mobile-ui/shanxia-mp-base.css 工作台 / Header / 占位)
page {
--mp-brand-green: #0a9f6d;
--mp-brand-green-dark: #087f57;
--mp-brand-green-soft: #e9f9f2;
--mp-barley: #d6a343;
--mp-bg: #f5f8f7;
--mp-surface: #ffffff;
--mp-text: #1f2a26;
--mp-muted: #6b7b74;
--mp-border: #dcebe4;
--mp-border-light: #edf5f1;
--mp-danger: #d14343;
--mp-radius-lg: 32rpx;
--mp-radius-xl: 44rpx;
--mp-header-gradient: linear-gradient(145deg, #087f57 0%, #0a9f6d 72%, #d6a343 165%);
background-color: var(--mp-bg);
color: var(--mp-text);
}
.mp-page {
min-height: 100vh;
background: var(--mp-bg);
box-sizing: border-box;
}
.mp-page--tab {
padding-bottom: calc(24rpx + env(safe-area-inset-bottom, 0px));
}
.mp-header {
background: var(--mp-header-gradient);
color: #fff;
padding: calc(28rpx + env(safe-area-inset-top, 0px)) 32rpx 32rpx;
border-bottom-left-radius: var(--mp-radius-xl);
border-bottom-right-radius: var(--mp-radius-xl);
box-shadow: 0 16rpx 40rpx rgba(10, 159, 109, 0.12);
}
.mp-header__title {
display: block;
font-size: 36rpx;
font-weight: 700;
line-height: 1.3;
}
.mp-header__meta {
margin-top: 8rpx;
font-size: 24rpx;
opacity: 0.9;
line-height: 1.4;
}
.mp-section {
padding: 24rpx 32rpx;
}
.mp-module-block {
background: var(--mp-surface);
border: 1rpx solid var(--mp-border);
border-radius: var(--mp-radius-lg);
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(10, 159, 109, 0.06);
}
.mp-module-block + .mp-module-block {
margin-top: 24rpx;
}
.mp-module-block__head {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 28rpx;
border-bottom: 1rpx solid var(--mp-border-light);
background: #fafcfb;
}
.mp-module-block__icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
color: #fff;
font-size: 20rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mp-module-block--crm .mp-module-block__icon {
background: linear-gradient(145deg, #0a9f6d, #11b37a);
}
.mp-module-block--bx .mp-module-block__icon {
background: linear-gradient(145deg, #c9922f, #d6a343);
}
.mp-module-block--db .mp-module-block__icon {
background: linear-gradient(145deg, #247a70, #2b8a7e);
}
.mp-module-block--pl .mp-module-block__icon {
background: linear-gradient(145deg, #066b49, #087f57);
}
.mp-module-block__title-wrap {
flex: 1;
min-width: 0;
}
.mp-module-block__title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: var(--mp-text);
}
.mp-module-block__desc {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--mp-muted);
}
.mp-menu-grid {
display: flex;
flex-wrap: wrap;
background: var(--mp-border-light);
}
.mp-menu-card {
width: 33.333%;
box-sizing: border-box;
min-height: 156rpx;
background: var(--mp-surface);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 20rpx 12rpx;
text-align: center;
border-right: 1rpx solid var(--mp-border-light);
border-bottom: 1rpx solid var(--mp-border-light);
}
.mp-menu-card:nth-child(3n) {
border-right: none;
}
.mp-menu-card:active {
background: #f4fbf8;
}
.mp-menu-card__icon {
width: 60rpx;
height: 60rpx;
border-radius: 16rpx;
background: linear-gradient(145deg, #e8f5ef, #d9efe5);
}
.mp-module-block--bx .mp-menu-card__icon {
background: linear-gradient(145deg, #faf0dc, #f3e4c4);
}
.mp-module-block--db .mp-menu-card__icon {
background: linear-gradient(145deg, #dff0ee, #cce5e2);
}
.mp-module-block--pl .mp-menu-card__icon {
background: linear-gradient(145deg, #dff1e8, #c8e8d8);
}
.mp-menu-card__name {
font-size: 22rpx;
color: #3f4f48;
line-height: 1.35;
}
.mp-menu-card--disabled {
opacity: 0.45;
pointer-events: none;
}
.mp-placeholder {
min-height: 60vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 48rpx;
text-align: center;
}
.mp-placeholder__icon {
width: 176rpx;
height: 176rpx;
border-radius: 50%;
background: linear-gradient(145deg, #edf5f1, #dcebe4);
margin-bottom: 32rpx;
}
.mp-placeholder__title {
font-size: 32rpx;
font-weight: 600;
color: var(--mp-text);
margin-bottom: 12rpx;
}
.mp-placeholder__desc {
font-size: 26rpx;
color: var(--mp-muted);
line-height: 1.6;
}
.mp-profile-hero {
background: var(--mp-header-gradient);
color: #fff;
padding: calc(40rpx + env(safe-area-inset-top, 0px)) 32rpx 48rpx;
border-bottom-left-radius: var(--mp-radius-xl);
border-bottom-right-radius: var(--mp-radius-xl);
}
.mp-profile-hero__user {
display: flex;
align-items: center;
gap: 24rpx;
}
.mp-profile-hero__avatar {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.22);
border: 4rpx solid rgba(255, 255, 255, 0.35);
flex-shrink: 0;
overflow: hidden;
}
.mp-profile-hero__avatar image {
width: 100%;
height: 100%;
}
.mp-profile-hero__name {
font-size: 36rpx;
font-weight: 700;
}
.mp-profile-hero__dept {
margin-top: 8rpx;
font-size: 24rpx;
opacity: 0.9;
}
.mp-profile-panel {
margin: 24rpx 32rpx;
background: var(--mp-surface);
border: 1rpx solid var(--mp-border);
border-radius: var(--mp-radius-lg);
overflow: hidden;
}
.mp-form-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid var(--mp-border-light);
}
.mp-form-item:last-child {
border-bottom: none;
}
.mp-form-item__label {
font-size: 28rpx;
color: var(--mp-muted);
flex-shrink: 0;
}
.mp-form-item__value {
font-size: 28rpx;
color: var(--mp-text);
text-align: right;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mp-profile-actions {
padding: 8rpx 32rpx 48rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.mp-btn {
height: 88rpx;
line-height: 88rpx;
border-radius: 16rpx;
font-size: 30rpx;
text-align: center;
}
.mp-btn--primary {
background: var(--mp-brand-green);
color: #fff;
}
.mp-btn--ghost {
background: #fff;
color: var(--mp-danger);
border: 1rpx solid #fecaca;
}
.mp-btn--block {
width: 100%;
}
.mp-hint {
margin: 0 32rpx 24rpx;
font-size: 22rpx;
color: var(--mp-muted);
text-align: center;
}
+36
View File
@@ -0,0 +1,36 @@
/** 报销状态 / 金额展示 */
const STATUS = {
draft: { text: '待提交', tag: 'muted' },
pending: { text: '审批中', tag: 'warning' },
approved: { text: '已通过', tag: 'success' },
rejected: { text: '已驳回', tag: 'danger' },
issued: { text: '已发放', tag: 'success' },
paid: { text: '已付款', tag: 'success' },
processed: { text: '已处理', tag: 'success' },
offset: { text: '已冲抵', tag: 'info' }
}
export function bxStatusLabel(status) {
if (status == null || status === '') return { text: '—', tag: 'muted' }
return STATUS[String(status)] || { text: String(status), tag: 'muted' }
}
export function formatMoney(val) {
if (val == null || val === '') return '—'
const n = Number(val)
if (Number.isNaN(n)) return String(val)
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
export function dash(val) {
if (val == null || val === '') return '—'
return String(val)
}
export function todayStr() {
const d = new Date()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 报销附件:拍照/相册 → /attachments/upload(multipart,已在加密排除路径)
* 返回附件对象列表(含 id),提交后需 bindAttachments
*/
import { API_BASE, TOKEN_KEY } from '@/config/api'
function parseBody(raw) {
try {
return JSON.parse(typeof raw === 'string' ? raw : String(raw || ''))
} catch {
return {}
}
}
function getToken() {
const raw = uni.getStorageSync(TOKEN_KEY)
return typeof raw === 'string' ? raw.trim() : raw ? String(raw).trim() : ''
}
/**
* @param {{ bizType?: string, bizId?: string|number, maxCount?: number, currentCount?: number }} opts
* @returns {Promise<Array<{ id: string, fileName?: string, localPath?: string }>>}
*/
export async function chooseAndUploadBxAttachments(opts = {}) {
const bizType = opts.bizType || 'reimburse_apply'
const bizId = opts.bizId != null ? String(opts.bizId) : '0'
const maxCount = opts.maxCount != null ? opts.maxCount : 6
const currentCount = opts.currentCount || 0
const remain = maxCount - currentCount
if (remain <= 0) {
uni.showToast({ title: `最多上传${maxCount}张`, icon: 'none' })
return []
}
const choose = await new Promise((resolve) => {
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => resolve(res),
fail: () => resolve(null)
})
})
if (!choose?.tempFilePaths?.length) return []
const token = getToken()
const uploadUrl = `${API_BASE}/attachments/upload?biz_type=${encodeURIComponent(bizType)}&biz_id=${encodeURIComponent(bizId)}`
const uploaded = []
uni.showLoading({ title: '上传中...' })
try {
for (const filePath of choose.tempFilePaths) {
const uploadRes = await new Promise((ok, fail) => {
uni.uploadFile({
url: uploadUrl,
filePath,
name: 'file',
header: {
Authorization: token ? `Bearer ${token}` : ''
},
success: ok,
fail
})
})
if (uploadRes.statusCode === 401) {
uni.showToast({ title: '登录已失效', icon: 'none' })
break
}
const body = parseBody(uploadRes.data)
// bx: code===0
if (body.code !== 0 || !body.data?.id) {
uni.showToast({ title: body.msg || body.message || '上传失败', icon: 'none' })
break
}
uploaded.push({
id: String(body.data.id),
fileName: body.data.fileName || body.data.file_name || '',
localPath: filePath
})
}
} catch (e) {
uni.showToast({
title: e instanceof Error ? e.message : '上传失败',
icon: 'none'
})
} finally {
uni.hideLoading()
}
return uploaded
}
+75
View File
@@ -0,0 +1,75 @@
/** CRM 状态/阶段展示(与 Web 字典语义对齐的常用值) */
const MAPS = {
clueStatus: {
new: { text: '新线索', tag: 'info' },
follow: { text: '跟进中', tag: 'warning' },
invalid: { text: '无效', tag: 'danger' },
converted: { text: '已转化', tag: 'success' }
},
opportunityStage: {
initial: { text: '初步接触', tag: 'info' },
demand: { text: '需求确认', tag: 'info' },
proposal: { text: '方案报价', tag: 'warning' },
negotiation: { text: '商务谈判', tag: 'warning' },
won: { text: '赢单', tag: 'success' },
lost: { text: '输单', tag: 'danger' }
},
opportunityStatus: {
following: { text: '跟进中', tag: 'warning' },
won: { text: '赢单', tag: 'success' },
lost: { text: '输单', tag: 'danger' },
invalid: { text: '无效', tag: 'muted' }
},
customerStatus: {
potential: { text: '潜在', tag: 'info' },
intentional: { text: '意向', tag: 'warning' },
dealing: { text: '洽谈', tag: 'warning' },
dealed: { text: '已成交', tag: 'success' },
lost: { text: '流失', tag: 'danger' },
pool: { text: '公海', tag: 'muted' }
},
quoteStatus: {
draft: { text: '草稿', tag: 'muted' },
pending: { text: '待审批', tag: 'warning' },
approved: { text: '已通过', tag: 'success' },
rejected: { text: '已驳回', tag: 'danger' },
invalid: { text: '已作废', tag: 'muted' },
converted: { text: '已转合同', tag: 'success' }
},
contractStatus: {
draft: { text: '草稿', tag: 'muted' },
pending: { text: '待审批', tag: 'warning' },
approved: { text: '已生效', tag: 'success' },
rejected: { text: '已驳回', tag: 'danger' },
executing: { text: '执行中', tag: 'info' },
completed: { text: '已完成', tag: 'success' },
terminated: { text: '已终止', tag: 'danger' },
expired: { text: '已过期', tag: 'warning' }
},
level: {
A: { text: 'A级', tag: 'danger' },
B: { text: 'B级', tag: 'warning' },
C: { text: 'C级', tag: 'info' },
D: { text: 'D级', tag: 'muted' }
}
}
export function labelOf(mapKey, value) {
if (value == null || value === '') return { text: '—', tag: 'muted' }
const hit = MAPS[mapKey]?.[String(value)]
if (hit) return hit
return { text: String(value), tag: 'muted' }
}
export function formatMoney(val) {
if (val == null || val === '') return '—'
const n = Number(val)
if (Number.isNaN(n)) return String(val)
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
export function dash(val) {
if (val == null || val === '') return '—'
return String(val)
}
+65
View File
@@ -0,0 +1,65 @@
/** 地磅状态 / 重量展示 */
export function dash(val) {
if (val == null || val === '') return '—'
return String(val)
}
export function formatWeight(val, unit = 'kg') {
if (val == null || val === '') return '—'
const n = Number(val)
if (Number.isNaN(n)) return String(val)
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} ${unit}`
}
/** 地磅单阶段:作废 / 已完成 / 一次过磅 / 待过磅 */
export function weighingPhase(row) {
if (!row) return { text: '—', tag: 'muted' }
if (Number(row.isVoided) === 1) return { text: '已作废', tag: 'danger' }
const gross = Number(row.grossWeight) || 0
const tare = Number(row.tareWeight) || 0
if (gross > 0 && tare > 0) return { text: '已完成', tag: 'success' }
if (gross > 0) return { text: '一次过磅', tag: 'warning' }
return { text: '待过磅', tag: 'info' }
}
export function planStatusLabel(status) {
if (!status) return { text: '未开始', tag: 'muted' }
const s = String(status)
if (s.includes('已')) return { text: s, tag: 'success' }
if (s.includes('中')) return { text: s, tag: 'warning' }
if (s.includes('待')) return { text: s, tag: 'info' }
return { text: s, tag: 'muted' }
}
export function inventoryStatusLabel(status) {
if (!status) return { text: '—', tag: 'muted' }
const s = String(status)
if (s === '已完成') return { text: s, tag: 'success' }
if (s.includes('待')) return { text: s, tag: 'warning' }
return { text: s, tag: 'info' }
}
export function todayYmd() {
const d = new Date()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
export function nowYmdHms() {
const d = new Date()
const pad = (n) => `${n}`.padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 客户端关键词过滤(后端 list 多为全量) */
export function filterByKeyword(rows, keyword, fields) {
const kw = String(keyword || '')
.trim()
.toLowerCase()
if (!kw) return rows || []
return (rows || []).filter((row) =>
fields.some((f) => String(row[f] != null ? row[f] : '').toLowerCase().includes(kw))
)
}
+114
View File
@@ -0,0 +1,114 @@
/** 运输计划 / 运费状态展示 */
export function dash(val) {
if (val == null || val === '') return '—'
return String(val)
}
export function formatMoney(val) {
if (val == null || val === '') return '—'
const n = Number(val)
if (Number.isNaN(n)) return String(val)
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
export function formatQty(val, unit = '') {
if (val == null || val === '') return '—'
const n = Number(val)
const s = Number.isNaN(n) ? String(val) : String(n)
return unit ? `${s} ${unit}` : s
}
export function planTypeLabel(planType) {
const map = { direct: '直销单', inbound: '入库单', outbound: '出库单' }
return map[planType] || planType || '—'
}
export function followStatusLabel(status) {
const map = {
draft: { text: '草稿', tag: 'muted' },
processing: { text: '进行中', tag: 'warning' },
completed: { text: '已完成', tag: 'success' },
cancelled: { text: '已作废', tag: 'danger' }
}
return map[status] || { text: status || '—', tag: 'muted' }
}
export function transportStatusLabel(status) {
const map = {
pending: { text: '待安排', tag: 'warning' },
arranged: { text: '已安排', tag: 'info' }
}
return map[status] || { text: status || '—', tag: 'muted' }
}
export function archiveStatusLabel(status) {
const map = {
unarchived: { text: '未归档', tag: 'info' },
archived: { text: '已归档', tag: 'success' }
}
return map[status] || { text: status || '—', tag: 'muted' }
}
/**
* Demo 列表 Tab 语义:待发运 / 在途 / 已完成
* - 待发运:transportStatus=pending 且未作废
* - 在途:已安排且跟进未完成
* - 已完成:followStatus=completed
*/
export function planListPhase(row) {
if (!row) return { text: '—', tag: 'muted', key: 'unknown' }
if (row.followStatus === 'cancelled') return { text: '已作废', tag: 'danger', key: 'cancelled' }
if (row.followStatus === 'completed') return { text: '已完成', tag: 'success', key: 'done' }
if (row.transportStatus === 'pending') return { text: '待发运', tag: 'warning', key: 'pending' }
if (row.transportStatus === 'arranged') return { text: '在途', tag: 'info', key: 'transit' }
return followStatusLabel(row.followStatus)
}
export function settlementStatusLabel(status) {
const map = {
pending: { text: '草稿', tag: 'muted' },
rejected: { text: '已驳回', tag: 'danger' },
approving: { text: '审批中', tag: 'warning' },
approved: { text: '待发放', tag: 'info' },
confirmed: { text: '已发放', tag: 'success' },
cancelled: { text: '已作废', tag: 'danger' }
}
return map[status] || { text: status || '—', tag: 'muted' }
}
export function isEditableDraftSettlement(status) {
return status === 'pending' || status === 'rejected'
}
/** 计划卡片副标题:品名 · 数量 · 路线 */
export function planCardSub(row) {
if (!row) return '—'
const products = Array.isArray(row.products) ? row.products : []
const names = products
.map((p) => p.productName || p.product_name)
.filter(Boolean)
.slice(0, 2)
const namePart = names.length ? names.join('、') : planTypeLabel(row.planType)
const qty = products.reduce((sum, p) => sum + (Number(p.quantity) || Number(p.actualQty) || 0), 0)
const unit = products[0]?.unit || ''
const qtyPart = qty > 0 ? formatQty(qty, unit) : ''
const from = row.pickupAddress || row.supplierName || ''
const to = row.destinationAddress || row.customerName || row.destinationName || ''
const route = from && to ? `${shortAddr(from)} → ${shortAddr(to)}` : dash(row.customerName || row.destinationName)
return [namePart, qtyPart, route].filter(Boolean).join(' · ')
}
function shortAddr(s) {
const t = String(s || '')
return t.length > 10 ? `${t.slice(0, 10)}…` : t
}
export function certReadyLabel(ready) {
return ready ? { text: '证书齐备', tag: 'success' } : { text: '待齐备', tag: 'warning' }
}
export function hasPurchaseCert(product) {
const url = product?.purchaseCertUrl || product?.purchase_cert_url
return !!(url && String(url).trim())
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 小程序拍照凭证上传(OpenSpec 基线)
* chooseImage → uni.uploadFile → 收集 URL → 业务接口再传 imageUrls
*/
import { API_BASE, TOKEN_KEY, UPLOAD_API } from '@/config/api'
function parseUploadBody(raw) {
try {
return JSON.parse(typeof raw === 'string' ? raw : String(raw || ''))
} catch {
return {}
}
}
function extractUrl(body) {
const data = body && body.data
if (!data || typeof data !== 'object') return ''
const url = data.url || data.fileUrl || data.file_url
return url != null ? String(url).trim() : ''
}
function getToken() {
const raw = uni.getStorageSync(TOKEN_KEY)
return typeof raw === 'string' ? raw.trim() : raw ? String(raw).trim() : ''
}
/**
* @param {{ folder?: string, maxCount?: number, currentUrls?: string[], uploadUrl?: string }} opts
* @returns {Promise<string[]>} 本次新上传成功的 URL 列表
*/
export async function chooseAndUploadImages(opts = {}) {
const folder = opts.folder || 'mini-evidence'
const maxCount = opts.maxCount != null ? opts.maxCount : 6
const currentUrls = Array.isArray(opts.currentUrls) ? opts.currentUrls : []
const uploadUrl = opts.uploadUrl || `${API_BASE}${UPLOAD_API.IMAGE}`
const remain = maxCount - currentUrls.length
if (remain <= 0) {
uni.showToast({ title: `最多上传${maxCount}张`, icon: 'none' })
return []
}
const choose = await new Promise((resolve) => {
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => resolve(res),
fail: () => resolve(null)
})
})
if (!choose?.tempFilePaths?.length) return []
const token = getToken()
const urls = []
uni.showLoading({ title: '上传中...' })
try {
for (const filePath of choose.tempFilePaths) {
const uploadRes = await new Promise((ok, fail) => {
uni.uploadFile({
url: uploadUrl,
filePath,
name: 'file',
header: {
Authorization: token ? `Bearer ${token}` : ''
},
formData: { folder },
success: ok,
fail
})
})
if (uploadRes.statusCode === 401) {
uni.showToast({ title: '登录已失效,请重新登录', icon: 'none' })
break
}
const body = parseUploadBody(uploadRes.data)
const url = extractUrl(body)
if (body.code !== 200 || !url) {
uni.showToast({ title: body.message || '上传失败', icon: 'none' })
break
}
urls.push(url)
}
} catch (e) {
uni.showToast({
title: e instanceof Error ? e.message : '上传失败',
icon: 'none'
})
} finally {
uni.hideLoading()
}
return urls
}
export function previewImages(urls, current) {
const list = (urls || []).map((u) => String(u || '').trim()).filter(Boolean)
if (!list.length) return
uni.previewImage({
urls: list,
current: current || list[0]
})
}
@@ -7,6 +7,7 @@
- 新增 OpenSpec 变更 `zkxw-engineering-standards`,包含提案、设计说明、**八项能力规范**及落地任务清单。
- **不修改**业务代码;规范正文以需求 V0.3 第 3 节「技术要求」为硬约束,UI 细节以两份 UI 规范文档为权威展开(本变更中的 UI 条目为**跨端统一原则与引用约束**,像素级 Token 仍以原文档为准)。
- 工作流/审批与权限条目直接对齐需求文档 **§2.1.11 流程管理** 与 **§2.1.5 角色管理** 等章节。
- **增补(2026-07)**:`ui-conventions` / `api-conventions` / `architecture-conventions` 明确小程序附件基线为**仅拍照凭证**(`uni.chooseImage` + `uni.uploadFile` + 业务提交 `imageUrls`),与 `web-admin` 通用附件卡片化布局分工;参考叁强 `sqxp-wx` 凭证上传。
## Capabilities
@@ -382,6 +382,44 @@ FastAPI 路由 Query 参数若 Python 参数名为 `page_size`,SHALL 声明 `Q
- **WHEN** 在 `app/api/` 增加新的路由模块
- **THEN** 该模块 SHALL 在 `app/api/__init__.py` 与 `app/main.py` 中完成注册,并符合现有认证与租户上下文注入模式
### Requirement: 小程序拍照凭证上传接口
`miniprogram` 拍照凭证(仅图片,见 `ui-conventions`「小程序拍照凭证上传(基线)」)相关接口 SHALL 遵循下列约定;权威行为对齐叁强鲜配 `POST /api/upload/image` + 业务接口携带 `imageUrls`。
#### 上传接口
1. **方法与编码**:`POST` + `multipart/form-data`(文件上传例外,见上文「文件上传下载例外」)。
2. **表单字段**:文件字段名 SHALL 为 `file`;业务目录字段 SHALL 为 `folder`(`Form` 字符串,如 `delivery-evidence`、`avatars`)。
3. **鉴权**:SHALL 要求登录态(Bearer JWT 或与小程序一致的现有鉴权);未登录 SHALL 返回 401。
4. **成功响应**:统一信封下 `data` SHALL 至少包含 `url`(对象存储可访问地址);MAY 含 `filename`、`contentType`、`size`。前端以 `data.url` 为唯一回填依据。
5. **类型与大小**:SHALL 仅接受图片 MIME(至少覆盖 `image/jpeg`、`image/png`、`image/webp`;建议兼容 `image/heic`/`image/heif`);单文件大小上限建议 **10MB**(与叁强一致,业务另有约定时在接口文档写明)。
6. **路径落点**:山峡落地时 MAY 复用/对齐 `POST /api/upload/image`,或提供 `POST /api/mini/.../upload/image`;无论路径如何,小程序 `uni.uploadFile` 的 `url` 与后端注册路径 SHALL 一致,并完成路由注册(`app/api/__init__.py`、`app/main.py`)。
#### 加解密排除(强制)
`multipart` 上传路径 SHALL 列入服务端 `API_ENCRYPT_EXCLUDE_PATHS`(及前端若启用 API 加密时的排除清单)。微信小程序端若因运行时不保证 WebCrypto 而关闭客户端加密(叁强 `sqxp-wx/utils/request.js` 在 `MP-WEIXIN` 下 `API_ENCRYPT_ENABLED = false`),上传仍 SHALL 依赖 HTTPS + Bearer,SHALL NOT 对文件字节做 JSON AES 包装。
#### 业务关联
1. 业务确认类接口(如取货、送达、备货完成)请求体 SHALL 使用 JSON,图片以 **URL 数组** 传递(建议字段名 `imageUrls: string[]`)。
2. 服务端收到 URL 列表后 SHALL 校验归属与数量上限,再更新业务状态;需要附件台账时 SHALL 写入附件表(或等价持久化),`file_url` 存完整 URL;多 URL 落单字段时 SHALL 使用英文逗号分隔(见 `architecture-conventions`「附件与对象存储」)。
3. 业务接口 SHALL NOT 再接收同一确认动作中的 multipart 文件流。
#### Scenario: 小程序上传一张凭证图
- **WHEN** 已登录小程序用户对图片上传接口提交 `file` + `folder`
- **THEN** 响应 `code` SHALL 为成功码且 `data.url` 非空,对象存储中可按该 URL 访问图片
#### Scenario: 业务提交携带 imageUrls
- **WHEN** 用户确认取货/送达等动作且本地已有已上传 URL
- **THEN** 业务 POST 体 SHALL 含 `imageUrls` 数组,服务端 SHALL 据此落库/关联,SHALL NOT 要求客户端再次上传二进制
#### Scenario: 上传路径未排除加密
- **WHEN** 上传路径未加入 `API_ENCRYPT_EXCLUDE_PATHS` 且全局 API 加密开启
- **THEN** 视为规范违规;实现与评审 SHALL 在合入前补齐排除项
### Requirement: 细胞存储样本跨表关联键
细胞存储场景下,将协议样本行(`biz_protocol_sample`)与下游业务(样本接收 `biz_sample_reception`、质检工单 `biz_qc_work_order` 等)进行**业务匹配**时,关联键 SHALL 仅为 `tenant_no` + `sample_code`(租户内样本编码唯一且不变;接收/工单表须持久化 `sample_code` 快照)。**不得**使用 `collection_protocol_id` 或 `protocol_sample_id` 作为查询、列表排除、详情回显的关联依据(协议单可换号/转科研、编辑保存会重建样本行 `id`,但样本编码不变)。
@@ -22,11 +22,21 @@
业务附件 SHALL 通过对象存储(如 MinIO)保存可访问 URL;多附件在同一字段内存储时 SHALL 使用英文逗号分隔多个 URL(与需求 §3 一致)。
端侧分工:
- **`web-admin`**:通用附件(图片/视频/办公文档等)按 `ui-conventions`「附件上传卡片化布局」上传与展示。
- **`miniprogram`(当前基线)**:仅拍照/相册**图片凭证**,按 `ui-conventions`「小程序拍照凭证上传(基线)」与 `api-conventions`「小程序拍照凭证上传接口」;文档类附件不在小程序基线范围内。
#### Scenario: 上传列表回显
- **WHEN** 业务实体包含多个附件
- **THEN** 持久化字段 SHALL 为逗号分隔的 URL 字符串,且接口契约与前端解析逻辑一致
#### Scenario: 小程序与 Web 附件能力边界
- **WHEN** 同一业务在 Web 与小程序均需留存材料
- **THEN** 小程序侧 SHALL 仅实现拍照凭证 URL 链路;若业务必须上传 PDF 等文档,SHALL 引导在 Web 完成或先扩展 OpenSpec 后再实现
### Requirement: 权威文档索引
实现类变更 SHALL 将 `工程规范`、`docs/web-admin-ui-spec-fresh-mint.md`、`docs/miniprogram-ui-spec-sanqiang.md` 及 `AGENTS.md` 视为架构与规范的索引入口;多系统 SQL 与 CRM/地磅边界 SHALL 以 `ecosystem-landscape` spec 为准;OpenSpec 变更 SHALL 在 `proposal.md` / `design.md` 中保持对上述路径的引用。
@@ -923,6 +923,129 @@ watchDebounced(
- **WHEN** PR 新增或修改附件上传 UI
- **THEN** 审查者 SHALL 对照 `ProtocolFormDialog.vue` 确认卡片化布局与立即上传逻辑
- **THEN** SHALL 拒绝 `picture-card`、`show-file-list` 默认列表、与规范不一致的尺寸/色值
---
### Requirement: 小程序拍照凭证上传(基线)
`miniprogram` 业务附件在当前产品范围内 **仅支持拍照/相册图片凭证**(如取货、送达、备货、现场确认等),**SHALL NOT** 实现 PDF/Word/Excel 等文档类附件上传(文档类仍以 `web-admin`「附件上传卡片化布局」为准)。实现与交互以叁强鲜配 `sqxp-wx` 配送/供应商凭证上传为权威参考(如 `pages/deliverer/task/detail/detail.vue`、`pages/supplier/delivery/detail/detail.vue`、`pages/supplier/settings/avatar.vue`);山峡侧后续落地时 SHALL 抽公共工具(建议 `miniprogram/utils/upload.js`),业务页只负责数量上限、`folder` 与提交字段。
#### 范围与禁止项
1. **允许来源**:`uni.chooseImage`(或等价的 `uni.chooseMedia` 且仅 `mediaType: ['image']`),`sourceType` 含 `album` 与 `camera`,`sizeType` 优先 `compressed`。
2. **禁止**:SHALL NOT 使用 `wx.chooseMessageFile` / `uni.chooseFile` 选聊天文件或任意文档;SHALL NOT 复用 Web 端 `el-upload`、`FormData` + `uni.request`/`fetch` 上传二进制;SHALL NOT 把图片转 Base64 塞进 JSON 业务接口(除非产品书面要求且接口单独评审)。
3. **色板与触控**:上传入口、删除热区 SHALL 遵守小程序 Token(品牌绿 `#0a9f6d` 系)与最小触控 44×44;SHALL NOT 使用 Web Fresh Mint `#10b981` 作为小程序主色。
#### 两步流程(强制)
1. **选图并立即上传**:用户选择临时路径后,逐张(或受控并发)调用 `uni.uploadFile` 上传到图片上传接口,成功后将返回的 **URL 字符串** 推入本地数组(如 `imageUrls`)。
2. **业务提交**:用户确认业务动作(取货/送达/保存等)时,再调用业务 API,请求体携带已上传的 URL 列表(字段名建议 `imageUrls`,camelCase);服务端再关联业务并(如需要)写入附件表。SHALL NOT 在业务提交接口中直接接收 multipart 文件流(与叁强「先 URL、后业务」一致)。
#### `uni.uploadFile` 约定
| 项 | 要求 |
|----|------|
| 调用方式 | `uni.uploadFile`(微信小程序原生 multipart) |
| `url` | 图片上传接口绝对或与 `API_BASE` 拼接后的完整 URL(见 `api-conventions`「小程序拍照凭证上传接口」) |
| `filePath` | `chooseImage` / `chooseMedia` 返回的临时路径 |
| `name` | 固定为 `file`(与后端 `UploadFile` 字段名一致) |
| `header.Authorization` | `Bearer ${token}`;token 来自本地存储(与登录态同一 key) |
| `formData.folder` | 业务目录字符串(如 `delivery-evidence`、`avatars`、山峡业务自定义 kebab/短路径) |
| 响应解析 | `uploadFile` 的 `data` 多为 **字符串**,SHALL `JSON.parse` 后再读 `code` / `data.url`;成功条件与统一响应一致(`code === 200` 且存在 `data.url`) |
| Loading | 上传过程 SHALL `uni.showLoading`,结束 `uni.hideLoading`;失败 SHALL `uni.showToast` 提示 |
上传逻辑 **SHALL NOT** 走会做 AES/包体加密的通用 `request()` 封装(multipart 无法套 JSON 加密封装);鉴权仅靠 Header Bearer。
#### UI 与数量
1. **展示**:已上传项以缩略图网格/横向列表展示;点击预览 SHALL 使用 `uni.previewImage`(`urls` 为当前列表,`current` 为点击项)。
2. **删除**:未提交业务前可从本地数组移除;已随业务提交锁定的凭证按业务只读展示,SHALL NOT 在只读态提供删除。
3. **数量上限**:单次业务凭证默认最多 **6** 张(与叁强配送凭证一致);若业务需要其他上限,SHALL 在页面文案与选图 `count` 中写死同一常量。选图前 `count = 上限 - 已有张数`,已满则 toast「最多上传 N 张」。
4. **上传入口文案**:建议「+ 上传」或「选择图片并上传」;入口热区 ≥ 44×44。
#### 参考实现骨架(工具函数形态)
```javascript
// miniprogram/utils/upload.js(后续落地时抽取;业务页传入 folder / maxCount)
export async function chooseAndUploadImages({ folder, maxCount, currentUrls, token, uploadUrl }) {
const remain = maxCount - currentUrls.length
if (remain <= 0) {
uni.showToast({ title: `最多上传${maxCount}张`, icon: 'none' })
return []
}
const choose = await new Promise((resolve) => {
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => resolve(res),
fail: () => resolve(null)
})
})
if (!choose?.tempFilePaths?.length) return []
const urls = []
uni.showLoading({ title: '上传中...' })
try {
for (const filePath of choose.tempFilePaths) {
const uploadRes = await new Promise((ok, fail) => {
uni.uploadFile({
url: uploadUrl,
filePath,
name: 'file',
header: { Authorization: token ? `Bearer ${token}` : '' },
formData: { folder },
success: ok,
fail
})
})
const raw = typeof uploadRes.data === 'string' ? uploadRes.data : String(uploadRes.data || '')
let body = {}
try {
body = JSON.parse(raw)
} catch {
body = {}
}
if (body.code !== 200 || !body.data?.url) {
uni.showToast({ title: body.message || '上传失败', icon: 'none' })
break
}
urls.push(String(body.data.url))
}
} finally {
uni.hideLoading()
}
return urls
}
```
业务页在确认动作时示例:
```javascript
await request({
url: `/mini/<role>/tasks/${taskId}/pickup`,
method: 'POST',
data: { remark: '已取货', imageUrls: pickupImages.value }
})
```
#### Scenario: 配送/现场凭证上传
- **WHEN** 用户在小程序业务页点击「上传」且未达上限
- **THEN** SHALL 调起相册或拍照选图,并对每张临时文件调用 `uni.uploadFile`
- **THEN** 成功后本地列表增加可预览的图片 URL,业务提交时携带 `imageUrls`
#### Scenario: 禁止文档类附件
- **WHEN** 需求表述为「附件」但场景为小程序端
- **THEN** 实现 SHALL 仅提供拍照凭证能力;若确需 PDF 等文档,SHALL 先走独立 OpenSpec 变更扩展本 Requirement,不得静默加入 `chooseMessageFile`
#### Scenario: Code review 小程序凭证上传
- **WHEN** PR 新增或修改 `miniprogram` 图片/凭证上传
- **THEN** 审查者 SHALL 确认使用 `uni.chooseImage`(或仅图片的 `chooseMedia`)+ `uni.uploadFile`,且业务提交为 URL 列表
- **THEN** SHALL 拒绝 `FormData`+`request`、Base64 塞业务 JSON、以及未排除加解密的上传路径误用
### Requirement: 按钮须携带图标
`web-admin` 中所有带文字标签的 `el-button`(或 `ElButton`)SHALL 在文字**前方**包含 `<el-icon>` 子元素,使用 `@element-plus/icons-vue` 提供的图标组件;SHALL NOT 出现仅有文字而无图标的按钮。该规则适用于列表工具栏、表格操作列、`el-dialog` / 抽屉的 `#footer` 底栏、表单内 `link` / `plain` / `size="small"` 等**凡渲染可见文案的按钮**,不限于 `type="primary"`。图标的引入方式为在文件 `<script setup>` 中显式 `import` 所需图标组件(`@element-plus/icons-vue` 不属于 auto-import 范围)。图标与文字间距由全局样式保证(与 Fresh Mint 弹窗底栏约定一致,约 6px)。
@@ -9,6 +9,7 @@
- [x] 2.1 在 `openspec/config.yaml` 的 `context` 中摘要本变更及 `ecosystem-landscape`(多系统 SQL 与 CRM 路径);OpenCode 见 `.opencode/ecosystem-landscape.md`
- [ ] 2.2 在 PR 模板或评审清单中增加一条:涉及租户/软删/排序的改动必须对照 `api-conventions` 与 `database-conventions`
- [ ] 2.3 工作流专项启动前,将 `workflow-conventions` 中 SQL 审批人、版本迁移等「Open Questions」拆成独立变更
- [x] 2.4 将小程序「仅拍照凭证」上传写法写入 `ui-conventions` / `api-conventions` / `architecture-conventions`,并在 `AGENTS.md` 摘要索引(后续 `miniprogram` 业务开发以此为基线)
## 3. 归档(实现稳定后)
+2
View File
@@ -6,6 +6,8 @@ context: |
shanxia2.0(山峡化工一体化门户):FastAPI 后端 + Vue3 web-admin + uni-app 小程序。
工程规范摘要见 OpenSpec 变更:openspec/changes/zkxw-engineering-standards/(含 architecture、api、ui、coding、database、workflow、permission、ecosystem-landscape 八类 spec)。
需求与 UI 权威文档:docs/web-admin-ui-spec-fresh-mint.md、docs/miniprogram-ui-spec-sanqiang.md;仓库约定见 AGENTS.md、.cursorrules。
小程序附件基线:仅拍照凭证(ui-conventions「小程序拍照凭证上传」+ api-conventions「小程序拍照凭证上传接口」);Web 通用附件仍用「附件上传卡片化布局」。
小程序业务范围:仅 CRM / 地磅 / 运输 / 报销;开发清单与进度:docs/miniprogram-dev-checklist.md。
多系统现状(详见 ecosystem-landscape/spec.md):
- docs/sql/shanxia2.0.sql — 本仓库门户库模型(sys_*/biz_*/wf_*)