feat(mobile-h5): add CRM/PL option APIs and select-only pickers

Provide customer/product/vehicle pickers that search portal master data without create-on-type.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-03 22:39:36 +08:00
1 parent 4520ccbfcb
commit a8f59183df
5 files changed
+437

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
import api from '@/utils/http'
export interface CrmCustomerOption {
id: string
customerName: string
customerNo?: string
}
export interface CrmProductOption {
id: string
productName: string
model?: string
unit?: string
status?: string
}
function extractList<T>(res: unknown): T[] {
const data = (res as { list?: T[]; rows?: T[] }) || {}
return data.list || data.rows || []
}
/** CRM 客户远程搜索(只读) */
export async function fetchCustomerOptions(keyword?: string): Promise<CrmCustomerOption[]> {
const res = await api.post<{ list?: CrmCustomerOption[] }>({
url: '/web/crm/customer/list',
data: {
page: 1,
pageSize: keyword?.trim() ? 50 : 20,
...(keyword?.trim() ? { customerName: keyword.trim() } : {})
}
})
return extractList<CrmCustomerOption>(res)
}
/** CRM 产品远程搜索(只读) */
export async function fetchProductOptions(keyword?: string): Promise<CrmProductOption[]> {
const res = await api.post<{ list?: CrmProductOption[] }>({
url: '/web/crm/products/list',
data: {
page: 1,
pageSize: keyword?.trim() ? 50 : 20,
...(keyword?.trim() ? { productName: keyword.trim() } : {})
}
})
return extractList<CrmProductOption>(res)
}
+45
View File
@@ -0,0 +1,45 @@
import api from '@/utils/http'
export interface VehicleOption {
id: string
licensePlate: string
vehicleType?: string
}
function extractList<T>(res: unknown): T[] {
const data = (res as { list?: T[]; rows?: T[]; items?: T[] }) || {}
return data.list || data.rows || data.items || (Array.isArray(res) ? (res as T[]) : [])
}
/** 登录态:运输车辆(自有 + 三方),只读搜索 */
export async function fetchVehicleOptions(q?: string): Promise<VehicleOption[]> {
const res = await api.get<{ list?: VehicleOption[]; total?: number }>({
url: '/transport/vehicle',
params: {
...(q?.trim() ? { q: q.trim() } : {}),
page: 1,
pageSize: 100
}
})
return extractList<VehicleOption>(res)
}
/** 免登:现场页车牌 options(依赖 Task 6 公共接口) */
export async function fetchPublicVehiclePlates(params: {
q?: string
inventoryId?: string
planId?: string
}): Promise<Array<{ licensePlate: string; vehicleType?: string }>> {
const res = await api.get<Array<{ licensePlate: string; vehicleType?: string }> | {
list?: Array<{ licensePlate: string; vehicleType?: string }>
}>({
url: '/public/db/vehicle-plates',
params: {
q: params.q,
inventoryId: params.inventoryId,
planId: params.planId
}
})
if (Array.isArray(res)) return res
return extractList(res)
}
@@ -0,0 +1,102 @@
<template>
<van-popup v-model:show="visible" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="picker-container">
<div class="search-container">
<van-search
v-model="keyword"
placeholder="搜索客户名称"
shape="round"
@search="loadOptions"
@clear="loadOptions"
@update:model-value="onKeywordInput"
/>
</div>
<div class="option-list">
<van-cell
v-for="item in options"
:key="item.id"
:title="item.customerName"
:label="item.customerNo"
clickable
@click="onSelect(item)"
/>
<van-empty
v-if="!loading && options.length === 0"
description="暂无匹配,请到门户维护"
/>
</div>
</div>
</van-popup>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { fetchCustomerOptions, type CrmCustomerOption } from '@/api/crmOptionsApi'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: 'update:show', v: boolean): void
(e: 'select', item: CrmCustomerOption): void
}>()
const visible = ref(false)
const keyword = ref('')
const loading = ref(false)
const options = ref<CrmCustomerOption[]>([])
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => props.show,
(v) => {
visible.value = v
if (v) {
keyword.value = ''
loadOptions()
}
},
{ immediate: true }
)
watch(visible, (v) => emit('update:show', v))
const loadOptions = async () => {
loading.value = true
try {
options.value = await fetchCustomerOptions(keyword.value)
} catch (e) {
console.error(e)
options.value = []
} finally {
loading.value = false
}
}
const onKeywordInput = () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => loadOptions(), 300)
}
const onSelect = (item: CrmCustomerOption) => {
emit('select', item)
visible.value = false
}
</script>
<style lang="scss" scoped>
.picker-container {
display: flex;
flex-direction: column;
max-height: 70vh;
}
.search-container {
padding: 8px 12px 0;
}
.option-list {
flex: 1;
overflow-y: auto;
padding-bottom: 16px;
}
</style>
@@ -0,0 +1,109 @@
<template>
<van-popup v-model:show="visible" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="picker-container">
<div class="search-container">
<van-search
v-model="keyword"
placeholder="搜索产品名称"
shape="round"
@search="loadOptions"
@clear="loadOptions"
@update:model-value="onKeywordInput"
/>
</div>
<div class="option-list">
<van-cell
v-for="item in options"
:key="item.id"
:title="item.productName"
:label="formatMeta(item)"
clickable
@click="onSelect(item)"
/>
<van-empty
v-if="!loading && options.length === 0"
description="暂无匹配,请到门户维护"
/>
</div>
</div>
</van-popup>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { fetchProductOptions, type CrmProductOption } from '@/api/crmOptionsApi'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: 'update:show', v: boolean): void
(e: 'select', item: CrmProductOption): void
}>()
const visible = ref(false)
const keyword = ref('')
const loading = ref(false)
const options = ref<CrmProductOption[]>([])
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => props.show,
(v) => {
visible.value = v
if (v) {
keyword.value = ''
loadOptions()
}
},
{ immediate: true }
)
watch(visible, (v) => emit('update:show', v))
const formatMeta = (item: CrmProductOption) => {
const parts: string[] = []
if (item.model?.trim()) parts.push(item.model.trim())
if (item.status === 'off') parts.push('已下架')
return parts.join(' · ')
}
const loadOptions = async () => {
loading.value = true
try {
options.value = await fetchProductOptions(keyword.value)
} catch (e) {
console.error(e)
options.value = []
} finally {
loading.value = false
}
}
const onKeywordInput = () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => loadOptions(), 300)
}
const onSelect = (item: CrmProductOption) => {
emit('select', item)
visible.value = false
}
</script>
<style lang="scss" scoped>
.picker-container {
display: flex;
flex-direction: column;
max-height: 70vh;
}
.search-container {
padding: 8px 12px 0;
}
.option-list {
flex: 1;
overflow-y: auto;
padding-bottom: 16px;
}
</style>
@@ -0,0 +1,135 @@
<template>
<van-popup v-model:show="visible" round position="bottom" :style="{ maxHeight: '70vh' }">
<div class="picker-container">
<div class="search-container">
<van-search
v-model="keyword"
placeholder="搜索车牌号"
shape="round"
@search="loadOptions"
@clear="loadOptions"
@update:model-value="onKeywordInput"
/>
</div>
<div class="option-list">
<van-cell
v-for="item in options"
:key="item.id || item.licensePlate"
:title="item.licensePlate"
:label="typeLabel(item.vehicleType)"
clickable
@click="onSelect(item.licensePlate)"
/>
<van-empty
v-if="!loading && options.length === 0"
description="暂无匹配,请到门户维护"
/>
</div>
</div>
</van-popup>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
fetchPublicVehiclePlates,
fetchVehicleOptions,
type VehicleOption
} from '@/api/vehicleOptionsApi'
const props = withDefaults(
defineProps<{
show: boolean
/** 现场免登模式 */
publicMode?: boolean
inventoryId?: string
planId?: string
}>(),
{
publicMode: false
}
)
const emit = defineEmits<{
(e: 'update:show', v: boolean): void
(e: 'select', licensePlate: string): void
}>()
const visible = ref(false)
const keyword = ref('')
const loading = ref(false)
const options = ref<Array<{ id?: string; licensePlate: string; vehicleType?: string }>>([])
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => props.show,
(v) => {
visible.value = v
if (v) {
keyword.value = ''
loadOptions()
}
},
{ immediate: true }
)
watch(visible, (v) => emit('update:show', v))
const typeLabel = (t?: string) => {
if (t === 'self') return '自有'
if (t === 'carrier') return '三方'
return ''
}
const loadOptions = async () => {
loading.value = true
try {
if (props.publicMode) {
const list = await fetchPublicVehiclePlates({
q: keyword.value,
inventoryId: props.inventoryId,
planId: props.planId
})
options.value = list.map((x) => ({
id: x.licensePlate,
licensePlate: x.licensePlate,
vehicleType: x.vehicleType
}))
} else {
const list: VehicleOption[] = await fetchVehicleOptions(keyword.value)
options.value = list
}
} catch (e) {
console.error(e)
options.value = []
} finally {
loading.value = false
}
}
const onKeywordInput = () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => loadOptions(), 300)
}
const onSelect = (licensePlate: string) => {
emit('select', licensePlate)
visible.value = false
}
</script>
<style lang="scss" scoped>
.picker-container {
display: flex;
flex-direction: column;
max-height: 70vh;
}
.search-container {
padding: 8px 12px 0;
}
.option-list {
flex: 1;
overflow-y: auto;
padding-bottom: 16px;
}
</style>