This commit is contained in:
zlwu committed 2026-07-04 08:21:06 +08:00
1 parent 8e70047f4c
commit b4a49183db
33 files changed
+11558 -5889

No files matched your search

@@ -0,0 +1,497 @@
<!-- 运输地址库 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">地址库</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="名称/地址">
<el-input
v-model="searchForm.q"
placeholder="搜索名称或详细地址"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option label="启用" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增地址
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
:title="selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0]) ? CREATOR_DELETE_TIP : ''"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="addressRows"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="addressName" label="地址名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleView(row)">{{ row.addressName }}</el-link>
</template>
</el-table-column>
<el-table-column prop="fullAddress" label="详细地址" min-width="240" show-overflow-tooltip />
<el-table-column label="坐标" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.lng != null && row.lat != null">{{ row.lng }}, {{ row.lat }}</span>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column prop="contactName" label="联系人" min-width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" min-width="120" align="center" />
<el-table-column prop="status" label="状态" min-width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === '1' ? 'success' : 'info'" size="small">
{{ row.status === '1' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</section>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
align-center
destroy-on-close
class="dialog-form-layout pl-transport-address-form-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="地址名称" prop="addressName">
<el-input v-model="formData.addressName" placeholder="如:安庆仓库、某某化工厂" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态">
<el-option label="启用" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item label="详细地址" prop="fullAddress" class="form-col-span-2">
<AmapAddressPicker
v-model="formData.fullAddress"
v-model:lng="formData.lng"
v-model:lat="formData.lat"
placeholder="请地图选点或搜索 POI"
/>
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="formData.contactName" placeholder="联系人(可选)" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="formData.contactPhone" placeholder="联系电话(可选)" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="备注(可选)" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<SystemViewDialog
v-model="viewVisible"
title="地址详情"
:loading="viewLoading"
:hero-title="viewDetail?.addressName || '—'"
:hero-meta="viewHeroMeta"
:sections="viewSections"
>
<template #hero-actions>
<el-button @click="handleEditFromView">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
type="danger"
:disabled="!viewDetail || !canDeleteByCreator(viewDetail)"
@click="handleDeleteFromView"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</SystemViewDialog>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
import {
transportAddressApi,
type TransportAddress,
type TransportAddressPayload
} from '@/api/pl/index'
import AmapAddressPicker from '@/components/business/amap-address-picker/index.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
const loading = ref(false)
const submitLoading = ref(false)
const addressRows = ref<TransportAddress[]>([])
const selectedRows = ref<TransportAddress[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formRef = ref()
const isEdit = ref(false)
const viewVisible = ref(false)
const viewLoading = ref(false)
const viewDetail = ref<TransportAddress | null>(null)
const searchForm = reactive({
q: '',
status: ''
})
const pagination = reactive({
page: 1,
pageSize: DEFAULT_LIST_PAGE_SIZE,
total: 0
})
const formData = reactive({
id: '',
addressName: '',
fullAddress: '',
lng: null as number | null,
lat: null as number | null,
contactName: '',
contactPhone: '',
status: '1',
remark: ''
})
const formRules = {
addressName: [{ required: true, message: '请输入地址名称', trigger: 'blur' }],
fullAddress: [{ required: true, message: '请填写详细地址', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
lng: [{ required: true, message: '请通过地图选点获取经度', trigger: 'change' }],
lat: [{ required: true, message: '请通过地图选点获取纬度', trigger: 'change' }]
}
const viewHeroMeta = computed(() => {
if (!viewDetail.value) return ''
return viewDetail.value.fullAddress || ''
})
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
return [
{
title: '基本信息',
fields: [
{ label: '地址名称', value: d.addressName },
{ label: '详细地址', value: d.fullAddress, span: 'full' },
{
label: '坐标',
value: d.lng != null && d.lat != null ? `${d.lng}, ${d.lat}` : '—'
},
{ label: '联系人', value: d.contactName || '—' },
{ label: '联系电话', value: d.contactPhone || '—' },
{
label: '状态',
value: d.status === '1' ? '启用' : '停用',
tagType: d.status === '1' ? 'success' : 'info'
},
{ label: '创建时间', value: formatDateTime(d.createTime) },
{ label: '更新时间', value: formatDateTime(d.updateTime) },
{ label: '备注', value: d.remark || '—', span: 'full' }
]
}
]
})
async function fetchData() {
loading.value = true
try {
const res = await transportAddressApi.list({
q: searchForm.q || undefined,
status: searchForm.status || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const { list, total } = pickBxPageResult<TransportAddress>(res)
addressRows.value = list
pagination.total = total
const idSet = new Set(addressRows.value.map(item => item.id))
selectedRows.value = selectedRows.value.filter(item => idSet.has(item.id))
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
searchForm.q = ''
searchForm.status = ''
selectedRows.value = []
pagination.page = 1
fetchData()
}
function handlePageChange() {
fetchData()
}
function handleSizeChange() {
pagination.page = 1
fetchData()
}
function handleSelectionChange(rows: TransportAddress[]) {
selectedRows.value = rows
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请选择一条地址记录')
return
}
handleEdit(selectedRows.value[0])
}
function handleToolbarDelete() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请选择一条地址记录')
return
}
handleDelete(selectedRows.value[0])
}
function handleAdd() {
dialogTitle.value = '新增地址'
isEdit.value = false
Object.assign(formData, {
id: '',
addressName: '',
fullAddress: '',
lng: null,
lat: null,
contactName: '',
contactPhone: '',
status: '1',
remark: ''
})
dialogVisible.value = true
}
async function handleView(row: TransportAddress) {
viewVisible.value = true
viewLoading.value = true
viewDetail.value = null
try {
const res = await transportAddressApi.get(row.id)
viewDetail.value = (res.data ?? res) as TransportAddress
} catch {
viewVisible.value = false
ElMessage.error('加载地址详情失败')
} finally {
viewLoading.value = false
}
}
async function handleEdit(row: TransportAddress) {
dialogTitle.value = '编辑地址'
isEdit.value = true
dialogVisible.value = true
try {
const res = await transportAddressApi.get(row.id)
const detail = (res.data ?? res) as TransportAddress
Object.assign(formData, {
id: detail.id,
addressName: detail.addressName,
fullAddress: detail.fullAddress,
lng: detail.lng != null ? Number(detail.lng) : null,
lat: detail.lat != null ? Number(detail.lat) : null,
contactName: detail.contactName || '',
contactPhone: detail.contactPhone || '',
status: detail.status,
remark: detail.remark || ''
})
} catch {
ElMessage.error('加载地址详情失败')
}
}
function handleEditFromView() {
if (!viewDetail.value) return
viewVisible.value = false
handleEdit(viewDetail.value)
}
async function handleDeleteFromView() {
if (!viewDetail.value) return
await handleDelete(viewDetail.value)
viewVisible.value = false
viewDetail.value = null
}
function validateCoordinates(): boolean {
if (formData.lng == null || formData.lat == null) {
ElMessage.warning('请通过地图选点填写经纬度')
return false
}
return true
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !validateCoordinates()) return
submitLoading.value = true
try {
const payload: TransportAddressPayload = {
addressName: formData.addressName.trim(),
fullAddress: formData.fullAddress.trim(),
lng: formData.lng as number,
lat: formData.lat as number,
contactName: formData.contactName.trim() || undefined,
contactPhone: formData.contactPhone.trim() || undefined,
status: formData.status,
remark: formData.remark.trim() || undefined
}
if (isEdit.value) {
await transportAddressApi.update(formData.id, payload)
} else {
await transportAddressApi.create(payload)
}
ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
dialogVisible.value = false
fetchData()
} finally {
submitLoading.value = false
}
}
function handleDialogClose() {
formRef.value?.resetFields()
}
async function handleDelete(row: TransportAddress) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('确定删除该地址吗?', '提示', { type: 'warning' })
await transportAddressApi.delete(row.id)
ElMessage.success('删除成功')
if (viewDetail.value?.id === row.id) {
viewVisible.value = false
viewDetail.value = null
}
fetchData()
}
useDbInstantSearch(
() => ({
q: searchForm.q,
status: searchForm.status
}),
handleSearch
)
onMounted(() => {
fetchData()
})
</script>
@@ -15,6 +15,35 @@
<van-cell title="路线" :value="routeDisplay" class="route-cell" />
</van-cell-group>
<van-cell-group
v-if="pickupNavUrl || destinationNavUrl"
inset
class="section-card nav-card"
title="导航"
>
<div class="nav-actions">
<van-button
v-if="pickupNavUrl"
type="primary"
block
round
@click="openNavigation(pickupNavUrl)"
>
导航到取货点
</van-button>
<van-button
v-if="destinationNavUrl"
type="primary"
plain
block
round
@click="openNavigation(destinationNavUrl)"
>
导航到送货点
</van-button>
</div>
</van-cell-group>
<van-cell-group v-if="page.products.length" inset class="section-card" title="产品明细">
<van-cell
v-for="(item, idx) in page.products"
@@ -72,6 +101,7 @@
type PublicTransportProduct
} from '@/api/pl/public-transport'
import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
import 'vant/es/toast/style'
import '@/styles/public-mobile-vant.scss'
@@ -99,6 +129,28 @@
return `${page.value.pickupAddress || '—'} → ${page.value.destinationAddress || '—'}`
})
const pickupNavUrl = computed(() => {
if (!page.value) return null
return buildAmapNavigationUrl(
page.value.pickupLng,
page.value.pickupLat,
page.value.pickupAddress || '取货点'
)
})
const destinationNavUrl = computed(() => {
if (!page.value) return null
return buildAmapNavigationUrl(
page.value.destinationLng,
page.value.destinationLat,
page.value.destinationAddress || '送货点'
)
})
function openNavigation(url: string) {
window.location.href = url
}
function formatProductQuantity(value?: number) {
if (value == null || Number.isNaN(Number(value))) return '—'
const num = Number(value)
@@ -207,4 +259,11 @@
.uploader-wrap {
padding: 12px 16px 16px;
}
.nav-actions {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px 16px;
}
</style>