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>
360 lines
11 KiB
Vue
360 lines
11 KiB
Vue
<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>
|