feat(credit): 完善客户详情页面功能
- 移除自定义导航栏样式配置 - 添加员工列表加载和缓存机制 - 实现客户状态显示和状态选项功能 - 添加客户电话号码提取和展示功能 - 集成跟进人员标签显示功能 - 实现客户联系方式拨打功能 - 添加跟进历史记录存储机制 - 更新页面背景色和样式 - 添加固定底部跟进按钮 - 修改API基础URL配置
This commit is contained in:
@@ -8,6 +8,24 @@ export interface CreditMpCustomer {
|
||||
id?: number;
|
||||
// 拖欠方
|
||||
toUser?: string;
|
||||
// 联系人(如后端提供)
|
||||
contact?: string;
|
||||
// 联系人姓名(兼容字段)
|
||||
contactName?: string;
|
||||
// 联系电话(主)
|
||||
tel?: string;
|
||||
// 其他联系电话(多个用分隔符拼接)
|
||||
moreTel?: string;
|
||||
// 联系电话(兼容字段)
|
||||
phone?: string;
|
||||
// 手机号(兼容字段)
|
||||
mobile?: string;
|
||||
// 联系人手机(兼容字段)
|
||||
contactPhone?: string;
|
||||
// 其他电话(兼容字段)
|
||||
otherPhone?: string;
|
||||
// 其他电话集合(兼容字段)
|
||||
otherPhones?: string;
|
||||
// 拖欠金额
|
||||
price?: string;
|
||||
// 拖欠年数
|
||||
@@ -16,6 +34,10 @@ export interface CreditMpCustomer {
|
||||
url?: string;
|
||||
// 状态
|
||||
statusTxt?: string;
|
||||
// 状态(兼容字段)
|
||||
statusText?: string;
|
||||
// 客户状态(兼容字段)
|
||||
customerStatus?: string;
|
||||
// 企业ID
|
||||
companyId?: number;
|
||||
// 所在省份
|
||||
@@ -30,8 +52,33 @@ export interface CreditMpCustomer {
|
||||
hasData?: string;
|
||||
// 步骤
|
||||
step?: number;
|
||||
// 步骤(兼容字段)
|
||||
stepStatus?: number;
|
||||
// 步骤(兼容字段)
|
||||
stepNum?: number;
|
||||
// 步骤(兼容字段)
|
||||
stepCode?: number;
|
||||
// 步骤文字(兼容字段)
|
||||
stepTxt?: string;
|
||||
// 步骤文字(兼容字段)
|
||||
stepText?: string;
|
||||
// 备注
|
||||
comments?: string;
|
||||
// 跟进人ID列表(兼容字段)
|
||||
followUserIds?: number[];
|
||||
// 跟进人列表(兼容字段)
|
||||
followUsers?: Array<{
|
||||
userId?: number;
|
||||
realName?: string;
|
||||
nickname?: string;
|
||||
username?: string;
|
||||
}>;
|
||||
// 跟进人姓名(兼容字段)
|
||||
realName?: string;
|
||||
// 跟进人姓名(兼容字段)
|
||||
userRealName?: string;
|
||||
// 跟进人姓名(兼容字段)
|
||||
followRealName?: string;
|
||||
// 是否推荐
|
||||
recommend?: number;
|
||||
// 排序(数字越小越靠前)
|
||||
@@ -48,6 +95,37 @@ export interface CreditMpCustomer {
|
||||
createTime?: string;
|
||||
// 修改时间
|
||||
updateTime?: string;
|
||||
|
||||
// 跟进 Step1:是否已提交
|
||||
followStep1Submitted?: number | boolean;
|
||||
// 跟进 Step1:提交时间
|
||||
followStep1SubmittedAt?: string;
|
||||
// 跟进 Step1:联系人(冗余)
|
||||
followStep1Contact?: string;
|
||||
// 跟进 Step1:联系电话(冗余)
|
||||
followStep1Phone?: string;
|
||||
// 跟进 Step1:电话录音URL
|
||||
followStep1AudioUrl?: string;
|
||||
// 跟进 Step1:电话录音文件名
|
||||
followStep1AudioName?: string;
|
||||
// 跟进 Step1:短信截图(JSON字符串)
|
||||
followStep1SmsShots?: string;
|
||||
// 跟进 Step1:电话沟通截图(JSON字符串)
|
||||
followStep1CallShots?: string;
|
||||
// 跟进 Step1:沟通情况
|
||||
followStep1Remark?: string;
|
||||
// 跟进 Step1:意向选择(无意向/有意向)
|
||||
followStep1Intention?: string;
|
||||
// 跟进 Step1:沟通次数
|
||||
followStep1CommunicationCount?: number;
|
||||
// 跟进 Step1:是否需要审核
|
||||
followStep1NeedApproval?: number | boolean;
|
||||
// 跟进 Step1:是否已审核通过
|
||||
followStep1Approved?: number | boolean;
|
||||
// 跟进 Step1:审核时间
|
||||
followStep1ApprovedAt?: string;
|
||||
// 跟进 Step1:审核人
|
||||
followStep1ApprovedBy?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Cell, CellGroup, ConfigProvider, Empty, Loading, Tag } from '@nutui/nutui-react-taro'
|
||||
import { Image, View, Text } from '@tarojs/components'
|
||||
import { Button, Cell, CellGroup, ConfigProvider, Empty, Loading, Tag } from '@nutui/nutui-react-taro'
|
||||
import dayjs from 'dayjs'
|
||||
import { Close } from '@nutui/icons-react-taro'
|
||||
|
||||
import { getCreditMpCustomer } from '@/api/credit/creditMpCustomer'
|
||||
import { getCreditMpCustomer, updateCreditMpCustomer } 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 { uploadFileByPath } from '@/api/system/file'
|
||||
import FixedButton from '@/components/FixedButton'
|
||||
|
||||
const fmtTime = (t?: string) => {
|
||||
@@ -34,6 +36,15 @@ type CustomerStatus = '保护期内' | '已签约' | '已完成' | '保护期外
|
||||
const STATUS_OPTIONS: CustomerStatus[] = ['保护期内', '已签约', '已完成', '保护期外']
|
||||
const FOLLOW_HISTORY_KEY_PREFIX = 'credit_mp_customer_follow_user_history:'
|
||||
|
||||
const STEP_STATUS_TEXT: Record<number, string> = {
|
||||
0: '未受理',
|
||||
1: '已受理',
|
||||
2: '材料提交',
|
||||
3: '合同签订',
|
||||
4: '执行回款',
|
||||
5: '完结'
|
||||
}
|
||||
|
||||
const safeParseJSON = <T,>(v: any): T | null => {
|
||||
try {
|
||||
if (!v) return null
|
||||
@@ -56,6 +67,43 @@ const splitPhones = (raw?: string) => {
|
||||
|
||||
const uniq = <T,>(arr: T[]) => Array.from(new Set(arr))
|
||||
|
||||
type Attachment = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
isImage: boolean
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
const isHttpUrl = (url?: string) => {
|
||||
if (!url) return false
|
||||
return /^https?:\/\//i.test(url)
|
||||
}
|
||||
|
||||
const guessFileType = (nameOrUrl?: string) => {
|
||||
const s = String(nameOrUrl || '').trim()
|
||||
if (!s) return undefined
|
||||
const clean = s.split('?')[0].split('#')[0]
|
||||
const dot = clean.lastIndexOf('.')
|
||||
if (dot < 0) return undefined
|
||||
const ext = clean.slice(dot + 1).toLowerCase()
|
||||
if (!/^[a-z0-9]+$/.test(ext)) return undefined
|
||||
return ext
|
||||
}
|
||||
|
||||
const isImageUrl = (url?: string) => {
|
||||
const ext = guessFileType(url)
|
||||
if (!ext) return false
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic'].includes(ext)
|
||||
}
|
||||
|
||||
const guessNameFromUrl = (url: string, fallback: string) => {
|
||||
const clean = String(url || '').split('?')[0].split('#')[0]
|
||||
const lastSlash = clean.lastIndexOf('/')
|
||||
const base = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
|
||||
return String(base || fallback).trim()
|
||||
}
|
||||
|
||||
const pickPhoneLike = (raw?: string) => {
|
||||
const txt = String(raw || '').trim()
|
||||
if (!txt) return []
|
||||
@@ -101,6 +149,47 @@ const loadFollowHistoryIds = (customerId: number): number[] => {
|
||||
}
|
||||
}
|
||||
|
||||
const parseFilesToAttachments = (raw?: any): Attachment[] => {
|
||||
const txt = String(raw || '').trim()
|
||||
if (!txt) return []
|
||||
|
||||
const parsed = safeParseJSON<any>(txt)
|
||||
const nowId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((it, idx) => {
|
||||
if (!it) return null
|
||||
if (typeof it === 'string') {
|
||||
const url = String(it).trim()
|
||||
if (!url) return null
|
||||
const name = guessNameFromUrl(url, `附件${idx + 1}`)
|
||||
return { id: nowId(), name, url, isImage: isImageUrl(url) } as Attachment
|
||||
}
|
||||
const url = String(it?.url || it?.downloadUrl || it?.thumbnail || it?.path || '').trim()
|
||||
if (!url) return null
|
||||
const name = String(it?.name || guessNameFromUrl(url, `附件${idx + 1}`)).trim()
|
||||
const thumbnail = it?.thumbnail ? String(it.thumbnail) : undefined
|
||||
const isImage = it?.isImage !== undefined ? !!it.isImage : isImageUrl(url)
|
||||
return { id: nowId(), name, url, thumbnail, isImage } as Attachment
|
||||
})
|
||||
.filter(Boolean) as Attachment[]
|
||||
}
|
||||
|
||||
// 非 JSON:尝试按分隔符拆分成多条 url
|
||||
const parts = txt
|
||||
.split(/[\n,,;;\s]+/g)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
if (!parts.length) return []
|
||||
return parts.map((url, idx) => ({
|
||||
id: nowId(),
|
||||
url,
|
||||
name: guessNameFromUrl(url, `附件${idx + 1}`),
|
||||
isImage: isImageUrl(url)
|
||||
}))
|
||||
}
|
||||
|
||||
export default function CreditMpCustomerDetailPage() {
|
||||
const router = useRouter()
|
||||
const rowId = useMemo(() => {
|
||||
@@ -113,6 +202,9 @@ export default function CreditMpCustomerDetailPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [row, setRow] = useState<CreditMpCustomer | null>(null)
|
||||
const [staffList, setStaffList] = useState<User[]>([])
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [savingFiles, setSavingFiles] = useState(false)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setError(null)
|
||||
@@ -121,6 +213,7 @@ export default function CreditMpCustomerDetailPage() {
|
||||
if (!rowId) throw new Error('缺少客户ID')
|
||||
const res = await getCreditMpCustomer(rowId)
|
||||
setRow((res || null) as CreditMpCustomer | null)
|
||||
setAttachments(parseFilesToAttachments((res as any)?.files))
|
||||
} catch (e) {
|
||||
console.error('加载客户详情失败:', e)
|
||||
setRow(null)
|
||||
@@ -159,6 +252,15 @@ export default function CreditMpCustomerDetailPage() {
|
||||
const title = String(row?.toUser || '').trim() || '—'
|
||||
const desc = buildDesc(row)
|
||||
const statusText = useMemo(() => getCustomerStatusText(row), [row])
|
||||
const stepText = useMemo(() => {
|
||||
const anyRow = row as any
|
||||
const raw = anyRow?.step ?? anyRow?.stepStatus ?? anyRow?.stepNum ?? anyRow?.stepCode
|
||||
const n = Number(raw)
|
||||
if (Number.isInteger(n) && n in STEP_STATUS_TEXT) return STEP_STATUS_TEXT[n]
|
||||
const txt = String(anyRow?.stepTxt || anyRow?.stepText || '').trim()
|
||||
if (txt) return txt
|
||||
return '—'
|
||||
}, [row])
|
||||
|
||||
const staffNameMap = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
@@ -171,6 +273,210 @@ export default function CreditMpCustomerDetailPage() {
|
||||
|
||||
const phones = useMemo(() => getCustomerPhones(row), [row])
|
||||
|
||||
const imageAttachments = useMemo(() => attachments.filter(a => a.isImage), [attachments])
|
||||
const fileAttachments = useMemo(() => attachments.filter(a => !a.isImage), [attachments])
|
||||
|
||||
const saveAttachments = useCallback(
|
||||
async (next: Attachment[]) => {
|
||||
if (!rowId || !row) return
|
||||
if (savingFiles) return
|
||||
setSavingFiles(true)
|
||||
try {
|
||||
const filesPayload = next.map(a => ({
|
||||
name: a.name,
|
||||
url: a.url,
|
||||
thumbnail: a.thumbnail,
|
||||
isImage: a.isImage
|
||||
}))
|
||||
const nextFiles = filesPayload.length ? JSON.stringify(filesPayload) : undefined
|
||||
await updateCreditMpCustomer({ ...(row as any), id: rowId, files: nextFiles } as any)
|
||||
setAttachments(next)
|
||||
setRow(prev => (prev ? ({ ...prev, files: nextFiles } as any) : prev))
|
||||
Taro.showToast({ title: '已更新', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.error('更新附件失败:', e)
|
||||
Taro.showToast({ title: (e as any)?.message || '更新失败', icon: 'none' })
|
||||
} finally {
|
||||
setSavingFiles(false)
|
||||
}
|
||||
},
|
||||
[row, rowId, savingFiles]
|
||||
)
|
||||
|
||||
const previewAttachment = useCallback(
|
||||
async (a: Attachment) => {
|
||||
try {
|
||||
if (a.isImage) {
|
||||
const urls = imageAttachments.map(x => x.url)
|
||||
await Taro.previewImage({ urls, current: a.url })
|
||||
return
|
||||
}
|
||||
|
||||
let filePath = a.url
|
||||
if (isHttpUrl(a.url)) {
|
||||
const dl = await Taro.downloadFile({ url: a.url })
|
||||
// @ts-ignore
|
||||
filePath = dl?.tempFilePath
|
||||
}
|
||||
|
||||
const fileType = guessFileType(a.name || a.url)
|
||||
const openOpts: any = { filePath, showMenu: true }
|
||||
if (fileType) openOpts.fileType = fileType
|
||||
await Taro.openDocument(openOpts)
|
||||
} catch (e) {
|
||||
console.error('预览附件失败:', e)
|
||||
Taro.showToast({ title: '无法打开该文件', icon: 'none' })
|
||||
}
|
||||
},
|
||||
[imageAttachments]
|
||||
)
|
||||
|
||||
const chooseAndUploadImages = useCallback(async () => {
|
||||
let res: any
|
||||
try {
|
||||
res = await Taro.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera']
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
Taro.showToast({ title: '选择图片失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const tempFilePaths = (res?.tempFilePaths || []) as string[]
|
||||
if (!tempFilePaths.length) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const uploaded: Attachment[] = []
|
||||
for (const p of tempFilePaths) {
|
||||
const record: any = await uploadFileByPath(p)
|
||||
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
|
||||
if (!url) continue
|
||||
uploaded.push({
|
||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
name: String(record?.name || guessNameFromUrl(url, '图片')).trim(),
|
||||
url,
|
||||
thumbnail: record?.thumbnail ? String(record.thumbnail) : undefined,
|
||||
isImage: true
|
||||
})
|
||||
}
|
||||
if (!uploaded.length) return
|
||||
await saveAttachments(attachments.concat(uploaded))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [attachments, saveAttachments])
|
||||
|
||||
const chooseAndUploadFiles = useCallback(async () => {
|
||||
let res: any
|
||||
try {
|
||||
// @ts-ignore
|
||||
res = await Taro.chooseMessageFile({ count: 9, type: 'file' })
|
||||
} catch (e) {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
console.error('选择文件失败:', e)
|
||||
Taro.showToast({ title: '当前环境不支持选取文件', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const tempFiles = (res?.tempFiles || []) as Array<{ path?: string; name?: string }>
|
||||
const picked = tempFiles
|
||||
.map(f => ({ path: f?.path, name: f?.name }))
|
||||
.filter(f => Boolean(f.path)) as Array<{ path: string; name?: string }>
|
||||
if (!picked.length) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const uploaded: Attachment[] = []
|
||||
for (const f of picked) {
|
||||
const record: any = await uploadFileByPath(f.path)
|
||||
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
|
||||
if (!url) continue
|
||||
uploaded.push({
|
||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
name: String(f?.name || record?.name || guessNameFromUrl(url, '文件')).trim(),
|
||||
url,
|
||||
thumbnail: record?.thumbnail ? String(record.thumbnail) : undefined,
|
||||
isImage: false
|
||||
})
|
||||
}
|
||||
if (!uploaded.length) return
|
||||
await saveAttachments(attachments.concat(uploaded))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [attachments, saveAttachments])
|
||||
|
||||
const onAttachmentAction = useCallback(
|
||||
async (a: Attachment) => {
|
||||
try {
|
||||
const res = await Taro.showActionSheet({ itemList: ['重新上传', '删除'] })
|
||||
if (res.tapIndex === 0) {
|
||||
if (a.isImage) {
|
||||
const img = await Taro.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] })
|
||||
const p = String((img?.tempFilePaths || [])[0] || '').trim()
|
||||
if (!p) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const record: any = await uploadFileByPath(p)
|
||||
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
|
||||
if (!url) return
|
||||
const next = attachments.map(x =>
|
||||
x.id === a.id
|
||||
? { ...x, url, thumbnail: record?.thumbnail ? String(record.thumbnail) : undefined, name: String(record?.name || x.name) }
|
||||
: x
|
||||
)
|
||||
await saveAttachments(next)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const file = await Taro.chooseMessageFile({ count: 1, type: 'file' })
|
||||
// @ts-ignore
|
||||
const p = String((file?.tempFiles || [])[0]?.path || '').trim()
|
||||
// @ts-ignore
|
||||
const n = String((file?.tempFiles || [])[0]?.name || '').trim()
|
||||
if (!p) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const record: any = await uploadFileByPath(p)
|
||||
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
|
||||
if (!url) return
|
||||
const next = attachments.map(x =>
|
||||
x.id === a.id
|
||||
? {
|
||||
...x,
|
||||
url,
|
||||
thumbnail: record?.thumbnail ? String(record.thumbnail) : undefined,
|
||||
name: String(n || record?.name || x.name)
|
||||
}
|
||||
: x
|
||||
)
|
||||
await saveAttachments(next)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (res.tapIndex === 1) {
|
||||
await saveAttachments(attachments.filter(x => x.id !== a.id))
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
console.error('操作附件失败:', e)
|
||||
}
|
||||
},
|
||||
[attachments, saveAttachments]
|
||||
)
|
||||
|
||||
const followerIds = useMemo(() => {
|
||||
if (!rowId) return []
|
||||
const anyRow = row as any
|
||||
@@ -292,6 +598,82 @@ export default function CreditMpCustomerDetailPage() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-4">
|
||||
<View className="flex items-center justify-between">
|
||||
<Text className="text-sm font-medium text-gray-900">图片</Text>
|
||||
<Button size="small" fill="outline" disabled={uploading || savingFiles} loading={uploading} onClick={chooseAndUploadImages}>
|
||||
{uploading ? '上传中...' : '上传图片'}
|
||||
</Button>
|
||||
</View>
|
||||
{imageAttachments.length ? (
|
||||
<View className="mt-3 grid grid-cols-3 gap-3">
|
||||
{imageAttachments.map(a => (
|
||||
<View
|
||||
key={a.id}
|
||||
className="relative w-full"
|
||||
style={{ paddingTop: '100%' }}
|
||||
onClick={() => previewAttachment(a)}
|
||||
onLongPress={() => onAttachmentAction(a)}
|
||||
>
|
||||
<View className="absolute inset-0 rounded-lg overflow-hidden bg-gray-100">
|
||||
<Image src={a.thumbnail || a.url} mode="aspectFill" className="w-full h-full" />
|
||||
</View>
|
||||
<View
|
||||
className="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
saveAttachments(attachments.filter(x => x.id !== a.id)).then()
|
||||
}}
|
||||
>
|
||||
<Close size={12} color="#fff" />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="mt-2 text-xs text-gray-500">未上传图片</View>
|
||||
)}
|
||||
<View className="mt-2 text-xs text-gray-400">长按图片可重新上传或删除</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-4">
|
||||
<View className="flex items-center justify-between">
|
||||
<Text className="text-sm font-medium text-gray-900">文件</Text>
|
||||
<Button size="small" fill="outline" disabled={uploading || savingFiles} loading={uploading} onClick={chooseAndUploadFiles}>
|
||||
{uploading ? '上传中...' : '上传文件'}
|
||||
</Button>
|
||||
</View>
|
||||
{fileAttachments.length ? (
|
||||
<View className="mt-3 flex flex-col gap-2">
|
||||
{fileAttachments.map(a => (
|
||||
<View
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-200 bg-gray-50"
|
||||
onClick={() => previewAttachment(a)}
|
||||
onLongPress={() => onAttachmentAction(a)}
|
||||
>
|
||||
<Text className="text-xs text-gray-700 truncate flex-1">
|
||||
{a.name}
|
||||
</Text>
|
||||
<View
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-gray-500"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
saveAttachments(attachments.filter(x => x.id !== a.id)).then()
|
||||
}}
|
||||
>
|
||||
<Close size={12} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="mt-2 text-xs text-gray-500">未上传文件</View>
|
||||
)}
|
||||
<View className="mt-2 text-xs text-gray-400">长按文件可重新上传或删除</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-4">
|
||||
<View className="text-sm font-medium text-gray-900 mb-2">跟进人</View>
|
||||
{followerTags.length ? (
|
||||
@@ -317,7 +699,7 @@ export default function CreditMpCustomerDetailPage() {
|
||||
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-2">
|
||||
<CellGroup>
|
||||
<Cell title="客户ID" description={String(row.id ?? '—')} />
|
||||
<Cell title="跟进状态" description={String((row as any)?.stepTxt ?? row.step ?? '—')} />
|
||||
<Cell title="跟进状态" description={stepText} />
|
||||
<Cell title="所在地区" description={buildLocation(row) || '—'} />
|
||||
<Cell title="创建时间" description={fmtTime(row.createTime) || '—'} />
|
||||
<Cell title="更新时间" description={fmtTime(row.updateTime) || '—'} />
|
||||
|
||||
@@ -5,30 +5,12 @@ import { Button, ConfigProvider, Empty, Loading, TextArea } from '@nutui/nutui-r
|
||||
import { Setting } from '@nutui/icons-react-taro'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getCreditCompany } from '@/api/credit/creditCompany'
|
||||
import type { CreditCompany } from '@/api/credit/creditCompany/model'
|
||||
import { getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
|
||||
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
|
||||
import { uploadFileByPath } from '@/api/system/file'
|
||||
|
||||
type Intention = '无意向' | '有意向'
|
||||
|
||||
type FollowStep1Draft = {
|
||||
submitted: boolean
|
||||
submittedAt: string
|
||||
companyId: number
|
||||
contact: string
|
||||
phone: string
|
||||
audioSelected: boolean
|
||||
smsShots: number
|
||||
callShots: number
|
||||
remark: string
|
||||
intention: Intention
|
||||
// 业务规则模拟:
|
||||
// 多次沟通 + 有意向 => 无需审核(可直接进入下一步,下一步不在本页实现)
|
||||
// 多次沟通 + 无意向 => 需管理员审核
|
||||
communicationCount: number
|
||||
needApproval: boolean
|
||||
isApproved: boolean
|
||||
}
|
||||
|
||||
const splitPhones = (raw?: string) => {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return []
|
||||
@@ -40,20 +22,6 @@ const splitPhones = (raw?: string) => {
|
||||
|
||||
const uniq = <T,>(arr: T[]) => Array.from(new Set(arr))
|
||||
|
||||
const parseContactFromComments = (comments?: string) => {
|
||||
const txt = String(comments || '').trim()
|
||||
if (!txt) return ''
|
||||
const m = txt.match(/联系人:([^;;]+)/)
|
||||
return String(m?.[1] || '').trim()
|
||||
}
|
||||
|
||||
const makeThumb = () => ({
|
||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
})
|
||||
|
||||
const getDraftKey = (companyId: number) => `credit_company_follow_step1:${companyId}`
|
||||
const getCountKey = (companyId: number) => `credit_company_follow_comm_count:${companyId}`
|
||||
|
||||
const safeParseJSON = <T,>(v: any): T | null => {
|
||||
try {
|
||||
if (!v) return null
|
||||
@@ -65,9 +33,122 @@ const safeParseJSON = <T,>(v: any): T | null => {
|
||||
}
|
||||
}
|
||||
|
||||
export default function CreditCompanyFollowStep1Page() {
|
||||
type Attachment = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
type AudioAttachment = {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
|
||||
const isHttpUrl = (url?: string) => {
|
||||
if (!url) return false
|
||||
return /^https?:\/\//i.test(url)
|
||||
}
|
||||
|
||||
const guessNameFromUrl = (url: string, fallback: string) => {
|
||||
const clean = String(url || '').split('?')[0].split('#')[0]
|
||||
const lastSlash = clean.lastIndexOf('/')
|
||||
const base = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
|
||||
return String(base || fallback).trim()
|
||||
}
|
||||
|
||||
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 getCustomerContact = (row?: CreditMpCustomer | null) => {
|
||||
if (!row) return ''
|
||||
const anyRow = row as any
|
||||
const fromField = String(anyRow?.contact || anyRow?.contactName || anyRow?.linkman || anyRow?.contacts || '').trim()
|
||||
if (fromField) return fromField
|
||||
const txt = String(row.comments || '').trim()
|
||||
if (!txt) return ''
|
||||
const m = txt.match(/联系人:([^;;]+)/)
|
||||
return String(m?.[1] || '').trim()
|
||||
}
|
||||
|
||||
const normalizeAttachmentsFromJson = (raw?: string): Attachment[] => {
|
||||
const parsed = safeParseJSON<any>(raw)
|
||||
if (!parsed) return []
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const out: Attachment[] = []
|
||||
for (const item of parsed) {
|
||||
if (typeof item === 'string') {
|
||||
const url = String(item).trim()
|
||||
if (!url) continue
|
||||
out.push({ id: makeId(), url, name: guessNameFromUrl(url, '图片') })
|
||||
continue
|
||||
}
|
||||
const url = String(item?.url || item?.downloadUrl || item?.path || '').trim()
|
||||
if (!url) continue
|
||||
out.push({
|
||||
id: makeId(),
|
||||
url,
|
||||
name: String(item?.name || guessNameFromUrl(url, '图片')).trim(),
|
||||
thumbnail: item?.thumbnail ? String(item.thumbnail) : undefined
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const FOLLOW_HISTORY_KEY_PREFIX = 'credit_mp_customer_follow_user_history:'
|
||||
|
||||
const loadFollowHistoryIds = (customerId: number): number[] => {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(`${FOLLOW_HISTORY_KEY_PREFIX}${customerId}`)
|
||||
const arr = safeParseJSON<number[]>(raw) || []
|
||||
return arr
|
||||
.map(v => Number(v))
|
||||
.filter(v => Number.isFinite(v) && v > 0)
|
||||
} catch (_e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const saveFollowHistoryIds = (customerId: number, ids: number[]) => {
|
||||
const next = 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(next))
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export default function CreditMpCustomerFollowStep1Page() {
|
||||
const router = useRouter()
|
||||
const companyId = useMemo(() => {
|
||||
const customerId = useMemo(() => {
|
||||
const id = Number((router?.params as any)?.id)
|
||||
return Number.isFinite(id) && id > 0 ? id : undefined
|
||||
}, [router?.params])
|
||||
@@ -83,85 +164,211 @@ export default function CreditCompanyFollowStep1Page() {
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [company, setCompany] = useState<CreditCompany | null>(null)
|
||||
const [row, setRow] = useState<CreditMpCustomer | null>(null)
|
||||
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [audioSelected, setAudioSelected] = useState(false)
|
||||
const [smsShots, setSmsShots] = useState<Array<{ id: string }>>([])
|
||||
const [callShots, setCallShots] = useState<Array<{ id: string }>>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [audio, setAudio] = useState<AudioAttachment | null>(null)
|
||||
const [smsShots, setSmsShots] = useState<Attachment[]>([])
|
||||
const [callShots, setCallShots] = useState<Attachment[]>([])
|
||||
const [remark, setRemark] = useState('')
|
||||
const [intention, setIntention] = useState<Intention | undefined>(undefined)
|
||||
|
||||
const loadDraft = useCallback(() => {
|
||||
if (!companyId) return
|
||||
const raw = Taro.getStorageSync(getDraftKey(companyId))
|
||||
const saved = safeParseJSON<FollowStep1Draft>(raw)
|
||||
if (!saved?.submitted) return
|
||||
setSubmitted(true)
|
||||
setAudioSelected(!!saved.audioSelected)
|
||||
setSmsShots(Array.from({ length: Math.max(0, Math.min(6, Number(saved.smsShots || 0))) }, makeThumb))
|
||||
setCallShots(Array.from({ length: Math.max(0, Math.min(6, Number(saved.callShots || 0))) }, makeThumb))
|
||||
setRemark(String(saved.remark || ''))
|
||||
setIntention(saved.intention)
|
||||
}, [companyId])
|
||||
|
||||
const reloadCompany = useCallback(async () => {
|
||||
const reload = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
if (!companyId) throw new Error('缺少客户ID')
|
||||
const res = await getCreditCompany(companyId)
|
||||
setCompany(res as CreditCompany)
|
||||
if (!customerId) throw new Error('缺少客户ID')
|
||||
const res = await getCreditMpCustomer(customerId)
|
||||
const next = (res || null) as CreditMpCustomer | null
|
||||
setRow(next)
|
||||
|
||||
const anyRow = next as any
|
||||
const hasSubmitted = Boolean(anyRow?.followStep1Submitted) || Boolean(anyRow?.followStep1SubmittedAt)
|
||||
setSubmitted(hasSubmitted)
|
||||
|
||||
const nextIntentionRaw = String(anyRow?.followStep1Intention || '').trim()
|
||||
const nextIntention: Intention | undefined =
|
||||
nextIntentionRaw === '无意向' || nextIntentionRaw === '有意向' ? (nextIntentionRaw as Intention) : undefined
|
||||
setIntention(nextIntention)
|
||||
|
||||
setRemark(String(anyRow?.followStep1Remark || ''))
|
||||
|
||||
const audioUrl = String(anyRow?.followStep1AudioUrl || '').trim()
|
||||
if (audioUrl) {
|
||||
setAudio({
|
||||
url: audioUrl,
|
||||
name: String(anyRow?.followStep1AudioName || guessNameFromUrl(audioUrl, '录音')).trim()
|
||||
})
|
||||
} else {
|
||||
setAudio(null)
|
||||
}
|
||||
|
||||
setSmsShots(normalizeAttachmentsFromJson(anyRow?.followStep1SmsShots))
|
||||
setCallShots(normalizeAttachmentsFromJson(anyRow?.followStep1CallShots))
|
||||
} catch (e) {
|
||||
console.error('加载客户信息失败:', e)
|
||||
setCompany(null)
|
||||
setRow(null)
|
||||
setError(String((e as any)?.message || '加载失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [companyId])
|
||||
}, [customerId])
|
||||
|
||||
useDidShow(() => {
|
||||
reloadCompany().then()
|
||||
loadDraft()
|
||||
reload().then()
|
||||
})
|
||||
|
||||
const contact = useMemo(() => parseContactFromComments(company?.comments), [company?.comments])
|
||||
|
||||
const phone = useMemo(() => {
|
||||
const arr = uniq([...splitPhones(company?.tel), ...splitPhones(company?.moreTel)])
|
||||
return String(arr[0] || '').trim()
|
||||
}, [company?.moreTel, company?.tel])
|
||||
const contact = useMemo(() => getCustomerContact(row), [row])
|
||||
const phone = useMemo(() => String(getCustomerPhones(row)[0] || '').trim(), [row])
|
||||
|
||||
const canEdit = !submitted
|
||||
|
||||
const addSmsShot = () => {
|
||||
const chooseAndUploadImages = useCallback(
|
||||
async (kind: 'sms' | 'call') => {
|
||||
if (!canEdit) return
|
||||
const current = kind === 'sms' ? smsShots : callShots
|
||||
const remain = Math.max(0, 6 - current.length)
|
||||
if (!remain) {
|
||||
Taro.showToast({ title: '已达上限(6张)', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
let res: any
|
||||
try {
|
||||
res = await Taro.chooseImage({
|
||||
count: remain,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera']
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
Taro.showToast({ title: '选择图片失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const tempFilePaths = (res?.tempFilePaths || []) as string[]
|
||||
if (!tempFilePaths.length) return
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
try {
|
||||
const uploaded: Attachment[] = []
|
||||
for (const p of tempFilePaths) {
|
||||
const record: any = await uploadFileByPath(p)
|
||||
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
|
||||
if (!url) continue
|
||||
uploaded.push({
|
||||
id: makeId(),
|
||||
name: String(record?.name || guessNameFromUrl(url, '图片')).trim(),
|
||||
url,
|
||||
thumbnail: record?.thumbnail ? String(record.thumbnail) : undefined
|
||||
})
|
||||
}
|
||||
if (!uploaded.length) return
|
||||
if (kind === 'sms') setSmsShots(prev => prev.concat(uploaded).slice(0, 6))
|
||||
if (kind === 'call') setCallShots(prev => prev.concat(uploaded).slice(0, 6))
|
||||
} catch (e) {
|
||||
console.error('上传图片失败:', e)
|
||||
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
|
||||
} finally {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
},
|
||||
[callShots, canEdit, smsShots]
|
||||
)
|
||||
|
||||
const onImageAction = useCallback(
|
||||
async (kind: 'sms' | 'call', item: Attachment) => {
|
||||
const list = kind === 'sms' ? smsShots : callShots
|
||||
try {
|
||||
const res = await Taro.showActionSheet({ itemList: ['预览', '删除'] })
|
||||
if (res.tapIndex === 0) {
|
||||
const urls = list.map(x => x.url)
|
||||
await Taro.previewImage({ urls, current: item.url })
|
||||
}
|
||||
if (res.tapIndex === 1) {
|
||||
if (!canEdit) return
|
||||
if (kind === 'sms') setSmsShots(prev => prev.filter(x => x.id !== item.id))
|
||||
if (kind === 'call') setCallShots(prev => prev.filter(x => x.id !== item.id))
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
}
|
||||
},
|
||||
[callShots, canEdit, smsShots]
|
||||
)
|
||||
|
||||
const chooseAndUploadAudio = useCallback(async () => {
|
||||
if (!canEdit) return
|
||||
if (smsShots.length >= 6) {
|
||||
Taro.showToast({ title: '短信截图已达上限(6张)', icon: 'none' })
|
||||
|
||||
let res: any
|
||||
try {
|
||||
// @ts-ignore
|
||||
res = await Taro.chooseMessageFile({ count: 1, type: 'file' })
|
||||
} catch (e) {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
console.error('选择录音失败:', e)
|
||||
Taro.showToast({ title: '当前环境不支持选取文件', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSmsShots(prev => prev.concat(makeThumb()))
|
||||
}
|
||||
|
||||
const addCallShot = () => {
|
||||
if (!canEdit) return
|
||||
if (callShots.length >= 6) {
|
||||
Taro.showToast({ title: '电话沟通截图已达上限(6张)', icon: 'none' })
|
||||
return
|
||||
// @ts-ignore
|
||||
const picked = (res?.tempFiles || []) as Array<{ path?: string; name?: string }>
|
||||
const path = String(picked?.[0]?.path || '').trim()
|
||||
const name = String(picked?.[0]?.name || '').trim()
|
||||
if (!path) return
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
try {
|
||||
const record: any = await uploadFileByPath(path)
|
||||
const url = String(record?.url || record?.downloadUrl || record?.thumbnail || record?.path || '').trim()
|
||||
if (!url) throw new Error('上传失败:缺少url')
|
||||
setAudio({
|
||||
url,
|
||||
name: String(name || record?.name || guessNameFromUrl(url, '录音')).trim()
|
||||
})
|
||||
Taro.showToast({ title: '已上传', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.error('上传录音失败:', e)
|
||||
Taro.showToast({ title: (e as any)?.message || '上传失败', icon: 'none' })
|
||||
} finally {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
setCallShots(prev => prev.concat(makeThumb()))
|
||||
}
|
||||
}, [canEdit])
|
||||
|
||||
const chooseAudio = async () => {
|
||||
if (!canEdit) return
|
||||
// 本步骤仅做“选择录音文件”的交互模拟
|
||||
setAudioSelected(true)
|
||||
Taro.showToast({ title: '已选择录音文件(模拟)', icon: 'none' })
|
||||
}
|
||||
const onAudioAction = useCallback(async () => {
|
||||
if (!audio) return
|
||||
try {
|
||||
const res = await Taro.showActionSheet({ itemList: ['打开', canEdit ? '重新上传' : '重新上传(不可编辑)', canEdit ? '删除' : '删除(不可编辑)'] })
|
||||
if (res.tapIndex === 0) {
|
||||
let filePath = audio.url
|
||||
if (isHttpUrl(audio.url)) {
|
||||
const dl = await Taro.downloadFile({ url: audio.url })
|
||||
// @ts-ignore
|
||||
filePath = dl?.tempFilePath
|
||||
}
|
||||
await Taro.openDocument({ filePath, showMenu: true } as any)
|
||||
}
|
||||
if (res.tapIndex === 1) {
|
||||
if (!canEdit) return
|
||||
await chooseAndUploadAudio()
|
||||
}
|
||||
if (res.tapIndex === 2) {
|
||||
if (!canEdit) return
|
||||
setAudio(null)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
}
|
||||
}, [audio, canEdit, chooseAndUploadAudio])
|
||||
|
||||
const submit = async () => {
|
||||
if (!companyId) {
|
||||
if (!customerId) {
|
||||
Taro.showToast({ title: '缺少客户ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
@@ -174,43 +381,88 @@ export default function CreditCompanyFollowStep1Page() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!smsShots.length && !callShots.length) {
|
||||
Taro.showToast({ title: '建议至少上传1张截图(非必填)', icon: 'none' })
|
||||
if (submitting) return
|
||||
if (!row) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const anyRow = row as any
|
||||
const prevCount = Number(anyRow?.followStep1CommunicationCount || 0)
|
||||
const communicationCount = (Number.isFinite(prevCount) && prevCount > 0 ? prevCount : 0) + 1
|
||||
const needApproval = communicationCount > 1 && intention === '无意向'
|
||||
|
||||
const smsPayload = smsShots.map(a => ({ name: a.name, url: a.url, thumbnail: a.thumbnail, isImage: true }))
|
||||
const callPayload = callShots.map(a => ({ name: a.name, url: a.url, thumbnail: a.thumbnail, isImage: true }))
|
||||
|
||||
const nextStep = (() => {
|
||||
const raw = anyRow?.step ?? anyRow?.stepStatus ?? anyRow?.stepNum ?? anyRow?.stepCode ?? undefined
|
||||
const n = Number(raw)
|
||||
if (Number.isInteger(n) && n >= 0) return Math.max(1, n)
|
||||
return 1
|
||||
})()
|
||||
|
||||
await updateCreditMpCustomer({
|
||||
...(row as any),
|
||||
id: customerId,
|
||||
step: nextStep,
|
||||
followStep1Submitted: 1,
|
||||
followStep1SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
followStep1Contact: contact || undefined,
|
||||
followStep1Phone: phone || undefined,
|
||||
followStep1AudioUrl: audio?.url || undefined,
|
||||
followStep1AudioName: audio?.name || undefined,
|
||||
followStep1SmsShots: smsPayload.length ? JSON.stringify(smsPayload) : undefined,
|
||||
followStep1CallShots: callPayload.length ? JSON.stringify(callPayload) : undefined,
|
||||
followStep1Remark: remark.trim(),
|
||||
followStep1Intention: intention,
|
||||
followStep1CommunicationCount: communicationCount,
|
||||
followStep1NeedApproval: needApproval ? 1 : 0,
|
||||
followStep1Approved: 0
|
||||
} as any)
|
||||
|
||||
const currentUser = safeParseJSON<any>(Taro.getStorageSync('User')) || {}
|
||||
const uid = Number(currentUser?.userId)
|
||||
if (Number.isFinite(uid) && uid > 0) {
|
||||
const history = loadFollowHistoryIds(customerId)
|
||||
saveFollowHistoryIds(customerId, history.concat(uid))
|
||||
}
|
||||
|
||||
setSubmitted(true)
|
||||
setRow(prev =>
|
||||
prev
|
||||
? ({
|
||||
...(prev as any),
|
||||
step: nextStep,
|
||||
followStep1Submitted: 1,
|
||||
followStep1SubmittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
followStep1Contact: contact || undefined,
|
||||
followStep1Phone: phone || undefined,
|
||||
followStep1AudioUrl: audio?.url || undefined,
|
||||
followStep1AudioName: audio?.name || undefined,
|
||||
followStep1SmsShots: smsPayload.length ? JSON.stringify(smsPayload) : undefined,
|
||||
followStep1CallShots: callPayload.length ? JSON.stringify(callPayload) : undefined,
|
||||
followStep1Remark: remark.trim(),
|
||||
followStep1Intention: intention,
|
||||
followStep1CommunicationCount: communicationCount,
|
||||
followStep1NeedApproval: needApproval ? 1 : 0,
|
||||
followStep1Approved: 0
|
||||
} as any)
|
||||
: prev
|
||||
)
|
||||
|
||||
await Taro.showModal({
|
||||
title: '提示',
|
||||
content: needApproval ? '跟进信息已提交\n请等待管理员审核' : '跟进信息已提交',
|
||||
showCancel: false
|
||||
})
|
||||
|
||||
Taro.navigateBack().catch(() => {})
|
||||
} catch (e) {
|
||||
console.error('提交跟进失败:', e)
|
||||
Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const prevCountRaw = Taro.getStorageSync(getCountKey(companyId))
|
||||
const prevCount = Number(prevCountRaw || 0)
|
||||
const communicationCount = (Number.isFinite(prevCount) ? prevCount : 0) + 1
|
||||
Taro.setStorageSync(getCountKey(companyId), communicationCount)
|
||||
|
||||
const needApproval = communicationCount > 1 && intention === '无意向'
|
||||
const isApproved = false // 模拟:默认未审核;后续步骤可检查该标志
|
||||
|
||||
const payload: FollowStep1Draft = {
|
||||
submitted: true,
|
||||
submittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
companyId,
|
||||
contact,
|
||||
phone,
|
||||
audioSelected,
|
||||
smsShots: smsShots.length,
|
||||
callShots: callShots.length,
|
||||
remark: remark.trim(),
|
||||
intention,
|
||||
communicationCount,
|
||||
needApproval,
|
||||
isApproved
|
||||
}
|
||||
|
||||
Taro.setStorageSync(getDraftKey(companyId), payload)
|
||||
|
||||
setSubmitted(true)
|
||||
|
||||
await Taro.showModal({
|
||||
title: '提示',
|
||||
content: '跟进信息已提交\n请等待管理员审核',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
const headerOffset = statusBarHeight + 80
|
||||
@@ -239,7 +491,7 @@ export default function CreditCompanyFollowStep1Page() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await Taro.showActionSheet({ itemList: ['刷新'] })
|
||||
if (res.tapIndex === 0) reloadCompany()
|
||||
if (res.tapIndex === 0) reload()
|
||||
} catch (e) {
|
||||
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
|
||||
if (msg.includes('cancel')) return
|
||||
@@ -265,12 +517,12 @@ export default function CreditCompanyFollowStep1Page() {
|
||||
<View className="bg-white rounded-xl border border-pink-100 p-6">
|
||||
<View className="text-red-500 text-sm">{error}</View>
|
||||
<View className="mt-4">
|
||||
<Button type="primary" onClick={reloadCompany}>
|
||||
<Button type="primary" onClick={reload}>
|
||||
重试
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : !company ? (
|
||||
) : !row ? (
|
||||
<View className="bg-white rounded-xl border border-pink-100 py-10">
|
||||
<Empty description="暂无客户信息" />
|
||||
</View>
|
||||
@@ -290,16 +542,36 @@ export default function CreditCompanyFollowStep1Page() {
|
||||
<Text className="text-gray-500">
|
||||
联系电话
|
||||
</Text>
|
||||
<Text className="text-gray-400 text-right break-all">{phone || '—'}</Text>
|
||||
<Text
|
||||
className={`text-right break-all ${phone ? 'text-blue-600' : 'text-gray-400'}`}
|
||||
onClick={() => phone && Taro.makePhoneCall({ phoneNumber: phone }).catch(() => {})}
|
||||
>
|
||||
{phone || '—'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex items-center justify-between gap-3">
|
||||
<Text className="text-gray-500">
|
||||
电话录音
|
||||
</Text>
|
||||
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAudio}>
|
||||
{audioSelected ? '已选择' : '点击上传'}
|
||||
</Button>
|
||||
{audio ? (
|
||||
<View className="flex items-center gap-2">
|
||||
<Text
|
||||
className="text-xs text-blue-600 truncate"
|
||||
style={{ maxWidth: Taro.pxTransform(160) }}
|
||||
onClick={onAudioAction}
|
||||
>
|
||||
{audio.name || '录音'}
|
||||
</Text>
|
||||
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAndUploadAudio}>
|
||||
重新上传
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAndUploadAudio}>
|
||||
点击上传
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="pt-2 border-t border-pink-100" />
|
||||
@@ -313,16 +585,30 @@ export default function CreditCompanyFollowStep1Page() {
|
||||
{smsShots.map((x, idx) => (
|
||||
<View
|
||||
key={x.id}
|
||||
className="w-16 h-16 rounded-lg bg-gray-200 flex items-center justify-center text-xs text-gray-500"
|
||||
className="w-16 h-16 rounded-lg bg-gray-200 overflow-hidden"
|
||||
onClick={() => onImageAction('sms', x)}
|
||||
>
|
||||
图{idx + 1}
|
||||
{x.url ? (
|
||||
<View
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `url(${x.thumbnail || x.url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="w-full h-full flex items-center justify-center text-xs text-gray-500">
|
||||
图{idx + 1}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
<View
|
||||
className={`w-16 h-16 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
|
||||
!canEdit || smsShots.length >= 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
|
||||
}`}
|
||||
onClick={addSmsShot}
|
||||
onClick={() => chooseAndUploadImages('sms')}
|
||||
>
|
||||
+
|
||||
</View>
|
||||
@@ -338,16 +624,30 @@ export default function CreditCompanyFollowStep1Page() {
|
||||
{callShots.map((x, idx) => (
|
||||
<View
|
||||
key={x.id}
|
||||
className="w-16 h-16 rounded-lg bg-gray-200 flex items-center justify-center text-xs text-gray-500"
|
||||
className="w-16 h-16 rounded-lg bg-gray-200 overflow-hidden"
|
||||
onClick={() => onImageAction('call', x)}
|
||||
>
|
||||
图{idx + 1}
|
||||
{x.url ? (
|
||||
<View
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `url(${x.thumbnail || x.url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="w-full h-full flex items-center justify-center text-xs text-gray-500">
|
||||
图{idx + 1}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
<View
|
||||
className={`w-16 h-16 rounded-lg border border-dashed flex items-center justify-center text-2xl ${
|
||||
!canEdit || callShots.length >= 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
|
||||
}`}
|
||||
onClick={addCallShot}
|
||||
onClick={() => chooseAndUploadImages('call')}
|
||||
>
|
||||
+
|
||||
</View>
|
||||
@@ -411,14 +711,14 @@ export default function CreditCompanyFollowStep1Page() {
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
disabled={!canEdit}
|
||||
disabled={!canEdit || submitting}
|
||||
style={{
|
||||
background: submitted ? '#94a3b8' : '#ef4444',
|
||||
borderColor: submitted ? '#94a3b8' : '#ef4444'
|
||||
}}
|
||||
onClick={submit}
|
||||
>
|
||||
确定
|
||||
{submitting ? '提交中...' : '确定'}
|
||||
</Button>
|
||||
</View>
|
||||
</ConfigProvider>
|
||||
|
||||
Reference in New Issue
Block a user