Files
medical-mall/pages/mall/admin/setting/delivery/management/components/DriverDetailDrawer.uvue

621 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="visible" class="drawer-mask" :class="{ closing: isClosing }" @click="close">
<view class="drawer-container" :class="{ closing: isClosing }" @click.stop>
<!-- 头部 -->
<view class="drawer-header">
<text class="drawer-title">配送员详情</text>
<view class="close-btn" @click="close">
<text class="close-icon">×</text>
</view>
</view>
<!-- 内容 -->
<scroll-view class="drawer-body" scroll-y>
<!-- 加载中 -->
<view v-if="detailLoading" class="drawer-center">
<text class="tip-text">加载中...</text>
</view>
<!-- 加载失败 -->
<view v-else-if="detailError !== ''" class="drawer-center">
<text class="tip-text-err">{{ detailError }}</text>
</view>
<!-- 内容区域 -->
<view v-else-if="detail != null" class="detail-content">
<!-- 顶部头像卡片 -->
<view class="avatar-card">
<image class="detail-avatar" :src="detail.avatarUrl" mode="aspectFill" />
<view class="avatar-info">
<text class="detail-name">{{ detail.realName }}</text>
<view class="badge-row">
<text class="badge" :class="detail.statusCls">{{ detail.statusLabel }}</text>
<text class="badge badge-work" :class="detail.workStatusCls">{{ detail.workStatusLabel }}</text>
</view>
</view>
</view>
<!-- 基本信息 -->
<view class="section-card">
<view class="section-title">
<view class="blue-bar"></view>
<text class="section-name">基本信息</text>
</view>
<view class="info-grid">
<view class="info-item">
<text class="info-label">真实姓名:</text>
<text class="info-value">{{ detail.realName }}</text>
</view>
<view class="info-item">
<text class="info-label">手机号码:</text>
<text class="info-value">{{ detail.phone }}</text>
</view>
<view class="info-item">
<text class="info-label">注册账号:</text>
<text class="info-value">{{ detail.username }}</text>
</view>
<view class="info-item">
<text class="info-label">ID</text>
<text class="info-value info-id">{{ detail.id }}</text>
</view>
</view>
</view>
<!-- 证件信息 -->
<view class="section-card">
<view class="section-title">
<view class="blue-bar"></view>
<text class="section-name">证件信息</text>
</view>
<view class="info-grid">
<view class="info-item">
<text class="info-label">身份证号:</text>
<text class="info-value">{{ detail.idCardMasked }}</text>
</view>
<view class="info-item">
<text class="info-label">驾驶证号:</text>
<text class="info-value">{{ detail.driverLicense }}</text>
</view>
</view>
</view>
<!-- 车辆信息 -->
<view class="section-card">
<view class="section-title">
<view class="blue-bar"></view>
<text class="section-name">车辆信息</text>
</view>
<view class="info-grid">
<view class="info-item">
<text class="info-label">车辆类型:</text>
<text class="info-value">{{ detail.vehicleTypeLabel }}</text>
</view>
<view class="info-item">
<text class="info-label">车牌号码:</text>
<text class="info-value">{{ detail.vehicleNumber }}</text>
</view>
<view class="info-item full">
<text class="info-label">服务区域:</text>
<text class="info-value">{{ detail.serviceAreaStr }}</text>
</view>
</view>
</view>
<!-- 绩效数据 -->
<view class="section-card">
<view class="section-title">
<view class="blue-bar"></view>
<text class="section-name">绩效数据</text>
</view>
<view class="kpi-row">
<view class="kpi-item">
<text class="kpi-value">{{ detail.orderCount }}</text>
<text class="kpi-label">接单总数</text>
</view>
<view class="kpi-item">
<text class="kpi-value">{{ detail.ratingAvg }}</text>
<text class="kpi-label">平均评分</text>
</view>
<view class="kpi-item">
<text class="kpi-value">{{ detail.ratingCount }}</text>
<text class="kpi-label">评价次数</text>
</view>
</view>
</view>
<!-- 时间信息 -->
<view class="section-card">
<view class="section-title">
<view class="blue-bar"></view>
<text class="section-name">时间信息</text>
</view>
<view class="info-grid">
<view class="info-item">
<text class="info-label">注册时间:</text>
<text class="info-value">{{ detail.createdAt }}</text>
</view>
<view class="info-item">
<text class="info-label">更新时间:</text>
<text class="info-value">{{ detail.updatedAt }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, watch } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
const props = defineProps({
visible: { type: Boolean, default: false },
driverId: { type: String, default: '' }
})
const emit = defineEmits(['update:visible'])
const isClosing = ref(false)
const detailLoading = ref(false)
const detailError = ref('')
type DriverDetail = {
id: string
realName: string
phone: string
username: string
avatarUrl: string
idCardMasked: string
driverLicense: string
vehicleTypeLabel: string
vehicleNumber: string
serviceAreaStr: string
statusLabel: string
statusCls: string
workStatusLabel: string
workStatusCls: string
orderCount: string
ratingAvg: string
ratingCount: string
createdAt: string
updatedAt: string
}
const detail = ref<DriverDetail | null>(null)
// ========== 字段格式化 ==========
function maskIdCard(v: string): string {
if (v === '' || v == null) return '—'
if (v.length <= 8) return v
const head = v.substring(0, 4)
const tail = v.substring(v.length - 4)
return head + '******' + tail
}
function formatTime(ts: string): string {
if (ts === '' || ts == null) return '—'
const tIdx = ts.indexOf('T')
if (tIdx > -1) {
const date = ts.substring(0, tIdx)
const timeFull = ts.substring(tIdx + 1)
const dotIdx = timeFull.indexOf('.')
const time = dotIdx > -1 ? timeFull.substring(0, dotIdx) : timeFull.substring(0, 8)
return date + ' ' + time
}
return ts
}
function vehicleTypeLabel(vt: number | null): string {
if (vt === 1) return '电动车'
if (vt === 2) return '摩托车'
if (vt === 3) return '汽车'
return '—'
}
function statusLabel(s: number | null): string {
if (s === 1) return '正常'
if (s === 2) return '暂停'
if (s === 3) return '离职'
return '—'
}
function statusCls(s: number | null): string {
if (s === 1) return 'badge-status-ok'
if (s === 2) return 'badge-status-warn'
if (s === 3) return 'badge-status-off'
return ''
}
function workStatusLabel(ws: number | null): string {
if (ws === 1) return '在线'
if (ws === 2) return '忙碌'
if (ws === 3) return '离线'
return '—'
}
function workStatusCls(ws: number | null): string {
if (ws === 1) return 'badge-work-online'
if (ws === 2) return 'badge-work-busy'
if (ws === 3) return 'badge-work-offline'
return ''
}
function parseServiceAreas(raw: any): string {
if (raw == null) return '—'
if (typeof raw === 'string') {
if (raw === '' || raw === '[]') return '—'
// 尝试解析 JSON 字符串
try {
const parsed = JSON.parse(raw) as any
if (Array.isArray(parsed) && parsed.length > 0) {
return (parsed as string[]).join('、')
}
} catch (_) {
return raw
}
}
if (Array.isArray(raw)) {
const arr = raw as string[]
return arr.length > 0 ? arr.join('、') : '—'
}
return '—'
}
// ========== 数据拉取 ==========
const fetchDetail = async (id: string) => {
if (id === '' || id == null) return
detailLoading.value = true
detailError.value = ''
detail.value = null
try {
const res = await supa.select(
'ml_delivery_drivers',
`id=eq.${id}`,
{
columns: 'id,real_name,id_card,driver_license,vehicle_type,vehicle_number,service_areas,work_status,rating_avg,rating_count,order_count,status,created_at,updated_at,user_info:ak_users!user_id(username,phone,avatar_url)',
limit: 1
}
)
if (res.status >= 200 && res.status < 300 && res.data != null) {
const rows = res.data as UTSJSONObject[]
if (rows.length === 0) {
detailError.value = '未找到配送员数据'
return
}
const row = rows[0]
const userInfo = row.getJSON('user_info')
const s = row.getNumber('status')
const ws = row.getNumber('work_status')
const vt = row.getNumber('vehicle_type')
detail.value = {
id: row.getString('id') ?? '—',
realName: row.getString('real_name') ?? '—',
phone: userInfo?.getString('phone') ?? '—',
username: userInfo?.getString('username') ?? '—',
avatarUrl: (() => {
const url = userInfo?.getString('avatar_url') ?? ''
return url !== '' ? url : '/static/logo.png'
})(),
idCardMasked: maskIdCard(row.getString('id_card') ?? ''),
driverLicense: row.getString('driver_license') ?? '—',
vehicleTypeLabel: vehicleTypeLabel(vt),
vehicleNumber: row.getString('vehicle_number') ?? '—',
serviceAreaStr: parseServiceAreas(row['service_areas']),
statusLabel: statusLabel(s),
statusCls: statusCls(s),
workStatusLabel: workStatusLabel(ws),
workStatusCls: workStatusCls(ws),
orderCount: String(row.getNumber('order_count') ?? 0),
ratingAvg: (() => {
const v = row.getNumber('rating_avg') ?? 0
return v.toFixed(2)
})(),
ratingCount: String(row.getNumber('rating_count') ?? 0),
createdAt: formatTime(row.getString('created_at') ?? ''),
updatedAt: formatTime(row.getString('updated_at') ?? '')
} as DriverDetail
} else {
detailError.value = '加载详情失败,请重试'
}
} catch (e) {
detailError.value = '请求异常,请稍后重试'
} finally {
detailLoading.value = false
}
}
const close = () => {
isClosing.value = true
setTimeout(() => {
isClosing.value = false
emit('update:visible', false)
}, 280)
}
watch(() => props.visible, (newVal: boolean) => {
if (newVal) {
isClosing.value = false
detail.value = null
detailError.value = ''
fetchDetail(props.driverId)
}
})
</script>
<style scoped>
.drawer-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.45);
z-index: 2000;
display: flex;
flex-direction: row;
justify-content: flex-end;
animation: maskFadeIn 0.3s ease-out;
}
.drawer-mask.closing {
animation: maskFadeOut 0.28s ease-in forwards;
}
.drawer-container {
width: 50%;
min-width: 360px;
height: 100vh;
background-color: #fff;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
animation: slideIn 0.32s cubic-bezier(0.23, 1, 0.32, 1);
}
.drawer-container.closing {
animation: slideOut 0.28s cubic-bezier(0.755, 0.05, 0.855, 0.06) forwards;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slideOut {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
@keyframes maskFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes maskFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* ---- 头部 ---- */
.drawer-header {
height: 56px;
padding: 0 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.drawer-title {
font-size: 16px;
font-weight: 600;
color: #262626;
}
.close-btn {
width: 32px;
height: 32px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
}
.close-icon {
font-size: 22px;
color: #8c8c8c;
line-height: 1;
}
/* ---- Body ---- */
.drawer-body {
flex: 1;
background-color: #f5f7f9;
}
.drawer-center {
padding: 60px 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.tip-text {
font-size: 14px;
color: #8c8c8c;
}
.tip-text-err {
font-size: 14px;
color: #ff4d4f;
}
/* ---- 内容区域 ---- */
.detail-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ---- 头像卡片 ---- */
.avatar-card {
background-color: #fff;
border-radius: 6px;
padding: 20px 24px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
.detail-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
flex-shrink: 0;
}
.avatar-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-name {
font-size: 18px;
font-weight: 600;
color: #262626;
}
.badge-row {
display: flex;
flex-direction: row;
gap: 8px;
}
.badge {
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
}
.badge-status-ok { background-color: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.badge-status-warn { background-color: #fffbe6; color: #faad14; border: 1px solid #ffe58f; }
.badge-status-off { background-color: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }
.badge-work-online { background-color: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
.badge-work-busy { background-color: #fffbe6; color: #fa8c16; border: 1px solid #ffd591; }
.badge-work-offline { background-color: #fafafa; color: #8c8c8c; border: 1px solid #d9d9d9; }
/* ---- 信息卡片 ---- */
.section-card {
background-color: #fff;
border-radius: 6px;
padding: 16px 24px 20px;
}
.section-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.blue-bar {
width: 3px;
height: 14px;
background-color: #1890ff;
border-radius: 2px;
}
.section-name {
font-size: 14px;
font-weight: 600;
color: #262626;
}
.info-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px 0;
}
.info-item {
width: 50%;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.info-item.full {
width: 100%;
}
.info-label {
font-size: 13px;
color: #8c8c8c;
flex-shrink: 0;
width: 76px;
}
.info-value {
font-size: 13px;
color: #262626;
flex: 1;
word-break: break-all;
}
.info-id {
font-size: 11px;
color: #8c8c8c;
word-break: break-all;
}
/* ---- KPI 行 ---- */
.kpi-row {
display: flex;
flex-direction: row;
gap: 1px;
background-color: #f0f0f0;
border: 1px solid #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.kpi-item {
flex: 1;
background-color: #fff;
padding: 16px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.kpi-value {
font-size: 22px;
font-weight: 600;
color: #1890ff;
}
.kpi-label {
font-size: 12px;
color: #8c8c8c;
}
</style>