feat: 全系统优化 — 并发控制 + 冗余清理 + 数据流修复 + 全面测试

核心修复:
- 状态机加 SELECT FOR UPDATE 行锁,消除并发竞态
- hss_md_staff 加 role 列,登录从数据库读取真实角色
- 申请重复校验排除自身,全流程 20 步闭环通过
- 派单 SQL 修复 + 支付状态机过渡 + 完成服务 plan_item_id 修复

并发控制新增:
- RedisLockService (SET NX PX + Lua 安全解锁)
- RateLimiterService (Redis 滑动窗口 + API 拦截器)
- TransactionIsolationConfig (SERIALIZABLE for 支付回调)
- MqttPublisher (异步队列 + JDK TCP 探测)
- ObjectStorageService (AWS SigV4 预签名, 纯 JDK)

冗余清理:
- 删除 6 个死代码文件 (~620 行)
- hutool-all → JDK MessageDigest, 去 MapStruct, 去 jsr310
- haversine 提取到 GeoUtil, count/round 提取到 JdbcUtil
- 创建 platform layout 组件

前端修复:
- 登录页移除角色选择器, 由后端 JWT 返回
- 移除 ClientOnly 包裹, 页面正常渲染
- SPA fallback Nginx 配置修复

Docker: 运行时镜像 eclipse-temurin:17-jre-jammy (缩小 ~300MB)

文档: 新增系统实现与修复报告.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 11:48:07 +08:00
parent 7d92322b99
commit 01e1034cc1
387 changed files with 6220 additions and 12952 deletions

View File

@@ -0,0 +1,515 @@
# Delivery 端完整实现方案
> 基于 5 轮需求采集 + 代码审计
> 后端 API已有 86 个接口,`WorkOrderService` 的 checkIn/finish/reportException 已就绪
> 当前状态12 个页面路由中仅实现 2 个login + index10 个缺失
---
## 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` | checkInHaversine+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
<template>
<view class="page">
<!-- Step 1: GPS定位 -->
<view class="step">
<text class="step-title">📍 位置确认</text>
<text v-if="location" class="coords">经度 {{location.longitude}} 纬度 {{location.latitude}}</text>
<button v-else @click="getGPS" :loading="locating">获取当前位置</button>
</view>
<!-- Step 2: 现场拍照水印 -->
<view class="step">
<text class="step-title">📷 现场拍照</text>
<image v-if="photoPath" :src="photoPath" class="photo-preview" />
<button v-else @click="takePhoto">拍摄现场照片</button>
</view>
<!-- Step 3: 对象确认 -->
<view class="step">
<text class="step-title"> 服务对象确认</text>
<switch :checked="confirmed" @change="confirmed = $event.detail.value" />
<text>{{ confirmed ? '已确认' : '请服务对象确认' }}</text>
</view>
<!-- Submit -->
<button @click="submitCheckIn" :disabled="!canSubmit" :loading="submitting"
class="submit-btn">确认签到</button>
</view>
</template>
<script>
import { getLocation, chooseImage, uploadEvidence } from '@/common/api.js';
export default {
data() {
return {
workOrderId: 0,
location: null, locating: false,
photoPath: '', photoFileId: '',
confirmed: false, submitting: false
};
},
computed: {
canSubmit() { return this.location && this.photoPath && this.confirmed; }
},
async onLoad(options) { this.workOrderId = options.id; },
methods: {
async getGPS() {
this.locating = true;
try { this.location = await getLocation(); }
catch(e) { uni.showToast({ title: '获取位置失败', icon: 'none' }); }
finally { this.locating = false; }
},
async takePhoto() {
try {
const res = await chooseImage(1);
this.photoPath = res.tempFilePaths[0];
// Upload evidence
const ev = await uploadEvidence(this.photoPath, 'checkin', this.workOrderId);
this.photoFileId = ev.fileKey;
} catch(e) { uni.showToast({ title: '拍照失败', icon: 'none' }); }
},
async submitCheckIn() {
this.submitting = true;
try {
const { apiPost } = require('@/common/api.js');
const res = await apiPost(`/work-orders/${this.workOrderId}/check-in`, {
latitude: this.location.latitude,
longitude: this.location.longitude,
photoFileId: this.photoFileId,
patientConfirmed: this.confirmed
});
if (res.data.code === 200) {
uni.showToast({ title: '签到成功' });
uni.navigateBack();
} else {
uni.showToast({ title: res.data.message, icon: 'none' });
}
} catch(e) { uni.showToast({ title: '网络错误', icon: 'none' }); }
finally { this.submitting = false; }
}
}
};
</script>
```
### 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<Void> 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.jsGPS/拍照/上传/离线队列/水印/轨迹)。
技术栈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 的风格。
```

View File

@@ -1,4 +1,6 @@
const BASE_URL = 'http://localhost:18080/api/hss';
// BASE_URL configured via manifest.json or build-time env
// For production, update this to your actual server URL
const BASE_URL = 'http://172.31.12.249:18080/api/hss';
function getHeaders() {
const token = uni.getStorageSync('token');
@@ -23,4 +25,80 @@ function apiPost(path, data = {}) {
return uni.request({ url: BASE_URL + path, method: 'POST', data, header: headers });
}
module.exports = { BASE_URL, apiGet, apiPost, generateIdempotencyKey };
// ==================== GPS ====================
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
});
}
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
});
});
}
// ==================== 文件上传 ====================
async function uploadEvidence(filePath, entityType, entityId) {
const fileName = filePath.split('/').pop() || 'photo.jpg';
const ft = fileName.endsWith('.mp4') ? 'VIDEO' : fileName.endsWith('.mp3') ? 'AUDIO' : 'PHOTO';
const presignRes = await apiPost('/evidence/presign-upload', { fileName, fileType: ft, entityType, entityId });
if (presignRes.data.code !== 200) throw new Error('PreSign failed');
const { fileKey } = presignRes.data.data;
return new Promise((resolve, reject) => {
uni.uploadFile({
url: BASE_URL + '/evidence/commit',
filePath, name: 'file',
formData: { fileKey, fileName, fileHash: '', fileSize: '0', contentType: 'image/jpeg' },
header: getHeaders(),
success: (r) => {
try { resolve({ fileKey, response: JSON.parse(r.data) }); }
catch (e) { resolve({ fileKey, response: r.data }); }
},
fail: reject
});
});
}
module.exports = {
addWatermark,
BASE_URL, apiGet, apiPost, generateIdempotencyKey,
getLocation, startTrajectory, stopTrajectory,
chooseImage, uploadEvidence,
};

View File

@@ -0,0 +1,36 @@
const OFFLINE_QUEUE_KEY = 'hss_offline_queue';
class OfflineQueue {
constructor() {
try { this.queue = uni.getStorageSync(OFFLINE_QUEUE_KEY) || []; }
catch (e) { this.queue = []; }
}
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 && (res.data.code === 200 || res.data.code === '200')) continue;
failed.push(item);
} catch (e) { failed.push(item); }
}
this.queue = failed;
this.save();
return { success: failed.length === 0, total: this.getCount(), remaining: failed.length };
}
clear() { this.queue = []; this.save(); }
}
module.exports = { OfflineQueue };

View File

@@ -29,7 +29,14 @@
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>"
]
},
"ios": {}
"ios": {
"app-plus": {
"plists": {
"NSLocationWhenInUseUsageDescription": "需要获取位置进行GPS签到和轨迹记录",
"NSCameraUsageDescription": "需要拍照记录服务现场情况"
}
}
}
}
},
"quickapp": {},

View File

@@ -0,0 +1,56 @@
<template>
<view class="page">
<view class="card"><text class="title">工单 #{{ workOrderId }}</text><text class="info">请在确认接单前查看服务详情</text></view>
<view class="actions">
<button @click="doAccept" :loading="loading" class="btn-accept">确认接单</button>
<button @click="showReject = true" class="btn-reject">拒单</button>
</view>
<view v-if="showReject" class="card">
<text class="label">拒单原因</text>
<textarea v-model="reason" placeholder="请填写拒单原因" class="textarea" />
<button @click="doReject" :loading="loading" class="btn-reject">确认拒单</button>
</view>
</view>
</template>
<script>
import { apiPost } from '@/common/api.js';
export default {
data() { return { workOrderId: 0, loading: false, showReject: false, reason: '' }; },
onLoad(options) { this.workOrderId = parseInt(options.id) || 0; },
methods: {
async doAccept() {
this.loading = true;
try {
const res = await apiPost('/work-orders/' + this.workOrderId + '/accept');
if (res.data.code === 200) { uni.showToast({ title: '接单成功' }); setTimeout(() => uni.navigateBack(), 800); }
else { uni.showToast({ title: res.data.message, icon: 'none' }); }
} catch(e) { uni.showToast({ title: '网络错误', icon: 'none' }); }
this.loading = false;
},
async doReject() {
if (!this.reason.trim()) { uni.showToast({ title: '请填写拒单原因', icon: 'none' }); return; }
this.loading = true;
try {
const res = await apiPost('/work-orders/' + this.workOrderId + '/reject', { reason: this.reason });
if (res.data.code === 200) { uni.showToast({ title: '已拒单' }); setTimeout(() => uni.navigateBack(), 800); }
else { uni.showToast({ title: res.data.message, icon: 'none' }); }
} catch(e) { uni.showToast({ title: '网络错误', icon: 'none' }); }
this.loading = false;
}
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.card { background: #fff; border-radius: 16rpx; padding: 32rpx; margin-bottom: 20rpx; }
.title { font-size: 34rpx; font-weight: 700; display: block; margin-bottom: 8rpx; }
.info { font-size: 26rpx; color: #999; }
.actions { display: flex; gap: 20rpx; margin-bottom: 20rpx; }
.btn-accept { flex: 1; background: #155EEF; color: #fff; border-radius: 12rpx; padding: 24rpx; font-size: 30rpx; font-weight: 600; }
.btn-reject { flex: 1; background: #fff; color: #FF4D4F; border: 1px solid #FF4D4F; border-radius: 12rpx; padding: 24rpx; font-size: 30rpx; }
.label { font-size: 26rpx; color: #666; display: block; margin-bottom: 12rpx; }
.textarea { width: 100%; height: 150rpx; border: 1px solid #ddd; border-radius: 12rpx; padding: 16rpx; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,108 @@
<template>
<view class="page">
<view class="step-card">
<text class="step-title">📍 位置确认</text>
<view v-if="location" class="info-row">
<text class="label">经度</text><text class="value">{{ location.longitude.toFixed(6) }}</text>
<text class="label">纬度</text><text class="value">{{ location.latitude.toFixed(6) }}</text>
</view>
<button v-else @click="getGPS" :loading="locating" class="btn-outline">获取当前位置</button>
</view>
<view class="step-card">
<text class="step-title">📷 现场拍照</text>
<image v-if="photoPath" :src="photoPath" class="photo" mode="aspectFill" />
<button v-else @click="takePhoto" class="btn-outline">拍摄现场照片</button>
</view>
<view class="step-card">
<text class="step-title"> 服务对象确认</text>
<switch :checked="confirmed" @change="confirmed = $event.detail.value" color="#155EEF" />
<text class="switch-label">{{ confirmed ? '已确认' : '请服务对象确认签到' }}</text>
</view>
<button @click="submitCheckIn" :disabled="!canSubmit" :loading="submitting" class="btn-primary">
{{ submitting ? '签到中...' : '确认签到' }}
</button>
</view>
</template>
<script>
import { getLocation, chooseImage, uploadEvidence, apiPost, addWatermark } from '@/common/api.js';
import { OfflineQueue } from '@/common/offline.js';
const offline = new OfflineQueue();
export default {
data() {
return {
workOrderId: 0, location: null, locating: false,
photoPath: '', photoFileId: '', confirmed: false, submitting: false
};
},
computed: {
canSubmit() { return this.location && this.photoPath && this.confirmed; }
},
onLoad(options) { this.workOrderId = parseInt(options.id) || 0; },
methods: {
async getGPS() {
this.locating = true;
try { this.location = await getLocation(); }
catch(e) { uni.showToast({ title: '获取位置失败请确认已授权GPS权限', icon: 'none', duration: 3000 }); }
finally { this.locating = false; }
},
async takePhoto() {
try {
const res = await chooseImage(1);
this.photoPath = res.tempFilePaths[0];
uni.showLoading({ title: '上传中' });
// 添加水印(时间+地点+工单号)
try { this.photoPath = await addWatermark(this.photoPath, { workOrderId: this.workOrderId, address: '服务地址' }); } catch(e) {}
const ev = await uploadEvidence(this.photoPath, 'checkin', this.workOrderId);
this.photoFileId = ev.fileKey;
uni.hideLoading();
} catch(e) {
uni.hideLoading();
uni.showToast({ title: '拍照或上传失败', icon: 'none' });
}
},
async submitCheckIn() {
this.submitting = true;
const payload = {
latitude: this.location.latitude,
longitude: this.location.longitude,
photoFileId: this.photoFileId,
patientConfirmed: this.confirmed
};
try {
const res = await apiPost('/work-orders/' + this.workOrderId + '/check-in', payload);
if (res.data.code === 200) {
uni.showToast({ title: '签到成功' });
startTrajectory(this.workOrderId);
setTimeout(() => uni.navigateBack(), 1000);
} else {
uni.showToast({ title: res.data.message, icon: 'none' });
}
} catch(e) {
offline.add({ path: '/work-orders/' + this.workOrderId + '/check-in', data: payload });
uni.showToast({ title: '网络异常,已缓存待补传', icon: 'none' });
}
this.submitting = false;
}
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.step-card { background: #fff; border-radius: 16rpx; padding: 32rpx; margin-bottom: 20rpx; }
.step-title { font-size: 32rpx; font-weight: 700; display: block; margin-bottom: 16rpx; }
.info-row { display: flex; gap: 16rpx; flex-wrap: wrap; }
.label { font-size: 26rpx; color: #999; }
.value { font-size: 26rpx; color: #333; font-family: monospace; }
.photo { width: 100%; height: 400rpx; border-radius: 12rpx; }
.switch-label { margin-left: 16rpx; font-size: 28rpx; color: #666; }
.btn-outline { border: 1px solid #155EEF; color: #155EEF; background: #fff; border-radius: 12rpx; padding: 20rpx; font-size: 28rpx; }
.btn-primary { background: #155EEF; color: #fff; border-radius: 12rpx; padding: 24rpx; font-size: 32rpx; font-weight: 600; margin-top: 32rpx; }
.btn-primary[disabled] { opacity: 0.4; }
</style>

View File

@@ -0,0 +1,48 @@
<template>
<view class="page">
<text class="title">异常上报</text>
<view class="types">
<view v-for="t in exceptionTypes" :key="t.value" @click="selected = t.value"
:class="['type-item', selected === t.value ? 'active' : '']">{{ t.label }}</view>
</view>
<textarea v-model="desc" placeholder="请描述异常情况" class="textarea" />
<button @click="submit" :loading="submitting" :disabled="!selected" class="btn">提交异常</button>
</view>
</template>
<script>
import { apiPost } from '@/common/api.js';
const exceptionTypes = [
{ value: 'PATIENT_ABSENT', label: '对象不在家' }, { value: 'PATIENT_REFUSE', label: '对象拒绝服务' },
{ value: 'WRONG_ADDRESS', label: '地址错误' }, { value: 'CANNOT_CONTACT', label: '无法联系' },
{ value: 'CONDITION_NOT_READY', label: '服务条件不具备' }, { value: 'HEALTH_EMERGENCY', label: '身体突发异常' },
];
export default {
data() { return { workOrderId: 0, selected: '', desc: '', submitting: false, exceptionTypes }; },
onLoad(options) { this.workOrderId = parseInt(options.id) || 0; },
methods: {
async submit() {
this.submitting = true;
try {
const res = await apiPost('/work-orders/' + this.workOrderId + '/report-exception', { exceptionType: this.selected, description: this.desc });
if (res.data.code === 200) { uni.showToast({ title: '异常已上报' }); setTimeout(() => uni.navigateBack(), 800); }
else { uni.showToast({ title: res.data.message, icon: 'none' }); }
} catch(e) { uni.showToast({ title: '网络错误', icon: 'none' }); }
this.submitting = false;
}
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.title { font-size: 36rpx; font-weight: 700; display: block; margin-bottom: 24rpx; }
.types { display: flex; flex-wrap: wrap; gap: 16rpx; margin-bottom: 24rpx; }
.type-item { padding: 20rpx 28rpx; border-radius: 12rpx; background: #fff; border: 1px solid #ddd; font-size: 28rpx; }
.type-item.active { background: #FF4D4F; color: #fff; border-color: #FF4D4F; }
.textarea { width: 100%; height: 200rpx; border: 1px solid #ddd; border-radius: 12rpx; padding: 16rpx; font-size: 28rpx; margin-bottom: 24rpx; background: #fff; }
.btn { background: #FF4D4F; color: #fff; border-radius: 12rpx; padding: 24rpx; font-size: 32rpx; font-weight: 600; }
.btn[disabled] { opacity: 0.4; }
</style>

View File

@@ -0,0 +1,80 @@
<template>
<view class="page">
<view class="header"><text class="title">服务执行</text><text class="sub">逐项记录完成情况与证据</text></view>
<view v-for="(item, i) in items" :key="i" class="item-card">
<view class="item-header">
<text class="item-name">{{ item.item_name }}</text>
<text :class="['status', item.status === 'COMPLETED' ? 'done' : item.status === 'PENDING' ? 'pending' : '']">
{{ item.status === 'COMPLETED' ? '✓ 已完成' : item.status === 'SKIPPED' ? '已跳过' : '待执行' }}
</text>
</view>
<view v-if="item.evidence_required" class="tag">需拍照留证</view>
<view v-if="item.status !== 'COMPLETED'" class="actions">
<button @click="recordItem(item, 'COMPLETED')" class="btn-sm done-btn">标记完成</button>
<button @click="recordItem(item, 'SKIPPED')" class="btn-sm skip-btn">跳过</button>
</view>
<view v-if="item.photoPath" class="evidence">
<image :src="item.photoPath" class="ev-photo" mode="aspectFill" />
</view>
</view>
<view v-if="allDone" class="tip"><text>所有项目已完成可前往签退</text></view>
</view>
</template>
<script>
import { apiGet, apiPost, chooseImage, uploadEvidence } from '@/common/api.js';
export default {
data() { return { workOrderId: 0, items: [] }; },
computed: {
allDone() { return this.items.length > 0 && this.items.every(i => i.status === 'COMPLETED' || i.status === 'SKIPPED'); }
},
async onLoad(options) {
this.workOrderId = parseInt(options.id) || 0;
try {
const res = await apiGet('/delivery/work-orders/' + this.workOrderId + '/detail');
if (res.data.code === 200) {
this.items = (res.data.data.items || []).map(i => ({ ...i, photoPath: '' }));
}
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }); }
},
methods: {
async recordItem(item, status) {
if (item.evidence_required && status === 'COMPLETED') {
try {
const img = await chooseImage(1);
uni.showLoading({ title: '上传证据' });
const ev = await uploadEvidence(img.tempFilePaths[0], 'execution', this.workOrderId);
item.photoPath = img.tempFilePaths[0];
uni.hideLoading();
} catch(e) { uni.hideLoading(); uni.showToast({ title: '请拍照留证', icon: 'none' }); return; }
}
item.status = status;
// 持久化到后端
try {
await apiPost('/work-orders/' + this.workOrderId + '/execution-records', { planItemId: item.id, status, notes: '' });
} catch(e) { try { const { OfflineQueue } = require('@/common/offline.js'); new OfflineQueue().add({ path: '/work-orders/' + this.workOrderId + '/execution-records', data: { planItemId: item.id, status, notes: '' } }); } catch(_) {} }
uni.showToast({ title: status === 'COMPLETED' ? '已标记完成' : '已跳过', icon: 'success' });
}
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.header { margin-bottom: 24rpx; } .title { font-size: 36rpx; font-weight: 700; display: block; } .sub { font-size: 26rpx; color: #999; }
.item-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
.item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.item-name { font-size: 30rpx; font-weight: 600; }
.status { font-size: 24rpx; padding: 4rpx 12rpx; border-radius: 8rpx; }
.status.done { background: #E6F9F5; color: #18A999; }
.status.pending { background: #FFF3E0; color: #FF8A3D; }
.tag { font-size: 22rpx; color: #FF8A3D; background: #FFF3E0; display: inline-block; padding: 4rpx 12rpx; border-radius: 4rpx; margin-bottom: 12rpx; }
.actions { display: flex; gap: 16rpx; }
.btn-sm { flex: 1; padding: 16rpx; border-radius: 10rpx; font-size: 26rpx; }
.done-btn { background: #E6F9F5; color: #18A999; border: 1px solid #18A999; }
.skip-btn { background: #F5F5F5; color: #999; border: 1px solid #ddd; }
.ev-photo { width: 200rpx; height: 200rpx; border-radius: 12rpx; margin-top: 12rpx; }
.tip { text-align: center; padding: 32rpx; color: #18A999; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,65 @@
<template>
<view class="page">
<text class="title">签退完成</text>
<view class="card"><text class="label">执行摘要</text><text class="val">{{ completedCount }} / {{ totalCount }} 项已完成</text></view>
<textarea v-model="summary" placeholder="服务总结(选填)" class="textarea" />
<view class="card"><text class="label">签退定位</text><text v-if="loc" class="val">经度 {{ loc.longitude.toFixed(6) }} 纬度 {{ loc.latitude.toFixed(6) }}</text></view>
<button @click="submit" :loading="submitting" class="btn">提交完成</button>
</view>
</template>
<script>
import { apiPost, getLocation, apiGet } from '@/common/api.js';
import { stopTrajectory } from '@/common/api.js';
import { OfflineQueue } from '@/common/offline.js';
const offline = new OfflineQueue();
export default {
data() { return { workOrderId: 0, summary: '', loc: null, submitting: false, items: [] }; },
computed: {
totalCount() { return this.items.length; },
completedCount() { return this.items.filter(i => i.status !== 'PENDING').length; }
},
async onLoad(options) {
this.workOrderId = parseInt(options.id) || 0;
try {
const res = await apiGet('/delivery/work-orders/' + this.workOrderId + '/detail');
this.items = res.data?.data?.items || [];
} catch(e) {}
try { this.loc = await getLocation(); } catch(e) {}
},
methods: {
async submit() {
stopTrajectory();
this.submitting = true;
const records = this.items.filter(i => i.status !== 'PENDING').map(i => ({ planItemId: i.id, status: i.status, notes: '' }));
const payload = {
executionRecords: records,
serviceSummary: this.summary,
signOffLatitude: this.loc?.latitude || 0,
signOffLongitude: this.loc?.longitude || 0
};
try {
const res = await apiPost('/work-orders/' + this.workOrderId + '/finish', payload);
if (res.data.code === 200) { uni.showToast({ title: '服务完成' }); setTimeout(() => uni.navigateBack(), 800); }
else { uni.showToast({ title: res.data.message, icon: 'none' }); }
} catch(e) {
offline.add({ path: '/work-orders/' + this.workOrderId + '/finish', data: payload });
uni.showToast({ title: '已缓存,待补传', icon: 'none' });
}
this.submitting = false;
}
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.title { font-size: 36rpx; font-weight: 700; display: block; margin-bottom: 24rpx; }
.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
.label { font-size: 26rpx; color: #999; display: block; margin-bottom: 8rpx; }
.val { font-size: 28rpx; color: #333; }
.textarea { width: 100%; height: 150rpx; border: 1px solid #ddd; border-radius: 12rpx; padding: 16rpx; font-size: 28rpx; margin-bottom: 24rpx; background: #fff; }
.btn { background: #155EEF; color: #fff; border-radius: 12rpx; padding: 24rpx; font-size: 32rpx; font-weight: 600; }
</style>

View File

@@ -0,0 +1,34 @@
<template>
<view class="page">
<text class="title">消息通知</text>
<view v-for="m in messages" :key="m.id" class="msg-card" @click="markRead(m.id)">
<text class="event">{{ eventMap[m.event_type] || m.event_type }}</text>
<text class="time">{{ m.created_at?.substring(0,16) || '' }}</text>
</view>
<view v-if="messages.length === 0" class="empty">暂无消息</view>
</view>
</template>
<script>
import { apiGet, apiPost } from '@/common/api.js';
export default {
data() { return { messages: [], eventMap: {
work_order_assigned: '新工单已派单', work_order_completed: '工单已完成', work_order_exception: '工单异常',
application_submitted: '申请已提交', plan_pending_sign: '方案待签署', settlement_paid: '结算已支付'
}}},
async onShow() { try { const res = await apiGet('/delivery/messages'); this.messages = res.data?.data || []; } catch(e) {} },
methods: {
async markRead(id) { try { await apiPost('/delivery/messages/' + id + '/read'); } catch(e) {} }
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.title { font-size: 36rpx; font-weight: 700; display: block; margin-bottom: 24rpx; }
.msg-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 12rpx; display: flex; justify-content: space-between; }
.event { font-size: 28rpx; font-weight: 500; }
.time { font-size: 24rpx; color: #999; }
.empty { text-align: center; padding: 100rpx; color: #999; }
</style>

View File

@@ -0,0 +1,39 @@
<template>
<view class="page">
<text class="title">离线补传</text>
<view class="card"><text class="count">{{ count }}</text><text class="label">条待补传</text></view>
<button @click="doSync" :loading="syncing" :disabled="count === 0" class="btn">{{ count === 0 ? '无待补传' : '开始补传' }}</button>
<view v-if="result" class="result"><text :class="result.success ? 'green' : 'red'">{{ result.success ? ' 全部补传成功' : '剩余 ' + result.remaining + ' 条失败' }}</text></view>
</view>
</template>
<script>
import { apiPost } from '@/common/api.js';
import { OfflineQueue } from '@/common/offline.js';
const queue = new OfflineQueue();
export default {
data() { return { count: 0, syncing: false, result: null }; },
onShow() { this.count = queue.getCount(); },
methods: {
async doSync() {
this.syncing = true; this.result = null;
this.result = await queue.retry(apiPost);
this.count = queue.getCount();
this.syncing = false;
}
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; text-align: center; }
.title { font-size: 36rpx; font-weight: 700; display: block; margin-bottom: 32rpx; }
.card { background: #fff; border-radius: 16rpx; padding: 48rpx; margin-bottom: 32rpx; }
.count { font-size: 72rpx; font-weight: 800; color: #FF8A3D; display: block; }
.label { font-size: 28rpx; color: #999; margin-top: 8rpx; }
.btn { background: #155EEF; color: #fff; border-radius: 12rpx; padding: 24rpx; font-size: 32rpx; }
.btn[disabled] { opacity: 0.4; }
.result { margin-top: 32rpx; font-size: 28rpx; } .green { color: #18A999; } .red { color: #FF4D4F; }
</style>

View File

@@ -0,0 +1,57 @@
<template>
<view class="page">
<view v-if="loading" class="loading">加载中...</view>
<block v-else>
<view class="card"><text class="card-title">服务对象</text><text class="name">{{ detail.patient_name || '-' }}</text><text class="addr">{{ detail.patient_address || '-' }}</text></view>
<view class="card"><text class="card-title">服务时间</text><text>{{ detail.time_window_start }} - {{ detail.time_window_end }}</text></view>
<view class="card"><text class="card-title">风险等级</text><text>{{ detail.risk_level || '低' }}</text></view>
<view class="card"><text class="card-title">服务项目</text>
<view v-for="item in detail.items" :key="item.id" class="item"><text>{{ item.item_name }}</text><text class="req">{{ item.required ? '必做' : '选做' }}</text></view>
</view>
<view class="actions">
<button v-if="detail.status === 'ORDER_ASSIGNED'" @click="navTo('/pages/delivery/accept/accept?id=' + detail.id)" class="btn">接单</button>
<button v-if="detail.status === 'ORDER_ACCEPTED'" @click="navTo('/pages/delivery/checkin/checkin?id=' + detail.id)" class="btn">GPS签到</button>
<button v-if="detail.status === 'ORDER_CHECKED_IN'" @click="doStart" class="btn">开始服务</button>
<button v-if="detail.status === 'ORDER_IN_SERVICE'" @click="navTo('/pages/delivery/execute/execute?id=' + detail.id)" class="btn">服务执行</button>
<button v-if="detail.status === 'ORDER_IN_SERVICE'" @click="navTo('/pages/delivery/exception/exception?id=' + detail.id)" class="btn-outline">上报异常</button>
<button v-if="detail.status === 'ORDER_IN_SERVICE'" @click="navTo('/pages/delivery/finish/finish?id=' + detail.id)" class="btn">签退完成</button>
</view>
</block>
</view>
</template>
<script>
import { apiGet, apiPost } from '@/common/api.js';
export default {
data() { return { detail: {}, loading: true }; },
async onLoad(options) {
const id = parseInt(options.id) || 0;
try { const res = await apiGet('/delivery/work-orders/' + id + '/detail'); this.detail = res.data?.data || {}; }
catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }); }
this.loading = false;
},
methods: {
navTo(url) { uni.navigateTo({ url }); },
async doStart() {
try {
const res = await apiPost('/work-orders/' + this.detail.id + '/start-service');
if (res.data.code === 200) { uni.showToast({ title: '已开始服务' }); this.detail.status = 'ORDER_IN_SERVICE'; }
} catch(e) { uni.showToast({ title: '网络错误', icon: 'none' }); }
}
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
.card-title { font-size: 24rpx; color: #999; display: block; margin-bottom: 8rpx; }
.name { font-size: 32rpx; font-weight: 700; display: block; }
.addr { font-size: 26rpx; color: #666; margin-top: 4rpx; }
.item { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1px solid #f0f0f0; }
.req { font-size: 22rpx; color: #FF8A3D; background: #FFF3E0; padding: 2rpx 10rpx; border-radius: 4rpx; }
.actions { display: flex; flex-direction: column; gap: 16rpx; margin-top: 24rpx; }
.btn { background: #155EEF; color: #fff; border-radius: 12rpx; padding: 24rpx; font-size: 30rpx; font-weight: 600; text-align: center; }
.btn-outline { background: #fff; color: #FF4D4F; border: 1px solid #FF4D4F; border-radius: 12rpx; padding: 24rpx; font-size: 30rpx; text-align: center; }
.loading { text-align: center; padding: 100rpx; color: #999; }
</style>

View File

@@ -0,0 +1,49 @@
<template>
<view class="page">
<view class="tabs">
<view v-for="t in tabs" :key="t.key" @click="activeTab = t.key"
:class="['tab', activeTab === t.key ? 'active' : '']">{{ t.label }}</view>
</view>
<view v-if="loading" class="loading">加载中...</view>
<view v-for="o in filteredOrders" :key="o.id" class="order-card" @click="navToDetail(o.id)">
<view class="top"><text class="name">{{ o.patient_name || '服务对象' }}</text><text :class="['status', statusCls(o.status)]">{{ statusMap[o.status] || o.status }}</text></view>
<text class="time">{{ o.time_window_start }} - {{ o.time_window_end }}</text>
</view>
<view v-if="!loading && filteredOrders.length === 0" class="empty">暂无工单</view>
</view>
</template>
<script>
import { apiGet } from '@/common/api.js';
const statusMap = { ORDER_CREATED:'待派单', ORDER_ASSIGNED:'待接单', ORDER_ACCEPTED:'待签到', ORDER_CHECKED_IN:'待服务', ORDER_IN_SERVICE:'服务中', ORDER_COMPLETED:'已完成', ORDER_EXCEPTION:'异常', ACCEPTED:'已验收' };
const tabs = [
{ key:'all', label:'全部' }, { key:'ORDER_ASSIGNED', label:'待接单' }, { key:'ORDER_ACCEPTED', label:'待签到' },
{ key:'ORDER_IN_SERVICE', label:'服务中' }, { key:'ORDER_COMPLETED', label:'已完成' },
];
export default {
data() { return { orders: [], loading: true, activeTab: 'all', statusMap, tabs }; },
computed: {
filteredOrders() { return this.activeTab === 'all' ? this.orders : this.orders.filter(o => o.status === this.activeTab); }
},
async onShow() { this.loading = true; try { const res = await apiGet('/delivery/work-orders/today'); this.orders = res.data?.data || []; } catch(e) {} this.loading = false; },
methods: {
statusCls(s) { return s === 'ORDER_EXCEPTION' ? 'red' : s === 'ORDER_COMPLETED' || s === 'ACCEPTED' ? 'green' : ''; },
navToDetail(id) { uni.navigateTo({ url: '/pages/delivery/order-detail/order-detail?id=' + id }); }
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.tabs { display: flex; gap: 8rpx; margin-bottom: 20rpx; overflow-x: auto; white-space: nowrap; }
.tab { padding: 12rpx 24rpx; border-radius: 20rpx; font-size: 24rpx; background: #fff; border: 1px solid #ddd; }
.tab.active { background: #155EEF; color: #fff; border-color: #155EEF; }
.order-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
.top { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
.name { font-size: 30rpx; font-weight: 600; }
.status { font-size: 24rpx; } .status.green { color: #18A999; } .status.red { color: #FF4D4F; }
.time { font-size: 24rpx; color: #999; }
.empty, .loading { text-align: center; padding: 80rpx; color: #999; }
</style>

View File

@@ -0,0 +1,45 @@
<template>
<view class="page">
<view class="header"><view class="avatar">👤</view><text class="name">{{ userName }}</text><text class="role">服务人员</text></view>
<view class="stats">
<view class="stat"><text class="num">{{ stats.todayOrders || 0 }}</text><text class="lbl">今日工单</text></view>
<view class="stat"><text class="num">{{ stats.completedToday || 0 }}</text><text class="lbl">已完成</text></view>
<view class="stat"><text class="num red">{{ stats.exceptionCount || 0 }}</text><text class="lbl">异常</text></view>
</view>
<button @click="logout" class="btn-logout">退出登录</button>
</view>
</template>
<script>
import { apiGet } from '@/common/api.js';
export default {
data() { return { userName: '', stats: {} }; },
async onShow() {
try {
const u = uni.getStorageSync('userInfo');
this.userName = u?.userName || u?.name || '服务人员';
const res = await apiGet('/delivery/workbench');
this.stats = res.data?.data || {};
} catch(e) {}
},
methods: {
logout() {
uni.removeStorageSync('token'); uni.removeStorageSync('userInfo');
uni.reLaunch({ url: '/pages/delivery/login/login' });
}
}
};
</script>
<style>
.page { padding: 24rpx; background: #F8F8F8; min-height: 100vh; }
.header { text-align: center; padding: 48rpx 0; }
.avatar { font-size: 80rpx; margin-bottom: 16rpx; }
.name { font-size: 36rpx; font-weight: 700; display: block; }
.role { font-size: 26rpx; color: #999; }
.stats { display: flex; gap: 16rpx; margin-bottom: 32rpx; }
.stat { flex: 1; background: #fff; border-radius: 16rpx; padding: 32rpx; text-align: center; }
.num { font-size: 48rpx; font-weight: 800; display: block; color: #155EEF; } .num.red { color: #FF4D4F; }
.lbl { font-size: 24rpx; color: #999; margin-top: 4rpx; }
.btn-logout { background: #fff; color: #FF4D4F; border: 1px solid #FF4D4F; border-radius: 12rpx; padding: 24rpx; font-size: 30rpx; }
</style>