Ver código fonte

feat: 新增联系人管理&调整线索管理交互&接口等功能

pengjq 5 dias atrás
pai
commit
8d93115ed3

+ 74 - 0
src/api/crm/contact/index.ts

@@ -0,0 +1,74 @@
+import type { PageParam } from '@/http/types'
+import { http } from '@/http/http'
+
+export interface ContactVO {
+  id?: number
+  name?: string
+  customerId?: number
+  customerName?: string
+  contactLastTime?: Date
+  contactLastContent?: string
+  contactNextTime?: Date
+  ownerUserId?: number
+  ownerUserName?: string
+  ownerUserDept?: string
+  mobile?: string
+  telephone?: string
+  qq?: string
+  wechat?: string
+  email?: string
+  areaId?: number
+  areaName?: string
+  detailAddress?: string
+  sex?: number
+  master?: boolean
+  post?: string
+  parentId?: number
+  parentName?: string
+  remark?: string
+  creator?: string
+  creatorName?: string
+  createTime?: Date
+  updateTime?: Date
+}
+
+export interface ContactPageReqVO extends PageParam {
+  sceneType?: string
+  name?: string
+  mobile?: string
+  telephone?: string
+  email?: string
+  customerId?: number
+  wechat?: string
+}
+
+export interface ContactSimpleVO {
+  id?: number
+  name?: string
+  customerId?: number
+  customerName?: string
+}
+
+export async function getContactPage(params: ContactPageReqVO) {
+  return http.get('/crm/contact/page', params)
+}
+
+export async function getContact(id: number) {
+  return http.get(`/crm/contact/get?id=${id}`)
+}
+
+export async function createContact(data: ContactVO) {
+  return http.post('/crm/contact/create', data)
+}
+
+export async function updateContact(data: ContactVO) {
+  return http.put('/crm/contact/update', data)
+}
+
+export async function deleteContact(id: number) {
+  return http.delete(`/crm/contact/delete?id=${id}`)
+}
+
+export async function getSimpleContactList() {
+  return http.get('/crm/contact/simple-all-list')
+}

+ 67 - 0
src/api/crm/customer/index.ts

@@ -0,0 +1,67 @@
+import type { PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+export interface CustomerSimpleVO {
+  id: number
+  name: string
+}
+
+export interface CustomerVO {
+  id?: number
+  name?: string
+  followUpStatus?: boolean
+  contactLastTime?: Date
+  contactLastContent?: string
+  contactNextTime?: Date
+  ownerUserId?: number
+  ownerUserName?: string
+  ownerUserDept?: string
+  lockStatus?: boolean
+  dealStatus?: boolean
+  mobile?: string
+  telephone?: string
+  qq?: string
+  wechat?: string
+  email?: string
+  areaId?: number
+  areaName?: string
+  detailAddress?: string
+  industryId?: number
+  level?: number
+  source?: number
+  remark?: string
+  creator?: string
+  creatorName?: string
+  createTime?: Date
+  updateTime?: Date
+}
+
+// 客户列表
+export async function getCustomerSimpleList() {
+  return await http.get<CustomerSimpleVO[]>(`/crm/customer/simple-list`)
+}
+
+// 查询客户列表
+export async function getCustomerPage(params: any) {
+  return await http.get<PageResult<CustomerVO>>(`/crm/customer/page`, params)
+}
+
+// 查询客户详情
+export async function getCustomer(id: number) {
+  return await http.get<CustomerVO>(`/crm/customer/get?id=${id}`)
+}
+
+// 新增客户
+export async function createCustomer(data: CustomerVO) {
+  return await http.post<number>(`/crm/customer/create`, data)
+}
+
+// 修改客户
+export async function updateCustomer(data: CustomerVO) {
+  return await http.put<boolean>(`/crm/customer/update`, data)
+}
+
+// 删除客户
+export async function deleteCustomer(id: number) {
+  return await http.delete<boolean>(`/crm/customer/delete?id=${id}`)
+}

+ 85 - 0
src/components/system-select/contact-picker.vue

@@ -0,0 +1,85 @@
+<template>
+  <view>
+    <wd-select-picker
+      v-model="selectedId"
+      :label="label"
+      :label-width="label ? '180rpx' : '0'"
+      :columns="contactOptions"
+      value-key="id"
+      label-key="name"
+      type="radio"
+      :prop="prop"
+      :placeholder="placeholder"
+      @confirm="handleConfirm"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { ContactSimpleVO } from '@/api/crm/contact'
+import { onMounted, ref, watch } from 'vue'
+import { getSimpleContactList } from '@/api/crm/contact'
+
+const props = withDefaults(defineProps<{
+  modelValue?: number
+  type?: 'radio' | 'checkbox'
+  label?: string
+  placeholder?: string
+  prop?: string
+  excludeId?: number
+}>(), {
+  type: 'radio',
+  label: '',
+  placeholder: '请选择',
+  prop: '',
+})
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: number | undefined): void
+  (e: 'confirm', contacts: ContactSimpleVO[]): void
+}>()
+
+const contactOptions = ref<ContactSimpleVO[]>([])
+const selectedId = ref<number | string>('')
+const isLoaded = ref(false)
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (isLoaded.value) {
+      if (val !== undefined && val > 0) {
+        selectedId.value = val
+      } else {
+        selectedId.value = ''
+      }
+    }
+  },
+)
+
+async function loadContactList() {
+  const list = await getSimpleContactList()
+  if (props.excludeId) {
+    contactOptions.value = list.filter(c => c.id !== props.excludeId)
+  } else {
+    contactOptions.value = list
+  }
+  isLoaded.value = true
+  if (props.modelValue !== undefined && props.modelValue > 0) {
+    selectedId.value = props.modelValue
+  }
+}
+
+function handleConfirm({ value }: { value: number }) {
+  emit('update:modelValue', value)
+  if (value) {
+    const selected = contactOptions.value.find(c => c.id === value)
+    emit('confirm', selected ? [selected] : [])
+  } else {
+    emit('confirm', [])
+  }
+}
+
+onMounted(() => {
+  loadContactList()
+})
+</script>

+ 79 - 0
src/components/system-select/customer-picker.vue

@@ -0,0 +1,79 @@
+<template>
+  <view>
+    <wd-select-picker
+      v-model="selectedId"
+      :label="label"
+      :label-width="label ? '180rpx' : '0'"
+      :columns="customerOptions"
+      value-key="id"
+      label-key="name"
+      type="radio"
+      :prop="prop"
+      :placeholder="placeholder"
+      @confirm="handleConfirm"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { CustomerSimpleVO } from '@/api/crm/customer'
+import { onMounted, ref, watch } from 'vue'
+import { getCustomerSimpleList } from '@/api/crm/customer'
+
+const props = withDefaults(defineProps<{
+  modelValue?: number
+  type?: 'radio' | 'checkbox'
+  label?: string
+  placeholder?: string
+  prop?: string
+}>(), {
+  type: 'radio',
+  label: '',
+  placeholder: '请选择',
+  prop: '',
+})
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: number | undefined): void
+  (e: 'confirm', customers: CustomerSimpleVO[]): void
+}>()
+
+const customerOptions = ref<CustomerSimpleVO[]>([])
+const selectedId = ref<number | string>('')
+const isLoaded = ref(false)
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (isLoaded.value) {
+      if (val !== undefined && val > 0) {
+        selectedId.value = val
+      } else {
+        selectedId.value = ''
+      }
+    }
+  },
+)
+
+async function loadCustomerList() {
+  customerOptions.value = await getCustomerSimpleList()
+  isLoaded.value = true
+  if (props.modelValue !== undefined && props.modelValue > 0) {
+    selectedId.value = props.modelValue
+  }
+}
+
+function handleConfirm({ value }: { value: number }) {
+  emit('update:modelValue', value)
+  if (value) {
+    const selected = customerOptions.value.find(c => c.id === value)
+    emit('confirm', selected ? [selected] : [])
+  } else {
+    emit('confirm', [])
+  }
+}
+
+onMounted(() => {
+  loadCustomerList()
+})
+</script>

+ 3 - 1
src/components/system-select/index.ts

@@ -1,2 +1,4 @@
-export { default as UserPicker } from './user-picker.vue'
 export { default as AreaPicker } from './area-picker.vue'
+export { default as ContactPicker } from './contact-picker.vue'
+export { default as CustomerPicker } from './customer-picker.vue'
+export { default as UserPicker } from './user-picker.vue'

+ 102 - 35
src/pages-crm/clue/components/search-form.vue

@@ -1,40 +1,54 @@
 <template>
-  <view @click="visible = true">
-    <wd-search :placeholder="placeholder" hide-cancel disabled />
-  </view>
+  <view class="search-sticky-wrapper">
+    <view class="tab-bar">
+      <view
+        v-for="item in tabs"
+        :key="item.value"
+        class="tab-item" :class="[{ active: activeTab === item.value }]"
+        @click="handleTabChange(item.value)"
+      >
+        {{ item.label }}
+      </view>
+    </view>
+    <!-- 搜索框入口 -->
+    <view @click="visible = true">
+      <wd-search :placeholder="placeholder" hide-cancel disabled />
+    </view>
 
-  <wd-popup v-model="visible" position="top" @close="visible = false">
-    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
-      <view class="yd-search-form-item">
-        <view class="yd-search-form-label">
-          线索名称
+    <!-- 搜索弹窗 -->
+    <wd-popup v-model="visible" position="top" @close="visible = false">
+      <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+        <view class="yd-search-form-item">
+          <view class="yd-search-form-label">
+            线索名称
+          </view>
+          <wd-input
+            v-model="formData.name"
+            placeholder="请输入线索名称"
+            clearable
+          />
         </view>
-        <wd-input
-          v-model="formData.name"
-          placeholder="请输入线索名称"
-          clearable
-        />
-      </view>
-      <view class="yd-search-form-item">
-        <view class="yd-search-form-label">
-          手机号码
+        <view class="yd-search-form-item">
+          <view class="yd-search-form-label">
+            手机号码
+          </view>
+          <wd-input
+            v-model="formData.mobile"
+            placeholder="请输入手机号码"
+            clearable
+          />
+        </view>
+        <view class="yd-search-form-actions">
+          <wd-button class="flex-1" plain @click="handleReset">
+            重置
+          </wd-button>
+          <wd-button class="flex-1" type="primary" @click="handleSearch">
+            搜索
+          </wd-button>
         </view>
-        <wd-input
-          v-model="formData.mobile"
-          placeholder="请输入手机号码"
-          clearable
-        />
-      </view>
-      <view class="yd-search-form-actions">
-        <wd-button class="flex-1" plain @click="handleReset">
-          重置
-        </wd-button>
-        <wd-button class="flex-1" type="primary" @click="handleSearch">
-          搜索
-        </wd-button>
       </view>
-    </view>
-  </wd-popup>
+    </wd-popup>
+  </view>
 </template>
 
 <script lang="ts" setup>
@@ -47,10 +61,15 @@ const emit = defineEmits<{
 }>()
 
 const visible = ref(false)
+const activeTab = ref('1')
+const tabs = [
+  { label: '我负责的', value: '1' },
+  { label: '我参与的', value: '2' },
+  { label: '下属负责的', value: '3' },
+]
 const formData = reactive({
   name: undefined as string | undefined,
   mobile: undefined as string | undefined,
-  ownerUserName: undefined as string | undefined,
 })
 
 const placeholder = computed(() => {
@@ -64,16 +83,64 @@ const placeholder = computed(() => {
   return conditions.length > 0 ? conditions.join(' | ') : '搜索线索'
 })
 
+function handleTabChange(value: string) {
+  activeTab.value = value
+  emit('search', { sceneType: value })
+}
+
+/** 搜索 */
 function handleSearch() {
   visible.value = false
-  emit('search', { ...formData })
+  emit('search', { ...formData, sceneType: activeTab.value })
 }
 
+/** 重置 */
 function handleReset() {
   formData.name = undefined
   formData.mobile = undefined
-  formData.ownerUserName = undefined
   visible.value = false
   emit('reset')
 }
 </script>
+
+<style lang="scss" scoped>
+.search-sticky-wrapper {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  background-color: #fff;
+}
+
+.tab-bar {
+  display: flex;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.tab-item {
+  flex: 1;
+  text-align: center;
+  padding: 16rpx 0;
+  font-size: 28rpx;
+  color: #666;
+  position: relative;
+  transition: color 0.3s;
+
+  &.active {
+    color: #1890ff;
+    font-weight: 500;
+
+    &::after {
+      content: '';
+      position: absolute;
+      bottom: 0;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 48rpx;
+      height: 4rpx;
+      background-color: #1890ff;
+      border-radius: 2rpx;
+    }
+  }
+}
+</style>

+ 1 - 1
src/pages-crm/clue/detail/index.vue

@@ -6,7 +6,7 @@
       @click-left="handleBack"
     />
 
-    <view>
+    <view class="pb-140rpx">
       <wd-cell-group border>
         <wd-cell title="线索名称" :value="formData?.name || '-'" />
         <wd-cell title="手机号码" :value="formData?.mobile || '-'" />

+ 9 - 8
src/pages-crm/clue/index.vue

@@ -6,9 +6,11 @@
       @click-left="handleBack"
     />
 
-    <SearchForm @search="handleQuery" @reset="handleReset" />
+    <view class="search-form-wrapper" :style="{ top: `${getNavbarHeight()}px` }">
+      <SearchForm @search="handleQuery" @reset="handleReset" />
+    </view>
 
-    <view class="p-24rpx">
+    <view class="p-24rpx" :style="{ paddingTop: `${getNavbarHeight() + 80}px` }">
       <view
         v-for="item in list"
         :key="item.id"
@@ -74,10 +76,10 @@
 import type { ClueVO } from '@/api/crm/clue'
 import type { LoadMoreState } from '@/http/types'
 import { onReachBottom, onShow } from '@dcloudio/uni-app'
-import { onMounted, ref } from 'vue'
+import { ref } from 'vue'
 import { getCluePage } from '@/api/crm/clue'
 import { useAccess } from '@/hooks/useAccess'
-import { navigateBackPlus } from '@/utils'
+import { getNavbarHeight, navigateBackPlus } from '@/utils'
 import { DICT_TYPE } from '@/utils/constants'
 import { formatDate } from '@/utils/date'
 import SearchForm from './components/search-form.vue'
@@ -96,6 +98,8 @@ const loadMoreState = ref<LoadMoreState>('loading')
 const queryParams = ref({
   pageNo: 1,
   pageSize: 10,
+  sceneType: '1',
+  transformStatus: false,
 })
 
 function handleBack() {
@@ -120,6 +124,7 @@ function handleQuery(data?: Record<string, any>) {
     ...data,
     pageNo: 1,
     pageSize: queryParams.value.pageSize,
+    transformStatus: false,
   }
   list.value = []
   getList()
@@ -153,10 +158,6 @@ onReachBottom(() => {
   loadMore()
 })
 
-onMounted(() => {
-  getList()
-})
-
 onShow(() => {
   // 当页面显示时,刷新列表数据(第一页)
   queryParams.value.pageNo = 1

+ 146 - 0
src/pages-crm/contact/components/search-form.vue

@@ -0,0 +1,146 @@
+<template>
+  <view class="search-sticky-wrapper">
+    <view class="tab-bar">
+      <view
+        v-for="item in tabs"
+        :key="item.value"
+        class="tab-item" :class="[{ active: activeTab === item.value }]"
+        @click="handleTabChange(item.value)"
+      >
+        {{ item.label }}
+      </view>
+    </view>
+    <!-- 搜索框入口 -->
+    <view @click="visible = true">
+      <wd-search :placeholder="placeholder" hide-cancel disabled />
+    </view>
+
+    <!-- 搜索弹窗 -->
+    <wd-popup v-model="visible" position="top" @close="visible = false">
+      <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+        <view class="yd-search-form-item">
+          <view class="yd-search-form-label">
+            联系人姓名
+          </view>
+          <wd-input
+            v-model="formData.name"
+            placeholder="请输入联系人姓名"
+            clearable
+          />
+        </view>
+        <view class="yd-search-form-item">
+          <view class="yd-search-form-label">
+            手机号码
+          </view>
+          <wd-input
+            v-model="formData.mobile"
+            placeholder="请输入手机号码"
+            clearable
+          />
+        </view>
+        <view class="yd-search-form-actions">
+          <wd-button class="flex-1" plain @click="handleReset">
+            重置
+          </wd-button>
+          <wd-button class="flex-1" type="primary" @click="handleSearch">
+            搜索
+          </wd-button>
+        </view>
+      </view>
+    </wd-popup>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref } from 'vue'
+import { getNavbarHeight } from '@/utils'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const activeTab = ref('1')
+const tabs = [
+  { label: '我负责的', value: '1' },
+  { label: '我参与的', value: '2' },
+  { label: '下属负责的', value: '3' },
+]
+const formData = reactive({
+  name: undefined as string | undefined,
+  mobile: undefined as string | undefined,
+})
+
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.name) {
+    conditions.push(`姓名:${formData.name}`)
+  }
+  if (formData.mobile) {
+    conditions.push(`手机:${formData.mobile}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索联系人'
+})
+
+function handleTabChange(value: string) {
+  activeTab.value = value
+  emit('search', { sceneType: value })
+}
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', { ...formData, sceneType: activeTab.value })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.name = undefined
+  formData.mobile = undefined
+  visible.value = false
+  emit('reset')
+}
+</script>
+
+<style lang="scss" scoped>
+.search-sticky-wrapper {
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  background-color: #fff;
+}
+
+.tab-bar {
+  display: flex;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.tab-item {
+  flex: 1;
+  text-align: center;
+  padding: 16rpx 0;
+  font-size: 28rpx;
+  color: #666;
+  position: relative;
+  transition: color 0.3s;
+
+  &.active {
+    color: #1890ff;
+    font-weight: 500;
+
+    &::after {
+      content: '';
+      position: absolute;
+      bottom: 0;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 48rpx;
+      height: 4rpx;
+      background-color: #1890ff;
+      border-radius: 2rpx;
+    }
+  }
+}
+</style>

+ 143 - 0
src/pages-crm/contact/detail/index.vue

@@ -0,0 +1,143 @@
+<template>
+  <view class="yd-page-container">
+    <wd-navbar
+      title="联系人详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <view class="pb-140rpx">
+      <wd-cell-group border>
+        <wd-cell title="联系人姓名" :value="formData?.name || '-'" />
+        <wd-cell title="客户名称" :value="formData?.customerName || '-'" />
+        <wd-cell title="性别">
+          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="formData?.sex" />
+        </wd-cell>
+        <wd-cell title="手机号码" :value="formData?.mobile || '-'" />
+        <wd-cell title="固定电话" :value="formData?.telephone || '-'" />
+        <wd-cell title="QQ" :value="formData?.qq || '-'" />
+        <wd-cell title="微信" :value="formData?.wechat || '-'" />
+        <wd-cell title="邮箱" :value="formData?.email || '-'" />
+        <wd-cell title="职务" :value="formData?.post || '-'" />
+        <wd-cell title="关键决策人">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="formData?.master" />
+        </wd-cell>
+        <wd-cell title="直属上级" :value="formData?.parentName || '-'" />
+        <wd-cell title="所在地" :value="formData?.areaName || '-'" />
+        <wd-cell title="详细地址" :value="formData?.detailAddress || '-'" />
+        <wd-cell title="负责人" :value="formData?.ownerUserName || '-'" />
+        <wd-cell title="负责人部门" :value="formData?.ownerUserDept || '-'" />
+        <wd-cell title="最后跟进时间" :value="formatDateTime(formData?.contactLastTime) || '-'" />
+        <wd-cell title="最后跟进内容" :value="formData?.contactLastContent || '-'" />
+        <wd-cell title="下次联系时间" :value="formatDateTime(formData?.contactNextTime) || '-'" />
+        <wd-cell title="创建人" :value="formData?.creatorName || '-'" />
+        <wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
+        <wd-cell title="更新时间" :value="formatDateTime(formData?.updateTime) || '-'" />
+        <wd-cell title="备注" :value="formData?.remark || '-'" />
+      </wd-cell-group>
+    </view>
+
+    <view class="yd-detail-footer">
+      <view class="yd-detail-footer-actions">
+        <wd-button
+          v-if="hasAccessByCodes(['crm:contact:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['crm:contact:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { ContactVO } from '@/api/crm/contact'
+import { onShow } from '@dcloudio/uni-app'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteContact, getContact } from '@/api/crm/contact'
+import { useAccess } from '@/hooks/useAccess'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const toast = useToast()
+const formData = ref<ContactVO>()
+const deleting = ref(false)
+const isLoading = ref(false)
+
+function handleBack() {
+  navigateBackPlus('/pages-crm/contact/index')
+}
+
+async function getDetail() {
+  if (!props.id || isLoading.value) {
+    return
+  }
+  isLoading.value = true
+  try {
+    toast.loading('加载中...')
+    formData.value = await getContact(props.id)
+  } finally {
+    toast.close()
+    isLoading.value = false
+  }
+}
+
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-crm/contact/form/index?id=${props.id}`,
+  })
+}
+
+function handleDelete() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要删除该联系人吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      deleting.value = true
+      try {
+        await deleteContact(Number(props.id))
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+onMounted(() => {
+  getDetail()
+})
+
+onShow(() => {
+  getDetail()
+})
+</script>

+ 235 - 0
src/pages-crm/contact/form/index.vue

@@ -0,0 +1,235 @@
+<template>
+  <view class="yd-page-container">
+    <wd-navbar
+      :title="getTitle"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <view>
+      <wd-form ref="formRef" :model="formData" :rules="formRules">
+        <wd-cell-group border>
+          <wd-input
+            v-model="formData.name"
+            label="联系人姓名"
+            label-width="180rpx"
+            prop="name"
+            clearable
+            placeholder="请输入联系人姓名"
+          />
+          <CustomerPicker v-model="formData.customerId" label="客户名称" />
+          <UserPicker v-model="formData.ownerUserId" label="负责人" type="radio" />
+          <wd-input
+            v-model="formData.mobile"
+            label="手机号码"
+            label-width="180rpx"
+            prop="mobile"
+            clearable
+            placeholder="请输入手机号码"
+          />
+          <wd-input
+            v-model="formData.telephone"
+            label="固定电话"
+            label-width="180rpx"
+            clearable
+            placeholder="请输入固定电话"
+          />
+          <wd-input
+            v-model="formData.email"
+            label="邮箱"
+            label-width="180rpx"
+            prop="email"
+            clearable
+            placeholder="请输入邮箱"
+          />
+          <wd-input
+            v-model="formData.wechat"
+            label="微信"
+            label-width="180rpx"
+            clearable
+            placeholder="请输入微信"
+          />
+          <wd-input
+            v-model="formData.qq"
+            label="QQ"
+            label-width="180rpx"
+            clearable
+            placeholder="请输入QQ"
+          />
+          <wd-input
+            v-model="formData.post"
+            label="职务"
+            label-width="180rpx"
+            clearable
+            placeholder="请输入职务"
+          />
+          <wd-cell title="性别" title-width="180rpx" center>
+            <wd-radio-group v-model="formData.sex" shape="button">
+              <wd-radio :value="0">
+                未知
+              </wd-radio>
+              <wd-radio :value="1">
+                男
+              </wd-radio>
+              <wd-radio :value="2">
+                女
+              </wd-radio>
+            </wd-radio-group>
+          </wd-cell>
+          <wd-cell title="关键决策人" title-width="180rpx" center>
+            <wd-switch v-model="formData.master" size="20px" />
+          </wd-cell>
+          <ContactPicker v-model="formData.parentId" label="直属上级" :exclude-id="Number(id)" />
+          <AreaPicker v-model="formData.areaId" label="地址" />
+          <wd-textarea
+            v-model="formData.detailAddress"
+            label="详细地址"
+            label-width="180rpx"
+            placeholder="请输入详细地址"
+            :maxlength="200"
+            show-word-limit
+            clearable
+          />
+          <wd-cell title="下次联系时间" title-width="180rpx" center>
+            <wd-datetime-picker
+              v-model="contactNextTimeValue"
+              type="datetime"
+              :min-date="minDate"
+              @confirm="handleDateTimeConfirm"
+            />
+          </wd-cell>
+          <wd-textarea
+            v-model="formData.remark"
+            label="备注"
+            label-width="180rpx"
+            placeholder="请输入备注"
+            :maxlength="200"
+            show-word-limit
+            clearable
+          />
+        </wd-cell-group>
+      </wd-form>
+    </view>
+
+    <view class="yd-detail-footer">
+      <wd-button
+        type="primary"
+        block
+        :loading="formLoading"
+        @click="handleSubmit"
+      >
+        保存
+      </wd-button>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
+import type { ContactVO } from '@/api/crm/contact'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createContact, getContact, updateContact } from '@/api/crm/contact'
+import { AreaPicker, ContactPicker, CustomerPicker, UserPicker } from '@/components/system-select'
+import { useUserStore } from '@/store/user'
+import { navigateBackPlus } from '@/utils'
+import { isEmail, isMobile } from '@/utils/validator'
+
+const props = defineProps<{
+  id?: number | string
+}>()
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const userStore = useUserStore()
+const getTitle = computed(() => props.id ? '编辑联系人' : '新增联系人')
+const formLoading = ref(false)
+const minDate = new Date()
+
+const formData = ref<ContactVO>({
+  id: undefined,
+  name: undefined,
+  customerId: undefined,
+  contactNextTime: undefined,
+  ownerUserId: undefined,
+  mobile: undefined,
+  telephone: undefined,
+  qq: undefined,
+  wechat: undefined,
+  email: undefined,
+  areaId: undefined,
+  detailAddress: undefined,
+  sex: undefined,
+  master: false,
+  post: undefined,
+  parentId: undefined,
+  remark: undefined,
+})
+
+const contactNextTimeValue = ref<number | null>(null)
+
+const formRules = {
+  name: [{ required: true, message: '联系人姓名不能为空' }],
+  customerId: [{ required: true, message: '客户不能为空' }],
+  ownerUserId: [{ required: true, message: '负责人不能为空' }],
+  mobile: [
+    { required: true, message: '手机号码不能为空' },
+    { validator: (value: string) => isMobile(value), message: '请输入正确的手机号码' },
+  ],
+  email: [{ validator: (value: string) => !value || isEmail(value), message: '请输入正确的邮箱地址' }],
+}
+const formRef = ref<FormInstance>()
+
+function handleDateTimeConfirm({ value }: { value: number | null }) {
+  formData.value.contactNextTime = value ? new Date(value) : undefined
+}
+
+function handleBack() {
+  navigateBackPlus('/pages-crm/contact/index')
+}
+
+async function getDetail() {
+  if (!props.id) {
+    formData.value.ownerUserId = userStore.userInfo?.id
+    return
+  }
+  const data = await getContact(Number(props.id))
+  formData.value = data
+  if (data.contactNextTime) {
+    contactNextTimeValue.value = new Date(data.contactNextTime).getTime()
+  }
+}
+
+async function handleSubmit() {
+  const { valid } = await formRef.value?.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    if (props.id) {
+      await updateContact(formData.value)
+      toast.success('修改成功')
+    } else {
+      await createContact(formData.value)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+onMounted(() => {
+  getDetail()
+})
+</script>

+ 174 - 0
src/pages-crm/contact/index.vue

@@ -0,0 +1,174 @@
+<template>
+  <view class="yd-page-container">
+    <wd-navbar
+      title="联系人管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <view class="search-form-wrapper" :style="{ top: `${getNavbarHeight()}px` }">
+      <SearchForm @search="handleQuery" @reset="handleReset" />
+    </view>
+
+    <view class="p-24rpx" :style="{ paddingTop: `${getNavbarHeight() + 80}px` }">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="mb-24rpx rounded-12rpx bg-white"
+        @click="handleDetail(item)"
+      >
+        <view class="p-24rpx">
+          <view class="mb-16rpx flex items-center justify-between">
+            <view class="text-32rpx text-[#333] font-semibold">
+              {{ item.name }}
+            </view>
+            <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="item.sex" />
+          </view>
+          <view class="mb-12rpx flex items-center gap-32rpx text-26rpx text-[#999]">
+            <view class="flex items-center gap-8rpx">
+              <wd-icon name="phone" size="24rpx" />
+              <text>{{ item.mobile || '-' }}</text>
+            </view>
+            <view v-if="item.post" class="flex items-center gap-8rpx">
+              <wd-icon name="profile" size="24rpx" />
+              <text>{{ item.post }}</text>
+            </view>
+          </view>
+          <view class="mb-12rpx text-26rpx text-[#666]">
+            客户:{{ item.customerName || '-' }}
+          </view>
+          <view class="flex items-center justify-between text-24rpx text-[#666]">
+            <view class="flex items-center gap-8rpx">
+              <wd-icon name="map" size="22rpx" />
+              <text>{{ item.detailAddress || '-' }}</text>
+            </view>
+            <text>{{ formatDate(item.createTime) }}</text>
+          </view>
+          <view v-if="item.contactLastContent" class="mt-16rpx border-t border-[#f0f0f0] pt-16rpx">
+            <view class="mb-8rpx text-24rpx text-[#999]">
+              最后跟进:
+            </view>
+            <view class="text-26rpx text-[#666]">
+              {{ item.contactLastContent }}
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
+        <wd-status-tip image="content" tip="暂无联系人数据" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+    </view>
+
+    <wd-fab
+      v-if="hasAccessByCodes(['crm:contact:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { ContactVO } from '@/api/crm/contact'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom, onShow } from '@dcloudio/uni-app'
+import { ref } from 'vue'
+import { getContactPage } from '@/api/crm/contact'
+import { useAccess } from '@/hooks/useAccess'
+import { getNavbarHeight, navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDate } from '@/utils/date'
+import SearchForm from './components/search-form.vue'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const { hasAccessByCodes } = useAccess()
+const total = ref(0)
+const list = ref<ContactVO[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  sceneType: '1',
+})
+
+function handleBack() {
+  navigateBackPlus()
+}
+
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getContactPage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+function handleQuery(data?: Record<string, any>) {
+  queryParams.value = {
+    ...queryParams.value,
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+function handleReset() {
+  queryParams.value = {
+    pageNo: 1,
+    pageSize: 10,
+    sceneType: '1',
+  }
+  list.value = []
+  getList()
+}
+
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-crm/contact/form/index',
+  })
+}
+
+function handleDetail(item: ContactVO) {
+  uni.navigateTo({
+    url: `/pages-crm/contact/detail/index?id=${item.id}`,
+  })
+}
+
+onReachBottom(() => {
+  loadMore()
+})
+
+onShow(() => {
+  queryParams.value.pageNo = 1
+  list.value = []
+  getList()
+})
+</script>

+ 8 - 0
src/pages/index/index.ts

@@ -30,6 +30,14 @@ const menuGroupsData: MenuGroup[] = [
     key: 'crm',
     name: '客户管理',
     menus: [
+      {
+        key: 'crmContact',
+        name: '联系人管理',
+        icon: 'people',
+        url: '/pages-crm/contact/index',
+        iconColor: '#52c41a',
+        permission: 'crm:contact:query',
+      },
       {
         key: 'crmClue',
         name: '线索管理',

+ 10 - 1
src/style/index.scss

@@ -110,4 +110,13 @@ border-t-1
       @apply mt-16rpx flex justify-end gap-16rpx;
     }
   }
-}
+}
+
+// ==================== 搜索表单吸顶样式 ====================
+.search-form-wrapper {
+  position: fixed;
+  left: 0;
+  right: 0;
+  z-index: 10;
+  background-color: #fff;
+}