Files
shanxia2.0/miniprogram/pages/pl/driver-plan/detail.vue
T
wuzilongandCursor 811e6a129f feat(pl): allocate copy_seq_no on first driver plan share-to-group
Add ensure-copy-seq API and miniprogram clipboard prefix so each plan
gets a tenant-scoped fixed serial on first copy; cover allocation with
unit tests and refresh the backend graph.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 12:36:58 +08:00

360 lines
11 KiB
Vue
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 class="bx-page pl-driver-plan-page pl-driver-plan-detail">
<view class="topbar" :style="safeAreaStyle">
<view class="topbar-left">
<text class="topbar-back" @click="goBack">←</text>
<text class="topbar-title">司机回执</text>
</view>
<view class="topbar-right" />
</view>
<view v-if="loading" class="page-loading">加载中...</view>
<view v-else-if="errorMessage" class="empty-hint">{{ errorMessage }}</view>
<view v-else-if="page?.planVoided" class="empty-hint">运输计划已作废</view>
<view v-else-if="page?.planArchived" class="empty-hint">运输计划已归档完成</view>
<template v-else-if="page">
<view class="section-card">
<view class="kv-row">
<text class="kv-k">计划编号</text>
<text class="kv-v">{{ page.planNo || '—' }}</text>
</view>
<view class="kv-row">
<text class="kv-k">计划日期</text>
<text class="kv-v">{{ page.planDate || '—' }}</text>
</view>
<view class="kv-row">
<text class="kv-k">车牌</text>
<text class="kv-v">{{ page.licensePlate || '—' }}</text>
</view>
<view class="kv-row">
<text class="kv-k">司机</text>
<text class="kv-v">{{ page.driverName || '—' }} {{ page.driverPhone || '' }}</text>
</view>
</view>
<view
v-for="stop in stopStates"
:key="`${stop.stopType}-${stop.stopId}`"
class="section-card"
>
<view class="stop-title"
>{{ stop.label }} · {{ stop.customerName || '—'
}}<text v-if="stop.businessType" class="meta">({{ stop.businessType }})</text></view
>
<view class="kv-row">
<text class="kv-k">地址</text>
<view class="addr-row kv-v">
<text>{{ stop.address || '—' }}</text>
<text v-if="canNavigate(stop)" class="nav-btn" @click="openNavigation(stop)"
>导航</text
>
</view>
</view>
<view class="kv-row">
<text class="kv-k">联系方式</text>
<text class="kv-v">{{ stop.contact || '—' }}</text>
</view>
<view class="kv-row">
<text class="kv-k">注意事项</text>
<text class="kv-v">{{ stop.notes || '—' }}</text>
</view>
<view v-for="(item, idx) in stop.products || []" :key="item.stopProductId || idx" class="product-card">
<view class="product-name"
>{{ idx + 1 }}/{{ (stop.products || []).length }} {{ item.productName || '产品' }}</view
>
<view class="meta">计划:{{ formatPlanQty(item) }}</view>
<view class="meta">规格型号:{{ item.model || '—' }}</view>
<view class="meta">包装规格:{{ item.packageSpec || '—' }}</view>
<view v-if="stop.showProductQty && !readonly" class="actual-box">
<view class="actual-label">实际数量</view>
<view class="actual-row">
<input
class="actual-input"
type="digit"
v-model="item.actualQtyText"
placeholder="请输入"
/>
<text>{{ item.unit || '' }}</text>
</view>
</view>
</view>
<view class="attach-label">附件上传</view>
<view class="attach-row">
<image
v-for="(url, i) in stop.attachmentUrls"
:key="`${url}-${i}`"
class="attach-thumb"
:src="resolveFileUrl(url)"
mode="aspectFill"
@click="previewStopImages(stop, i)"
@longpress="removeAttachment(stop, i)"
/>
<view
v-if="!readonly && stop.attachmentUrls.length < 9"
class="attach-add"
@click="uploadForStop(stop)"
>+</view
>
</view>
<textarea
class="remark-input"
v-model="stop.remark"
:disabled="readonly"
maxlength="500"
placeholder="填写备注(可选)"
/>
</view>
</template>
<view v-if="page && !errorMessage && !loading" class="page-footer">
<view class="footer-btn footer-btn--ghost" @click="goList">运输记录</view>
<view
v-if="(page.stops || []).length && !readonly"
class="footer-btn footer-btn--primary"
@click="handleSave"
>{{ saving ? '保存中...' : '保存回执' }}</view
>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { PAGE_PATHS, TOKEN_KEY, resolveApiBase } from '@/config/api'
import { resolveMinioUrl } from '@/config/env'
import { driverPlanApi } from '@/api/pl'
import { getSafeAreaStyle } from '@/utils/safeArea'
import { previewImages } from '@/utils/upload'
import { copyDriverPlanToClipboard } from '@/utils/plDriverPlanCopy'
const safeAreaStyle = getSafeAreaStyle()
const planId = ref('')
const page = ref(null)
const stopStates = ref([])
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const readonly = computed(() => !!(page.value?.planVoided || page.value?.planArchived))
function formatPlanQty(item) {
const qty = item.planQty != null ? item.planQty : item.quantity
const unit = item.unit || ''
if (qty == null) return '—'
return unit ? `${qty} ${unit}` : String(qty)
}
function canNavigate(stop) {
return stop.lng != null && stop.lat != null
}
function openNavigation(stop) {
const lat = Number(stop.lat)
const lng = Number(stop.lng)
if (Number.isNaN(lat) || Number.isNaN(lng)) return
uni.openLocation({
latitude: lat,
longitude: lng,
name: stop.label || '目的地',
address: stop.address || '',
fail: () => uni.showToast({ title: '无法打开导航', icon: 'none' })
})
}
function resolveFileUrl(url) {
return resolveMinioUrl(url)
}
function previewStopImages(stop, index) {
const urls = (stop.attachmentUrls || []).map(resolveFileUrl)
previewImages(urls, urls[index])
}
function removeAttachment(stop, index) {
if (readonly.value) return
uni.showModal({
title: '删除附件',
content: '确定删除该附件吗?',
success: (res) => {
if (res.confirm) stop.attachmentUrls.splice(index, 1)
}
})
}
async function uploadForStop(stop) {
if (readonly.value) return
const remain = 9 - (stop.attachmentUrls?.length || 0)
if (remain <= 0) return
const choose = await new Promise((resolve) => {
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: resolve,
fail: () => resolve(null)
})
})
if (!choose?.tempFilePaths?.length) return
const token = uni.getStorageSync(TOKEN_KEY) || ''
const uploadUrl = `${resolveApiBase()}/transport/driver-plan/${planId.value}/attachments`
uni.showLoading({ title: '上传中...' })
try {
for (const filePath of choose.tempFilePaths) {
const uploadRes = await new Promise((ok, fail) => {
uni.uploadFile({
url: uploadUrl,
filePath,
name: 'file',
header: { Authorization: token ? `Bearer ${token}` : '' },
formData: { folder: 'pl/transport/driver-feedback' },
success: ok,
fail
})
})
let body = {}
try {
body = JSON.parse(uploadRes.data || '{}')
} catch (e) {
body = {}
}
const data = body.data || {}
const url = data.url || data.fileUrl || data.file_url
if (body.code !== 200 && body.code !== 0) {
uni.showToast({ title: body.message || '上传失败', icon: 'none' })
break
}
if (!url) {
uni.showToast({ title: body.message || '上传失败', icon: 'none' })
break
}
stop.attachmentUrls.push(String(url))
}
} catch (e) {
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
async function loadDetail() {
if (!planId.value) {
errorMessage.value = '缺少计划 ID'
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const data = await driverPlanApi.get(planId.value)
page.value = data
stopStates.value = (data.stops || []).map((s) => ({
...s,
remark: s.remark || '',
attachmentUrls: [...(s.attachmentUrls || [])],
products: (s.products || []).map((p) => ({
...p,
actualQtyText: p.actualQty != null ? String(p.actualQty) : ''
}))
}))
} catch (e) {
errorMessage.value = e?.message || '加载失败'
page.value = null
stopStates.value = []
} finally {
loading.value = false
}
}
function promptCopyAfterSave() {
return new Promise((resolve) => {
uni.showModal({
title: '提示',
content: '保存成功,可将信息复制发到微信群',
confirmText: '复制发群',
cancelText: '关闭',
success: async (res) => {
if (res.confirm) {
try {
const ensured = await driverPlanApi.ensureCopySeq(planId.value)
const copySeqNo = ensured?.copySeqNo ?? ensured?.copy_seq_no
if (copySeqNo == null || copySeqNo === '') {
uni.showToast({ title: '复制失败', icon: 'none' })
resolve()
return
}
const ok = await copyDriverPlanToClipboard({
...(page.value || {}),
copySeqNo
})
if (ok) {
setTimeout(() => goList(), 400)
}
} catch (e) {
uni.showToast({ title: '复制失败', icon: 'none' })
}
}
resolve()
},
fail: () => resolve()
})
})
}
async function handleSave() {
if (readonly.value || saving.value) return
if (!stopStates.value.length) return
saving.value = true
try {
for (const stop of stopStates.value) {
const products =
stop.showProductQty
? (stop.products || []).map((p) => ({
stopProductId: p.stopProductId,
actualQty: Number(p.actualQtyText || 0)
}))
: undefined
await driverPlanApi.saveDriverFeedback(planId.value, {
stopType: stop.stopType,
stopId: stop.stopId,
attachmentUrls: stop.attachmentUrls,
remark: (stop.remark || '').trim() || undefined,
products
})
}
await loadDetail()
await promptCopyAfterSave()
} catch (e) {
// dbRequest already toasted
} finally {
saving.value = false
}
}
function goList() {
uni.navigateBack({
fail: () => uni.redirectTo({ url: PAGE_PATHS.plDriverPlan })
})
}
function goBack() {
goList()
}
onLoad((q) => {
planId.value = (q && q.planId) || ''
loadDetail()
})
</script>
<style lang="scss">
@import '@/styles/bx-theme.scss';
@import '@/styles/pl-driver-plan.scss';
</style>