核心修复: - 状态机加 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>
121 lines
7.8 KiB
Vue
121 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { usePlatformAuth } from '~/composables/usePlatformAuth'
|
|
const { isLoggedIn } = usePlatformAuth()
|
|
|
|
const orders = ref<any[]>([])
|
|
const loading = ref(true)
|
|
const statusFilter = ref('')
|
|
const dispatchModal = ref(false)
|
|
const dispatchWoId = ref(0)
|
|
const recommendations = ref<any[]>([])
|
|
const recLoading = ref(false)
|
|
|
|
const statusMap: Record<string,{label:string;cls:string}> = {
|
|
ORDER_CREATED: { label:'待派单', cls:'bg-gray-100 text-gray-600' },
|
|
ORDER_ASSIGNED: { label:'已派单', cls:'bg-blue-50 text-blue-600' },
|
|
ORDER_ACCEPTED: { label:'已接单', cls:'bg-indigo-50 text-indigo-600' },
|
|
ORDER_CHECKED_IN: { label:'已签到', cls:'bg-teal-50 text-teal-600' },
|
|
ORDER_IN_SERVICE: { label:'服务中', cls:'bg-accent-50 text-accent-700' },
|
|
ORDER_COMPLETED: { label:'已完成', cls:'bg-green-50 text-green-600' },
|
|
ORDER_EXCEPTION: { label:'异常', cls:'bg-red-50 text-red-600' },
|
|
ACCEPTED: { label:'已验收', cls:'bg-green-100 text-green-700' },
|
|
}
|
|
|
|
function getAuthHeaders(): Record<string,string> {
|
|
let role = 'ADMIN', uid = '1'
|
|
try { const u = JSON.parse(localStorage.getItem('hss_platform_user')||'{}'); role = u.userRole||role; uid = u.userId||uid } catch {}
|
|
return { 'Content-Type':'application/json','X-Tenant-Id':'1','X-Org-Id':'1','X-User-Role':role,'X-User-Id':uid }
|
|
}
|
|
|
|
async function apiFetch(path: string, opts: any = {}) {
|
|
const h = { ...getAuthHeaders(), 'Idempotency-Key': 'web-'+Date.now() }
|
|
return await $fetch<any>('/api/hss' + path, { ...opts, headers: { ...h, ...(opts.headers||{}) } })
|
|
}
|
|
|
|
async function loadOrders() {
|
|
try { const res = await apiFetch('/admin/work-orders?page=1&size=50'); orders.value = res?.data || [] } catch {}
|
|
}
|
|
|
|
async function doAction(id: number, action: string, body?: any) {
|
|
try { await apiFetch(`/work-orders/${id}/${action}`, { method:'POST', body: body ? JSON.stringify(body) : undefined }); await loadOrders() }
|
|
catch(e: any) { alert(e?.data?.message || e?.message || '操作失败') }
|
|
}
|
|
|
|
async function openDispatch(id: number) {
|
|
dispatchWoId.value = id; dispatchModal.value = true; recLoading.value = true; recommendations.value = []
|
|
try {
|
|
const res = await fetch('/api/hss/admin/work-orders/'+id+'/recommend-staff', { headers: getAuthHeaders() })
|
|
const data = await res.json()
|
|
recommendations.value = data?.data || []
|
|
} catch(e) {}
|
|
recLoading.value = false
|
|
}
|
|
|
|
async function doDispatch(staffId: number) {
|
|
await doAction(dispatchWoId.value, 'assign', { staffId, reason:'智能推荐派单' })
|
|
dispatchModal.value = false
|
|
}
|
|
|
|
onMounted(() => { loadOrders().finally(() => loading.value = false) })
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="loading" class="min-h-screen bg-surface flex items-center justify-center"><p class="text-text-secondary">加载中...</p></div>
|
|
<div v-else class="min-h-screen bg-surface flex">
|
|
<aside class="hidden lg:flex flex-col w-56 bg-white border-r shrink-0">
|
|
<div class="p-4 border-b"><a href="/platform" class="font-bold text-primary text-sm">← 返回工作台</a></div>
|
|
<div class="p-3 space-y-1">
|
|
<button v-for="s in ['', ...Object.keys(statusMap)]" :key="s" @click="statusFilter = s"
|
|
class="block w-full text-left px-3 py-2 rounded-lg text-xs transition-colors"
|
|
:class="statusFilter===s ? 'bg-primary-50 text-primary font-medium' : 'text-text-secondary hover:bg-gray-50'">
|
|
{{ s ? statusMap[s]?.label : '全部工单' }}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
<main class="flex-1 p-4 lg:p-8 overflow-auto">
|
|
<h2 class="text-xl font-bold mb-6">工单管理</h2>
|
|
<div class="bg-white rounded-2xl shadow-sm border overflow-hidden">
|
|
<table class="w-full text-sm"><thead><tr class="bg-gray-50 text-left text-xs text-text-secondary"><th class="px-4 py-3">ID</th><th class="px-4 py-3">日期</th><th class="px-4 py-3">对象</th><th class="px-4 py-3">状态</th><th class="px-4 py-3">人员</th><th class="px-4 py-3">操作</th></tr></thead>
|
|
<tbody><tr v-for="o in orders.filter((x:any) => !statusFilter || x.status===statusFilter)" :key="o.id" class="border-t border-gray-50 hover:bg-gray-50">
|
|
<td class="px-4 py-3 font-mono text-xs">#{{o.id}}</td><td class="px-4 py-3 text-xs">{{o.serviceDate}}</td><td class="px-4 py-3 text-xs">{{o.patientId}}</td>
|
|
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs font-medium" :class="statusMap[o.status]?.cls||'bg-gray-100'">{{statusMap[o.status]?.label||o.status}}</span></td>
|
|
<td class="px-4 py-3 text-xs">{{o.staffId||'-'}}</td>
|
|
<td class="px-4 py-3"><div class="flex gap-1 flex-wrap">
|
|
<button v-if="o.status==='ORDER_CREATED'" @click="openDispatch(o.id)" class="px-2 py-1 bg-blue-50 text-blue-600 rounded text-xs hover:bg-blue-100 font-medium">智能派单</button>
|
|
<button v-if="o.status==='ORDER_ASSIGNED'" @click="doAction(o.id,'accept')" class="px-2 py-1 bg-green-50 text-green-600 rounded text-xs hover:bg-green-100">接单</button>
|
|
<button v-if="o.status==='ORDER_ACCEPTED'" @click="doAction(o.id,'check-in',{latitude:24.2878,longitude:116.1271,photoFileId:'test',patientConfirmed:true})" class="px-2 py-1 bg-teal-50 text-teal-600 rounded text-xs hover:bg-teal-100">签到</button>
|
|
<button v-if="o.status==='ORDER_CHECKED_IN'" @click="doAction(o.id,'start-service')" class="px-2 py-1 bg-accent-50 text-accent-600 rounded text-xs hover:bg-accent-100">开始</button>
|
|
<button v-if="o.status==='ORDER_IN_SERVICE'" @click="doAction(o.id,'finish',{executionRecords:[{planItemId:1,status:'COMPLETED',notes:'完成'}],serviceSummary:'完成',signOffLatitude:24.2878,signOffLongitude:116.1271})" class="px-2 py-1 bg-green-50 text-green-600 rounded text-xs hover:bg-green-100">完成</button>
|
|
<button v-if="o.status==='ORDER_IN_SERVICE'" @click="doAction(o.id,'report-exception',{exceptionType:'PATIENT_ABSENT',description:'对象不在家'})" class="px-2 py-1 bg-red-50 text-red-600 rounded text-xs hover:bg-red-100">异常</button>
|
|
</div></td>
|
|
</tr></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Dispatch Modal -->
|
|
<div v-if="dispatchModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="dispatchModal=false">
|
|
<div class="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[80vh] overflow-auto">
|
|
<h3 class="font-bold text-lg mb-4">智能派单 — 工单 #{{ dispatchWoId }}</h3>
|
|
<div v-if="recLoading" class="text-center py-8 text-text-secondary">正在计算推荐...</div>
|
|
<div v-else-if="recommendations.length === 0" class="text-center py-8 text-text-secondary">暂无可推荐人员</div>
|
|
<div v-else class="space-y-3">
|
|
<div v-for="(r,i) in recommendations" :key="i" class="flex items-center gap-4 p-4 rounded-xl border hover:border-primary hover:bg-primary-50/30 transition-colors">
|
|
<span class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-50 to-accent-50 text-primary flex items-center justify-center font-bold text-sm shrink-0">{{ i+1 }}</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium">服务人员 #{{ r.staffId }}</div>
|
|
<div class="text-xs text-text-secondary mt-0.5">{{ (r.reasons||[]).join(' · ') }}</div>
|
|
</div>
|
|
<div class="text-right shrink-0">
|
|
<div class="text-lg font-bold font-mono text-primary">{{ r.score }}</div>
|
|
<button @click="doDispatch(r.staffId)" class="mt-1 px-4 py-1.5 bg-primary text-white rounded-lg text-xs font-medium hover:bg-primary-700">派单</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button @click="dispatchModal=false" class="w-full mt-4 py-2 border rounded-lg text-sm text-text-secondary">取消</button>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</template>
|