Преглед изворни кода

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>
zlwu пре 3 месеци
родитељ
комит
a8f59183df

+ 46 - 0
mobile-h5/src/api/crmOptionsApi.ts

@@ -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 - 0
mobile-h5/src/api/vehicleOptionsApi.ts

@@ -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)
+}

+ 102 - 0
mobile-h5/src/components/pickers/DbCustomerPicker.vue

@@ -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>

+ 109 - 0
mobile-h5/src/components/pickers/DbProductPicker.vue

@@ -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>

+ 135 - 0
mobile-h5/src/components/pickers/DbVehiclePicker.vue

@@ -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>