Переглянути джерело

feat: 新增客户管理模块-线索管理功能

- 新增线索列表、详情、编辑、新增页面
- 新增地区选择组件(省市区三级联动)
- 新增线索管理API接口封装
- 集成客户来源、行业、级别等字典选项
- 支持负责人选择、地址选择、下次联系时间等功能
- 实现数据刷新机制(列表/详情返回自动刷新)
pengjq 5 днів тому
батько
коміт
ddb6dbe3fd

+ 65 - 0
src/api/crm/clue/index.ts

@@ -0,0 +1,65 @@
+import { http } from '@/http/http'
+import type { TransferReqVO } from '@/api/crm/permission'
+
+export interface ClueVO {
+  id: number
+  name: string
+  followUpStatus: boolean
+  contactLastTime: Date
+  contactLastContent: string
+  contactNextTime: Date
+  ownerUserId: number
+  ownerUserName?: string
+  ownerUserDept?: string
+  transformStatus: boolean
+  customerId: number
+  customerName?: string
+  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 const getCluePage = async (params: any) => {
+  return await http.get('/crm/clue/page', params)
+}
+
+export const getClue = async (id: number) => {
+  return await http.get(`/crm/clue/get?id=${id}`)
+}
+
+export const createClue = async (data: ClueVO) => {
+  return await http.post('/crm/clue/create', data)
+}
+
+export const updateClue = async (data: ClueVO) => {
+  return await http.put('/crm/clue/update', data)
+}
+
+export const deleteClue = async (id: number) => {
+  return await http.delete(`/crm/clue/delete?id=${id}`)
+}
+
+export const transferClue = async (data: TransferReqVO) => {
+  return await http.put('/crm/clue/transfer', data)
+}
+
+export const transformClue = async (id: number) => {
+  return await http.put('/crm/clue/transform', { id })
+}
+
+export const getFollowClueCount = async () => {
+  return await http.get('/crm/clue/follow-count')
+}

+ 59 - 0
src/api/crm/permission/index.ts

@@ -0,0 +1,59 @@
+import { http } from '@/http/http'
+
+export interface PermissionVO {
+  id?: number
+  userId: number
+  bizType: number
+  bizId: number
+  level: number
+  toBizTypes?: number[]
+  deptName?: string
+  nickname?: string
+  postNames?: string[]
+  createTime?: Date
+  ids?: number[]
+}
+
+export interface TransferReqVO {
+  id: number
+  newOwnerUserId: number
+  oldOwnerPermissionLevel?: number
+  toBizTypes?: number[]
+}
+
+export enum BizTypeEnum {
+  CRM_CLUE = 1,
+  CRM_CUSTOMER = 2,
+  CRM_CONTACT = 3,
+  CRM_BUSINESS = 4,
+  CRM_CONTRACT = 5,
+  CRM_PRODUCT = 6,
+  CRM_RECEIVABLE = 7,
+  CRM_RECEIVABLE_PLAN = 8
+}
+
+export enum PermissionLevelEnum {
+  OWNER = 1,
+  READ = 2,
+  WRITE = 3
+}
+
+export const getPermissionList = async (params: any) => {
+  return await http.get({ url: '/crm/permission/list', params })
+}
+
+export const createPermission = async (data: PermissionVO) => {
+  return await http.post({ url: '/crm/permission/create', data })
+}
+
+export const updatePermission = async (data: any) => {
+  return await http.put({ url: '/crm/permission/update', data })
+}
+
+export const deletePermissionBatch = async (val: number[]) => {
+  return await http.delete({ url: `/crm/permission/delete?ids=${val.join(',')}` })
+}
+
+export const deleteSelfPermission = async (id: number) => {
+  return await http.delete({ url: `/crm/permission/delete-self?id=${id}` })
+}

+ 160 - 0
src/components/system-select/area-picker.vue

@@ -0,0 +1,160 @@
+<template>
+  <wd-col-picker
+    v-model="selectedValue"
+    :label="label"
+    label-width="180rpx"
+    :columns="columns"
+    value-key="id"
+    label-key="name"
+    :column-change="handleColumnChange"
+    :display-format="displayFormat"
+    placeholder="请选择地址"
+    @confirm="handleConfirm"
+  />
+</template>
+
+<script lang="ts" setup>
+import type { Area } from '@/api/system/area'
+import { onMounted, ref, watch } from 'vue'
+import { getAreaTree } from '@/api/system/area'
+
+const props = withDefaults(defineProps<{
+  modelValue?: number
+  label?: string
+}>(), {
+  label: '地址',
+})
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: number | undefined): void
+}>()
+
+const areaList = ref<Area[]>([])
+const columns = ref<any[]>([])
+const selectedValue = ref<number[]>([])
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val && val > 0 && areaList.value.length > 0) {
+      const path = findAreaPath(val)
+      selectedValue.value = path
+      buildColumnsForPath(path)
+    } else {
+      selectedValue.value = []
+      initFirstColumn()
+    }
+  },
+  { immediate: true },
+)
+
+async function loadAreaTree() {
+  areaList.value = await getAreaTree()
+  initFirstColumn()
+  if (props.modelValue && props.modelValue > 0) {
+    const path = findAreaPath(props.modelValue)
+    selectedValue.value = path
+    buildColumnsForPath(path)
+  }
+}
+
+function initFirstColumn() {
+  columns.value = [areaList.value]
+}
+
+function findAreaPath(targetId: number): number[] {
+  for (const province of areaList.value) {
+    if (province.id === targetId) {
+      return [province.id]
+    }
+    if (province.children?.length) {
+      for (const city of province.children) {
+        if (city.id === targetId) {
+          return [province.id, city.id]
+        }
+        if (city.children?.length) {
+          for (const district of city.children) {
+            if (district.id === targetId) {
+              return [province.id, city.id, district.id]
+            }
+          }
+        }
+      }
+    }
+  }
+  return []
+}
+
+function buildColumnsForPath(path: number[]) {
+  if (path.length === 0) return
+
+  const cols: any[][] = [areaList.value]
+  
+  for (let i = 0; i < path.length; i++) {
+    const areaId = path[i]
+    
+    for (const province of areaList.value) {
+      if (province.id === areaId) {
+        if (province.children && province.children.length > 0) {
+          cols.push(province.children)
+        }
+        break
+      }
+      if (province.children?.length) {
+        for (const city of province.children) {
+          if (city.id === areaId) {
+            if (city.children && city.children.length > 0) {
+              cols.push(city.children)
+            }
+            break
+          }
+        }
+      }
+    }
+  }
+  
+  columns.value = cols
+}
+
+function handleColumnChange({ selectedItem, resolve, finish }: any) {
+  for (const province of areaList.value) {
+    if (province.id === selectedItem.id) {
+      if (province.children && province.children.length > 0) {
+        resolve(province.children)
+      } else {
+        finish()
+      }
+      return
+    }
+    if (province.children?.length) {
+      for (const city of province.children) {
+        if (city.id === selectedItem.id) {
+          if (city.children && city.children.length > 0) {
+            resolve(city.children)
+          } else {
+            finish()
+          }
+          return
+        }
+      }
+    }
+  }
+  finish()
+}
+
+function displayFormat(selectedItems: any[]) {
+  return selectedItems.map(item => item.name).join('/')
+}
+
+function handleConfirm({ value }: { value: number[] }) {
+  if (value && value.length > 0) {
+    emit('update:modelValue', value[value.length - 1])
+  } else {
+    emit('update:modelValue', undefined)
+  }
+}
+
+onMounted(() => {
+  loadAreaTree()
+})
+</script>

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

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

+ 79 - 0
src/pages-crm/clue/components/search-form.vue

@@ -0,0 +1,79 @@
+<template>
+  <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>
+</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 formData = reactive({
+  name: undefined as string | undefined,
+  mobile: undefined as string | undefined,
+  ownerUserName: 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 handleSearch() {
+  visible.value = false
+  emit('search', { ...formData })
+}
+
+function handleReset() {
+  formData.name = undefined
+  formData.mobile = undefined
+  formData.ownerUserName = undefined
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 176 - 0
src/pages-crm/clue/detail/index.vue

@@ -0,0 +1,176 @@
+<template>
+  <view class="yd-page-container">
+    <wd-navbar
+      title="线索详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <view>
+      <wd-cell-group border>
+        <wd-cell title="线索名称" :value="formData?.name || '-'" />
+        <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="客户等级">
+          <dict-tag :type="DICT_TYPE.CRM_CLUE_LEVEL" :value="formData?.level" />
+        </wd-cell>
+        <wd-cell title="客户来源">
+          <dict-tag :type="DICT_TYPE.CRM_CLUE_SOURCE" :value="formData?.source" />
+        </wd-cell>
+        <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="跟进状态">
+          <dict-tag :type="DICT_TYPE.CRM_FOLLOWUP_STATUS" :value="formData?.followUpStatus" />
+        </wd-cell>
+        <wd-cell title="转化状态">
+          <dict-tag :type="DICT_TYPE.CRM_TRANSFORM_STATUS" :value="formData?.transformStatus" />
+        </wd-cell>
+        <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:clue:update'])"
+          class="flex-1" type="warning" @click="handleEdit"
+        >
+          编辑
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['crm:clue:transform'])"
+          class="flex-1" type="success" :loading="transforming" @click="handleTransform"
+        >
+          转化为客户
+        </wd-button>
+        <wd-button
+          v-if="hasAccessByCodes(['crm:clue:delete'])"
+          class="flex-1" type="error" :loading="deleting" @click="handleDelete"
+        >
+          删除
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { ClueVO } from '@/api/crm/clue'
+import { onShow } from '@dcloudio/uni-app'
+import { onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { deleteClue, getClue, transformClue } from '@/api/crm/clue'
+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<ClueVO>()
+const deleting = ref(false)
+const transforming = ref(false)
+
+function handleBack() {
+  navigateBackPlus('/pages-crm/clue/index')
+}
+
+async function getDetail() {
+  if (!props.id) {
+    return
+  }
+  try {
+    toast.loading('加载中...')
+    formData.value = await getClue(props.id)
+  } finally {
+    toast.close()
+  }
+}
+
+function handleEdit() {
+  uni.navigateTo({
+    url: `/pages-crm/clue/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 deleteClue(Number(props.id))
+        toast.success('删除成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        deleting.value = false
+      }
+    },
+  })
+}
+
+function handleTransform() {
+  if (!props.id) {
+    return
+  }
+  uni.showModal({
+    title: '提示',
+    content: '确定要将该线索转化为客户吗?',
+    success: async (res) => {
+      if (!res.confirm) {
+        return
+      }
+      transforming.value = true
+      try {
+        await transformClue(Number(props.id))
+        toast.success('转化成功')
+        setTimeout(() => {
+          handleBack()
+        }, 500)
+      } finally {
+        transforming.value = false
+      }
+    },
+  })
+}
+
+onMounted(() => {
+  getDetail()
+})
+
+onShow(() => {
+  // 当页面显示时,刷新详情数据
+  getDetail()
+})
+</script>

+ 281 - 0
src/pages-crm/clue/form/index.vue

@@ -0,0 +1,281 @@
+<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="请输入线索名称"
+          />
+          <wd-select-picker
+            v-model="formData.source"
+            label="客户来源"
+            label-width="180rpx"
+            :columns="sourceDictOptions"
+            value-key="value"
+            label-key="label"
+            type="radio"
+            placeholder="请选择"
+          />
+          <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-select-picker
+            v-model="formData.industryId"
+            label="客户行业"
+            label-width="180rpx"
+            :columns="industryDictOptions"
+            value-key="value"
+            label-key="label"
+            type="radio"
+            placeholder="请选择"
+          />
+          <wd-select-picker
+            v-model="formData.level"
+            label="客户级别"
+            label-width="180rpx"
+            :columns="levelDictOptions"
+            value-key="value"
+            label-key="label"
+            type="radio"
+            placeholder="请选择"
+          />
+          <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 { ClueVO } from '@/api/crm/clue'
+import { computed, onMounted, ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { createClue, getClue, updateClue } from '@/api/crm/clue'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { isEmail, isMobile } from '@/utils/validator'
+import { UserPicker, AreaPicker } from '@/components/system-select'
+import { useUserStore } from '@/store/user'
+
+const props = defineProps<{
+  id?: number | any
+}>()
+
+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<Partial<ClueVO>>({
+  id: undefined,
+  name: undefined,
+  contactNextTime: undefined,
+  ownerUserId: undefined,
+  mobile: undefined,
+  telephone: undefined,
+  qq: undefined,
+  wechat: undefined,
+  email: undefined,
+  areaId: undefined,
+  detailAddress: undefined,
+  industryId: undefined,
+  level: undefined,
+  source: undefined,
+  remark: undefined,
+})
+
+const contactNextTimeValue = ref<number | null>(null)
+
+const formRules = {
+  name: [{ required: true, message: '线索名称不能为空' }],
+  ownerUserId: [{ required: true, message: '负责人不能为空' }],
+  mobile: [
+    { required: true, message: '手机号码不能为空' },
+    { validator: (value: string) => isMobile(value), message: '请输入正确的手机号码' },
+  ],
+  email: [{ required: false, validator: (value: string) => !value || isEmail(value), message: '请输入正确的邮箱地址' }],
+}
+const formRef = ref<FormInstance>()
+
+const sourceDictOptions = computed(() => getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE))
+const industryDictOptions = computed(() => getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY))
+const levelDictOptions = computed(() => getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL))
+
+function handleDateTimeConfirm({ value }: { value: number | null }) {
+  formData.value.contactNextTime = value ? new Date(value) : undefined
+}
+
+function handleBack() {
+  navigateBackPlus('/pages-crm/clue/index')
+}
+
+async function getDetail() {
+  if (!props.id) {
+    formData.value.ownerUserId = userStore.userInfo?.id
+    return
+  }
+  const data = await getClue(Number(props.id))
+  formData.value = {
+    id: data.id,
+    name: data.name,
+    contactNextTime: data.contactNextTime ? new Date(data.contactNextTime) : undefined,
+    ownerUserId: data.ownerUserId,
+    mobile: data.mobile,
+    telephone: data.telephone,
+    qq: data.qq,
+    wechat: data.wechat,
+    email: data.email,
+    areaId: data.areaId,
+    detailAddress: data.detailAddress,
+    industryId: data.industryId,
+    level: data.level,
+    source: data.source,
+    remark: data.remark,
+  }
+  if (data.contactNextTime) {
+    contactNextTimeValue.value = new Date(data.contactNextTime).getTime()
+  }
+}
+
+function prepareFormData(): Partial<ClueVO> {
+  const data = { ...formData.value }
+  // 确保字段不是数组
+  if (Array.isArray(data.source)) {
+    data.source = data.source[0]
+  }
+  if (Array.isArray(data.industryId)) {
+    data.industryId = data.industryId[0]
+  }
+  if (Array.isArray(data.level)) {
+    data.level = data.level[0]
+  }
+  if (Array.isArray(data.ownerUserId)) {
+    data.ownerUserId = data.ownerUserId[0]
+  }
+  if (Array.isArray(data.areaId)) {
+    data.areaId = data.areaId[0]
+  }
+  return data
+}
+
+async function handleSubmit() {
+  const { valid } = await formRef.value?.validate()
+  if (!valid) {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    const submitData = prepareFormData()
+    if (props.id) {
+      await updateClue(submitData as ClueVO)
+      toast.success('修改成功')
+    } else {
+      await createClue(submitData as ClueVO)
+      toast.success('新增成功')
+    }
+    setTimeout(() => {
+      handleBack()
+    }, 500)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+onMounted(() => {
+  getDetail()
+})
+</script>

+ 166 - 0
src/pages-crm/clue/index.vue

@@ -0,0 +1,166 @@
+<template>
+  <view class="yd-page-container">
+    <wd-navbar
+      title="线索管理"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <SearchForm @search="handleQuery" @reset="handleReset" />
+
+    <view class="p-24rpx">
+      <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.CRM_CLUE_LEVEL" :value="item.level" />
+          </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.ownerUserName" class="flex items-center gap-8rpx">
+              <wd-icon name="user" size="24rpx" />
+              <text>{{ item.ownerUserName }}</text>
+            </view>
+          </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.areaName || '-' }}</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:clue:create'])"
+      position="right-bottom"
+      type="primary"
+      :expandable="false"
+      @click="handleAdd"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+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 { getCluePage } from '@/api/crm/clue'
+import { useAccess } from '@/hooks/useAccess'
+import { 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<ClueVO[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+function handleBack() {
+  navigateBackPlus()
+}
+
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getCluePage(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 = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+function handleReset() {
+  handleQuery()
+}
+
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+function handleAdd() {
+  uni.navigateTo({
+    url: '/pages-crm/clue/form/index',
+  })
+}
+
+function handleDetail(item: ClueVO) {
+  uni.navigateTo({
+    url: `/pages-crm/clue/detail/index?id=${item.id}`,
+  })
+}
+
+onReachBottom(() => {
+  loadMore()
+})
+
+onMounted(() => {
+  getList()
+})
+
+onShow(() => {
+  // 当页面显示时,刷新列表数据(第一页)
+  queryParams.value.pageNo = 1
+  list.value = []
+  getList()
+})
+</script>

+ 8 - 3
src/pages/index/components/banner.vue

@@ -22,9 +22,14 @@ defineOptions({
 
 /** Banner 轮播图数据 */
 const banners: string[] = [
-  staticUrl('/static/banner/banner01.png'),
-  staticUrl('/static/banner/banner02.png'),
-  staticUrl('/static/banner/banner03.png'),
+  'https://img1.baidu.com/it/u=2035287853,1835975660&fm=253&fmt=auto&app=138&f=JPEG?w=1000&h=500',
+  'https://img0.baidu.com/it/u=3282567666,1348910138&fm=253&fmt=auto&app=138&f=JPEG?w=971&h=405',
+  'https://img0.baidu.com/it/u=570586143,891948757&fm=253&fmt=auto&app=138&f=JPEG?w=972&h=427',
+  /**
+   *   staticUrl('/static/banner/banner01.png'),
+      staticUrl('/static/banner/banner02.png'),
+      staticUrl('/static/banner/banner03.png'),
+   */
 ]
 
 /** 处理点击 */

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

@@ -26,6 +26,20 @@ export interface MenuGroup {
 
 /** 菜单分组原始数据 */
 const menuGroupsData: MenuGroup[] = [
+  {
+    key: 'crm',
+    name: '客户管理',
+    menus: [
+      {
+        key: 'crmClue',
+        name: '线索管理',
+        icon: 'user',
+        url: '/pages-crm/clue/index',
+        iconColor: '#1890ff',
+        permission: 'crm:clue:query',
+      },
+    ],
+  },
   {
     key: 'system',
     name: '系统管理',

+ 12 - 0
src/utils/constants/dict-enum.ts

@@ -40,6 +40,17 @@ const INFRA_DICT = {
   INFRA_OPERATE_TYPE: 'infra_operate_type',
 } as const
 
+/** ========== CRM - 客户管理模块 ========== */
+const CRM_DICT = {
+  CRM_CLUE_LEVEL: 'crm_clue_level', // CRM 线索等级
+  CRM_CLUE_SOURCE: 'crm_clue_source', // CRM 线索来源
+  CRM_FOLLOWUP_STATUS: 'crm_followup_status', // CRM 跟进状态
+  CRM_TRANSFORM_STATUS: 'crm_transform_status', // CRM 转化状态
+  CRM_CUSTOMER_LEVEL: 'crm_customer_level', // CRM 客户级别
+  CRM_CUSTOMER_INDUSTRY: 'crm_customer_industry', // CRM 客户行业
+  CRM_CUSTOMER_SOURCE: 'crm_customer_source', // CRM 客户来源
+} as const
+
 /** ========== BPM - 工作流模块 ========== */
 const BPM_DICT = {
   BPM_MODEL_FORM_TYPE: 'bpm_model_form_type', // BPM 模型表单类型
@@ -54,6 +65,7 @@ const BPM_DICT = {
 
 /** 字典类型枚举 - 统一导出 */
 export const DICT_TYPE = {
+  ...CRM_DICT,
   ...BPM_DICT,
   ...INFRA_DICT,
   ...SYSTEM_DICT,

+ 1 - 0
vite.config.ts

@@ -77,6 +77,7 @@ export default defineConfig(({ command, mode }) => {
           'src/pages-system', // “系统管理”模块
           'src/pages-infra', // “基础设施”模块
           'src/pages-bpm', // “工作流程”模块
+          'src/pages-crm', // “客户关系管理”模块
         ],
         dts: 'src/types/uni-pages.d.ts',
       }),