From c3c8fa2c3b551d29b596902e800103123285ac7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Fri, 20 Mar 2026 01:13:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(credit):=20=E5=AE=8C=E5=96=84=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除自定义导航栏样式配置 - 添加员工列表加载和缓存机制 - 实现客户状态显示和状态选项功能 - 添加客户电话号码提取和展示功能 - 集成跟进人员标签显示功能 - 实现客户联系方式拨打功能 - 添加跟进历史记录存储机制 - 更新页面背景色和样式 - 添加固定底部跟进按钮 - 修改API基础URL配置 --- config/env.ts | 4 +- src/credit/mp-customer/detail.config.ts | 3 +- src/credit/mp-customer/detail.tsx | 322 ++++++++++++++++++------ src/credit/mp-customer/index.tsx | 32 +++ 4 files changed, 281 insertions(+), 80 deletions(-) diff --git a/config/env.ts b/config/env.ts index 0a5c838..aefa726 100644 --- a/config/env.ts +++ b/config/env.ts @@ -2,8 +2,8 @@ export const ENV_CONFIG = { // 开发环境 development: { - API_BASE_URL: 'http://127.0.0.1:9200/api', - // API_BASE_URL: 'https://ysb-api.websoft.top/api', + // API_BASE_URL: 'http://127.0.0.1:9200/api', + API_BASE_URL: 'https://ysb-api.websoft.top/api', APP_NAME: '开发环境', DEBUG: 'true', }, diff --git a/src/credit/mp-customer/detail.config.ts b/src/credit/mp-customer/detail.config.ts index 0aa6f01..7fc1abf 100644 --- a/src/credit/mp-customer/detail.config.ts +++ b/src/credit/mp-customer/detail.config.ts @@ -1,7 +1,6 @@ export default definePageConfig({ navigationBarTitleText: '客户详情', navigationBarTextStyle: 'black', - navigationBarBackgroundColor: '#ffffff', - navigationStyle: 'custom' + navigationBarBackgroundColor: '#ffffff' }) diff --git a/src/credit/mp-customer/detail.tsx b/src/credit/mp-customer/detail.tsx index b24499a..77ffd8e 100644 --- a/src/credit/mp-customer/detail.tsx +++ b/src/credit/mp-customer/detail.tsx @@ -1,12 +1,14 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import Taro, { useDidShow, useRouter } from '@tarojs/taro' import { View, Text } from '@tarojs/components' -import { Button, Cell, CellGroup, ConfigProvider, Empty, Loading } from '@nutui/nutui-react-taro' -import { Setting } from '@nutui/icons-react-taro' +import { Cell, CellGroup, ConfigProvider, Empty, Loading, Tag } from '@nutui/nutui-react-taro' import dayjs from 'dayjs' import { getCreditMpCustomer } from '@/api/credit/creditMpCustomer' import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model' +import { listUsers } from '@/api/system/user' +import type { User } from '@/api/system/user/model' +import FixedButton from '@/components/FixedButton' const fmtTime = (t?: string) => { const txt = String(t || '').trim() @@ -28,6 +30,77 @@ const buildDesc = (row?: CreditMpCustomer | null) => { return [price, years, loc].filter(Boolean).join(' · ') } +type CustomerStatus = '保护期内' | '已签约' | '已完成' | '保护期外' +const STATUS_OPTIONS: CustomerStatus[] = ['保护期内', '已签约', '已完成', '保护期外'] +const FOLLOW_HISTORY_KEY_PREFIX = 'credit_mp_customer_follow_user_history:' + +const safeParseJSON = (v: any): T | null => { + try { + if (!v) return null + if (typeof v === 'object') return v as T + if (typeof v === 'string') return JSON.parse(v) as T + return null + } catch (_e) { + return null + } +} + +const splitPhones = (raw?: string) => { + const text = String(raw || '').trim() + if (!text) return [] + return text + .split(/[\s,,;;、\n\r]+/g) + .map(s => s.trim()) + .filter(Boolean) +} + +const uniq = (arr: T[]) => Array.from(new Set(arr)) + +const pickPhoneLike = (raw?: string) => { + const txt = String(raw || '').trim() + if (!txt) return [] + const picked = txt.match(/1\d{10}/g) || [] + const bySplit = splitPhones(txt) + return uniq([...picked, ...bySplit]) +} + +const getCustomerPhones = (row?: CreditMpCustomer | null) => { + if (!row) return [] + const anyRow = row as any + const pool = [ + anyRow?.tel, + anyRow?.moreTel, + anyRow?.phone, + anyRow?.mobile, + anyRow?.contactPhone, + anyRow?.otherPhone, + anyRow?.otherPhones, + row.comments + ] + const arr = pool.flatMap(v => pickPhoneLike(String(v || ''))) + return uniq(arr) + .map(s => String(s).trim()) + .filter(Boolean) +} + +const getCustomerStatusText = (row?: CreditMpCustomer | null): string => { + if (!row) return '' + const anyRow = row as any + return String(anyRow?.customerStatus || anyRow?.statusTxt || anyRow?.statusText || '').trim() +} + +const loadFollowHistoryIds = (customerId: number): number[] => { + try { + const raw = Taro.getStorageSync(`${FOLLOW_HISTORY_KEY_PREFIX}${customerId}`) + const arr = safeParseJSON(raw) || [] + return arr + .map(v => Number(v)) + .filter(v => Number.isFinite(v) && v > 0) + } catch (_e) { + return [] + } +} + export default function CreditMpCustomerDetailPage() { const router = useRouter() const rowId = useMemo(() => { @@ -35,18 +108,11 @@ export default function CreditMpCustomerDetailPage() { return Number.isFinite(id) && id > 0 ? id : undefined }, [router?.params]) - const statusBarHeight = useMemo(() => { - try { - const info = Taro.getSystemInfoSync() - return Number(info?.statusBarHeight || 0) - } catch (_e) { - return 0 - } - }, []) - + const staffLoadingPromiseRef = useRef | null>(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [row, setRow] = useState(null) + const [staffList, setStaffList] = useState([]) const reload = useCallback(async () => { setError(null) @@ -64,103 +130,207 @@ export default function CreditMpCustomerDetailPage() { } }, [rowId]) + const ensureStaffLoaded = useCallback(async (): Promise => { + if (staffList.length) return staffList + if (staffLoadingPromiseRef.current) return staffLoadingPromiseRef.current + + const p = (async () => { + try { + const res = await listUsers({ isStaff: true } as any) + const arr = (res || []) as User[] + setStaffList(arr) + return arr + } catch (_e) { + return [] + } finally { + staffLoadingPromiseRef.current = null + } + })() + staffLoadingPromiseRef.current = p + return p + }, [staffList]) + useDidShow(() => { + Taro.setNavigationBarTitle({ title: '客户详情' }) reload().then() + ensureStaffLoaded().then() }) - const headerOffset = statusBarHeight + 80 - const title = String(row?.toUser || '').trim() || '客户详情' + const title = String(row?.toUser || '').trim() || '—' const desc = buildDesc(row) + const statusText = useMemo(() => getCustomerStatusText(row), [row]) + + const staffNameMap = useMemo(() => { + const map = new Map() + for (const u of staffList) { + if (!u?.userId) continue + map.set(u.userId, String(u.realName || u.nickname || u.username || `员工${u.userId}`)) + } + return map + }, [staffList]) + + const phones = useMemo(() => getCustomerPhones(row), [row]) + + const followerIds = useMemo(() => { + if (!rowId) return [] + const anyRow = row as any + const fromRow: number[] = [] + const fromStorage = loadFollowHistoryIds(rowId) + + const ids1 = anyRow?.followUserIds + if (Array.isArray(ids1)) fromRow.push(...ids1.map((v: any) => Number(v))) + + const ids2 = anyRow?.followUsers + if (Array.isArray(ids2)) fromRow.push(...ids2.map((v: any) => Number(v?.userId))) + + return uniq([...fromRow, ...fromStorage]) + .map(v => Number(v)) + .filter(v => Number.isFinite(v) && v > 0) + }, [row, rowId]) + + const followerTags = useMemo(() => { + const currentId = Number(row?.userId) + const currentName = + String((row as any)?.realName || (row as any)?.userRealName || (row as any)?.followRealName || '').trim() || + (Number.isFinite(currentId) && currentId > 0 ? staffNameMap.get(currentId) : '') || + '' + + const others = followerIds.filter(id => id !== currentId) + const otherNames = uniq(others.map(id => staffNameMap.get(id) || `员工${id}`)) + .filter(Boolean) + .filter(n => String(n) !== currentName) + + const tags = otherNames.map(n => ({ text: n, current: false })) + if (currentName) tags.push({ text: currentName, current: true }) + return tags + }, [followerIds, row, staffNameMap]) + + const statusSelected = useMemo(() => { + const t = String(statusText || '').trim() + return STATUS_OPTIONS.includes(t as any) ? (t as CustomerStatus) : undefined + }, [statusText]) + + const goFollow = () => { + if (!rowId) return + Taro.navigateTo({ url: `/credit/mp-customer/follow-step1?id=${rowId}` }) + } return ( - + - - - 12:00 - - 信号 - Wi-Fi - 电池 - - - - - Taro.navigateBack()}> - 返回 - - 客户详情 - - { - try { - const res = await Taro.showActionSheet({ itemList: ['编辑客户', '刷新'] }) - if (res.tapIndex === 0 && rowId) Taro.navigateTo({ url: `/credit/creditMpCustomer/add?id=${rowId}` }) - if (res.tapIndex === 1) reload() - } catch (e) { - const msg = String((e as any)?.errMsg || (e as any)?.message || e || '') - if (msg.includes('cancel')) return - } - }} - > - ... - - Taro.showToast({ title: '设置(示意)', icon: 'none' })} - > - - - - - - - + {loading ? ( 加载中... ) : error ? ( - + {error} - - - + 点击重试 ) : !row ? ( - + ) : ( - - {title} - {!!desc && ( - - {desc} + + + + + {title} + {!!desc && {desc}} + + + {statusText || '—'} + - )} - + + 客户状态 + + {STATUS_OPTIONS.map(s => { + const active = s === statusSelected + return ( + + {s} + + ) + })} + + + + + + 客户联系方式 + {phones.length ? ( + + {phones.map(p => ( + Taro.makePhoneCall({ phoneNumber: p }).catch(() => {})} + > + {p} + + ))} + + ) : ( + 暂无电话 + )} + {!!row.comments && ( + + 备注:{String(row.comments)} + + )} + + + + 跟进人 + {followerTags.length ? ( + + {followerTags.map(t => ( + + {t.text} + + ))} + + ) : ( + 未分配 + )} + + + - - - + + + {!!row.url && } {!!row.files && } - {!!row.comments && } )} + + ) } - diff --git a/src/credit/mp-customer/index.tsx b/src/credit/mp-customer/index.tsx index 52f5e30..b6504c0 100644 --- a/src/credit/mp-customer/index.tsx +++ b/src/credit/mp-customer/index.tsx @@ -38,6 +38,8 @@ const STEP_STATUS_TEXT: Record = { const STEP_OPTIONS = [0, 1, 2, 3, 4, 5].map(code => ({ code, text: STEP_STATUS_TEXT[code] })) +const FOLLOW_HISTORY_KEY_PREFIX = 'credit_mp_customer_follow_user_history:' + const safeParseJSON = (v: any): T | null => { try { if (!v) return null @@ -140,6 +142,29 @@ const filterCustomers = (incoming: CreditMpCustomer[], filters: FilterValues) => }) } +const loadFollowHistoryIds = (customerId: number): number[] => { + try { + const raw = Taro.getStorageSync(`${FOLLOW_HISTORY_KEY_PREFIX}${customerId}`) + const arr = safeParseJSON(raw) || [] + return arr + .map(v => Number(v)) + .filter(v => Number.isFinite(v) && v > 0) + } catch (_e) { + return [] + } +} + +const saveFollowHistoryIds = (customerId: number, ids: number[]) => { + const uniq = Array.from(new Set(ids)) + .map(v => Number(v)) + .filter(v => Number.isFinite(v) && v > 0) + try { + Taro.setStorageSync(`${FOLLOW_HISTORY_KEY_PREFIX}${customerId}`, JSON.stringify(uniq)) + } catch (_e) { + // ignore + } +} + export default function CreditCompanyPage() { const serverPageRef = useRef(1) const staffLoadingPromiseRef = useRef | null>(null) @@ -449,6 +474,13 @@ export default function CreditCompanyPage() { for (const id of ids) { const detail = await getCreditMpCustomer(Number(id)) + const prevOwner = Number((detail as any)?.userId) + if (Number.isFinite(prevOwner) || Number.isFinite(userId)) { + const history = loadFollowHistoryIds(Number(id)) + history.push(prevOwner) + history.push(userId) + saveFollowHistoryIds(Number(id), history) + } await updateCreditMpCustomer({ ...(detail || {}), id, userId } as any) } Taro.showToast({ title: '分配成功', icon: 'success' })