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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user