# Delivery 端完整实现方案
> 基于 5 轮需求采集 + 代码审计
> 后端 API:已有 86 个接口,`WorkOrderService` 的 checkIn/finish/reportException 已就绪
> 当前状态:12 个页面路由中仅实现 2 个(login + index),10 个缺失
---
## 1. 需求与约束摘要
| 项 | 内容 |
|---|---|
| **任务类型** | 功能扩展 |
| **目标** | 实现 delivery-miniapp 全功能:GPS签到 + 轨迹 + 拍照水印 + 执行记录 + 异常上报 + 签退 + 离线缓存 + 断点续传 |
| **硬边界** | uni-app + Vue、可改后端 API、离线缓存、对象存储(MinIO/OSS)、双端条件编译、Toast 提示 |
---
## 2. 代码依据
| 文件 | 职责 |
|------|------|
| `delivery-miniapp/common/api.js` | 现有 API 封装:BASE_URL、getHeaders、apiGet、apiPost、idempotency key |
| `delivery-miniapp/pages/delivery/login/login.vue` | 登录页实现参考(uni.request、token存储、switchTab) |
| `delivery-miniapp/pages/delivery/index/index.vue` | 工作台实现参考(apiGet、navTo、状态映射) |
| `delivery-miniapp/pages.json` | 12 个路由已注册 |
| `delivery-miniapp/manifest.json` | GPS/Camera 权限已声明 |
| `module/workorder/controller/WorkOrderController.java` | 7 个端点(assign/accept/reject/check-in/start/finish/exception) |
| `module/workorder/controller/DeliveryController.java` | 5 个端点(workbench/today/detail/messages/read) |
| `module/execution/controller/ExecutionController.java` | 证据上传(presignUpload/commit/signedUrl/list) |
| `module/workorder/service/WorkOrderService.java` | checkIn(Haversine+200m)、finish(执行记录+签退+必做检查)、reportException |
---
## 3. 改动范围清单
### 前端(delivery-miniapp)
| 路径 | 增/删/改 | 摘要 |
|------|----------|------|
| `common/api.js` | 改 | 新增 getLocation/chooseImage/uploadFile/offlineQueue/watermark |
| `common/offline.js` | 增 | 离线缓存队列 + 断点续传 |
| `pages/delivery/checkin/checkin.vue` | 增 | GPS签到 + 拍照水印 + 对象确认 |
| `pages/delivery/order-detail/order-detail.vue` | 增 | 工单详情(患者/项目/操作入口) |
| `pages/delivery/execute/execute.vue` | 增 | 逐项执行记录 + 证据上传 |
| `pages/delivery/exception/exception.vue` | 增 | 一键异常上报 |
| `pages/delivery/finish/finish.vue` | 增 | 服务总结 + 签退定位 |
| `pages/delivery/accept/accept.vue` | 增 | 接单/拒单确认 |
| `pages/delivery/orders/orders.vue` | 增 | 工单列表(按状态筛选) |
| `pages/delivery/offline-sync/offline-sync.vue` | 增 | 离线数据补传 |
| `pages/delivery/messages/messages.vue` | 增 | 消息通知列表 |
| `pages/delivery/profile/profile.vue` | 增 | 我的资质 + 统计 |
### 后端
| 路径 | 增/删/改 | 摘要 |
|------|----------|------|
| `module/workorder/controller/WorkOrderController.java` | 改 | checkIn 增加 `photoFileId` → 实际文件上传 URL |
| `module/execution/service/EvidenceService.java` | 改 | commit 支持多种 fileType、增加真实文件上传 |
| `module/workorder/controller/GpsController.java` | 增 | GPS 轨迹上报端点 |
---
## 4. 方案概述
### 数据流
```
Service Person opens app → login → workbench
→ orders list (filtered by status)
→ order detail (patient, items, actions)
→ accept order
→ GPS check-in (location + photo watermark + patient confirm)
→ start service
→ execute items (per-item recording + evidence upload)
→ [exception report if needed]
→ finish service (summary + sign-off location)
→ wait for acceptance & settlement
Offline mode:
Any POST → if network fails → save to offline queue
→ offline-sync page → manual retry with progress
```
### 组件关系
```
common/
api.js → getLocation(), chooseImage(), uploadFile(), apiGet(), apiPost()
offline.js → OfflineQueue class (add, retry, clear)
```
---
## 5. 代码级修改说明
### 5.1 common/api.js — 扩展工具函数
**当前逻辑**:仅 `apiGet` / `apiPost`,无 GPS/拍照/上传能力。
**拟议改动**:
1. 新增 `getLocation()` — 封装 `uni.getLocation({ type: 'gcj02' })`
2. 新增 `chooseImage(count)` — 封装 `uni.chooseImage({ count, sizeType, sourceType })`
3. 新增 `uploadFile(filePath, presignResp)` — 封装 `uni.uploadFile` 上传到对象存储
4. 新增 `uploadEvidence(filePath, entityType, entityId)` — 完整 presign→upload→commit 流程
5. 新增 `addWatermark(tempFilePath, text)` — Canvas 绘制水印(时间+地点+工单号)
6. 新增 `gpsTrajectory` — 每 30 秒上报一次位置到后端
**建议代码**(新增内容追加到现有 api.js):
```js
const BASE_URL = 'http://localhost:18080/api/hss';
// ... 保留现有的 getHeaders, generateIdempotencyKey, apiGet, apiPost ...
// ==================== GPS ====================
/** 获取当前位置(GCJ-02坐标系,微信/高德兼容) */
function getLocation() {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.getLocation({ type: 'gcj02', success: resolve, fail: reject });
// #endif
// #ifdef APP-PLUS
uni.getLocation({ type: 'gcj02', geocode: true, success: resolve, fail: reject });
// #endif
});
}
/** GPS 轨迹上报(每30秒) */
let trajectoryTimer = null;
function startTrajectory(workOrderId) {
stopTrajectory();
trajectoryTimer = setInterval(async () => {
try {
const loc = await getLocation();
await apiPost('/gps/trajectory', {
workOrderId,
latitude: loc.latitude,
longitude: loc.longitude,
timestamp: new Date().toISOString()
});
} catch (e) { /* 静默失败,不影响主流程 */ }
}, 30000);
}
function stopTrajectory() {
if (trajectoryTimer) { clearInterval(trajectoryTimer); trajectoryTimer = null; }
}
// ==================== 拍照与水印 ====================
/** 拍照(支持相册选择) */
function chooseImage(count = 1) {
return new Promise((resolve, reject) => {
uni.chooseImage({
count, sizeType: ['compressed'],
// #ifdef MP-WEIXIN
sourceType: ['camera'],
// #endif
// #ifdef APP-PLUS
sourceType: ['camera', 'album'],
// #endif
success: resolve,
fail: reject
});
});
}
/** Canvas 水印:时间 + 地点 + 工单号 */
function addWatermark(tempFilePath, { workOrderId, address }) {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
const ctx = uni.createCanvasContext('watermark-canvas');
// ... draw text on canvas, then save
// #endif
// #ifdef APP-PLUS
// 使用 plus.nativeObj.Bitmap 或直接跳过(APP 端拍照自带 EXIF)
resolve(tempFilePath);
// #endif
});
}
// ==================== 文件上传 ====================
/** 完整证据上传流程:presign → upload → commit */
async function uploadEvidence(filePath, entityType, entityId) {
const fileName = filePath.split('/').pop() || 'photo.jpg';
const fileType = fileName.endsWith('.mp4') ? 'VIDEO' : fileName.endsWith('.mp3') ? 'AUDIO' : 'PHOTO';
// 1. 获取预签名上传 URL
const presignRes = await apiPost('/evidence/presign-upload', {
fileName, fileType, entityType, entityId
});
if (presignRes.data.code !== 200) throw new Error('PreSign failed');
const { fileKey, uploadUrl } = presignRes.data.data;
// 2. 上传文件
const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({
url: BASE_URL + '/evidence/commit',
filePath,
name: 'file',
formData: {
fileKey,
fileName,
fileType,
entityType: String(entityType),
entityId: String(entityId),
},
header: getHeaders(),
success: resolve,
fail: reject
});
});
return { fileKey, uploadRes };
}
// ==================== 导出 ====================
module.exports = {
BASE_URL, apiGet, apiPost, generateIdempotencyKey,
getLocation, startTrajectory, stopTrajectory,
chooseImage, addWatermark, uploadEvidence,
};
```
### 5.2 common/offline.js — 离线缓存队列
**当前逻辑**:不存在。
**拟议改动**:新建离线队列,失败请求缓存到本地,恢复网络后批量补传。
```js
const OFFLINE_QUEUE_KEY = 'hss_offline_queue';
class OfflineQueue {
constructor() {
this.queue = uni.getStorageSync(OFFLINE_QUEUE_KEY) || [];
}
add(item) {
this.queue.push({ ...item, createdAt: Date.now() });
this.save();
}
save() {
uni.setStorageSync(OFFLINE_QUEUE_KEY, this.queue);
}
getPending() { return [...this.queue]; }
getCount() { return this.queue.length; }
async retry(apiPost) {
const failed = [];
for (const item of this.queue) {
try {
const res = await apiPost(item.path, item.data);
if (res.data.code === 200) continue;
failed.push(item);
} catch (e) { failed.push(item); }
}
this.queue = failed;
this.save();
return { success: this.getCount() === 0, remaining: this.queue.length };
}
clear() { this.queue = []; this.save(); }
}
module.exports = { OfflineQueue };
```
### 5.3 pages/delivery/checkin/checkin.vue — GPS签到
**当前逻辑**:不存在。
**拟议改动**:三步签到流程。
```vue
📍 位置确认
经度 {{location.longitude}} 纬度 {{location.latitude}}
📷 现场拍照
✍️ 服务对象确认
{{ confirmed ? '已确认' : '请服务对象确认' }}
```
### 5.4 pages/delivery/execute/execute.vue — 服务执行
**当前逻辑**:不存在。
**拟议改动**:逐项执行记录 + 证据上传 + 时间记录。
核心逻辑:
1. `onLoad` → 调用 `/delivery/work-orders/{id}/detail` 获取工单项目列表
2. 每个项目显示:名称、标准时长、完成状态(PENDING / COMPLETED / SKIPPED)、证据要求
3. 点击项目 → 弹出执行记录面板:实际开始/结束时间、备注、拍照/录像证据
4. 实时显示已用时长计时器
5. 所有项目完成后可进入签退
### 5.5 pages/delivery/exception/exception.vue — 异常上报
**当前逻辑**:不存在。
**拟议改动**:一键选择异常类型 + 描述 + 证据上传。
异常类型列表(与后端 `ExceptionRequest.exceptionType` 对齐):
- `PATIENT_ABSENT` — 对象不在家
- `PATIENT_REFUSE` — 对象拒绝服务
- `WRONG_ADDRESS` — 地址错误
- `CANNOT_CONTACT` — 无法联系
- `CONDITION_NOT_READY` — 服务条件不具备
- `HEALTH_EMERGENCY` — 身体突发异常
- `QUALITY_DISPUTE` — 服务质量争议
### 5.6 pages/delivery/finish/finish.vue — 签退完成
**当前逻辑**:不存在。
**拟议改动**:服务总结 + 签退定位 + 提交完成。
核心逻辑:
1. 展示所有执行记录摘要
2. 输入服务总结
3. 获取签退位置
4. 调用 `POST /work-orders/{id}/finish`
5. 检查必做项目是否都已非 PENDING
### 5.7 pages/delivery/accept/accept.vue — 接单确认
**当前逻辑**:不存在。
**拟议改动**:展示工单基本信息 + 接单/拒单按钮。
### 5.8 pages/delivery/orders/orders.vue — 工单列表
**当前逻辑**:不存在。
**拟议改动**:按状态筛选 + 上拉加载更多。
### 5.9 pages/delivery/order-detail/order-detail.vue — 工单详情
**当前逻辑**:不存在。
**拟议改动**:调用 `/delivery/work-orders/{id}/detail`,展示完整信息 + 操作入口。
### 5.10 pages/delivery/offline-sync/offline-sync.vue — 离线补传
**当前逻辑**:不存在。
**拟议改动**:展示离线队列数量,一键补传,进度展示。
### 5.11 pages/delivery/messages/messages.vue — 消息通知
**当前逻辑**:不存在。
**拟议改动**:调用 `/delivery/messages`,展示消息列表,标记已读。
### 5.12 pages/delivery/profile/profile.vue — 我的资质
**当前逻辑**:不存在。
**拟议改动**:展示服务人员信息、资质列表、评分、工单统计。
---
## 6. 后端改动
### 6.1 GPS 轨迹端点(新增)
**文件**:`module/workorder/controller/GpsController.java`(新增)
```java
@RestController
@RequestMapping("/api/hss/gps")
@Tag(name = "GPS轨迹")
public class GpsController {
@PostMapping("/trajectory")
@PreAuthorize("hasRole('STAFF')")
public ApiResponse recordTrajectory(@RequestBody TrajectoryRequest req) {
jdbcTemplate.update(
"INSERT INTO hss_gps_trajectory (work_order_id, latitude, longitude, recorded_at, created_by) "
+ "VALUES (?, ?, ?, NOW(), ?)",
req.workOrderId(), req.latitude(), req.longitude(), RequestContext.getUserId());
return ApiResponse.ok(null);
}
public record TrajectoryRequest(Long workOrderId, Double latitude, Double longitude, String timestamp) {}
}
```
### 6.2 数据库(新增表)
```sql
CREATE TABLE IF NOT EXISTS hss_gps_trajectory (
id BIGSERIAL PRIMARY KEY,
work_order_id BIGINT NOT NULL,
latitude DECIMAL(10,7) NOT NULL,
longitude DECIMAL(10,7) NOT NULL,
recorded_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT
);
CREATE INDEX idx_hss_gps_wo ON hss_gps_trajectory(work_order_id);
```
### 6.3 EvidenceService 修复
**当前问题**:`commit()` 方法硬编码 `file_type = 'PHOTO'`,不支持 VIDEO/AUDIO。
**拟议改动**:从请求参数中读取 `fileType`。
---
## 7. 自测与回归
### 手动验证步骤
1. 登录 → 工作台 → 点击待接单 → 工单列表
2. 点击工单 → 工单详情(患者信息、服务项目、操作入口)
3. 点击接单 → 确认接单
4. 点击签到 → GPS定位 → 拍照 → 对象确认 → 提交签到
5. 点击开始服务
6. 逐项执行:每项记录时间+拍照/录像证据
7. (可选)上报异常
8. 点击完成 → 填写总结 → 签退定位 → 提交
9. 离线模式:断网操作 → 联网 → 离线补传
### 关键回归场景
- GPS 定位失败时显示友好提示
- 200 米外签到应被后端拒绝
- 必做项目未完成时 finish 应被拒绝
- 离线队列在网络恢复后能补传
- 微信小程序端 `#ifdef MP-WEIXIN` 条件编译生效
---
## 8. 短 Prompt
```txt
在 delivery-miniapp/ 中实现完整的服务人员移动作业端。
已有:login.vue + index.vue + common/api.js。
需要:10个缺失页面 + 扩展 api.js(GPS/拍照/上传/离线队列/水印/轨迹)。
技术栈:uni-app + Vue,微信小程序 + APP 双端条件编译。
后端 API 已就绪(work-orders/check-in/finish/report-exception + delivery/workbench/today/detail + evidence/presign-upload/commit)。
严格按 IMPLEMENTATION.md 实现,页面结构参考已有 login.vue 和 index.vue 的风格。
```