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,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>