diff --git a/src/api/credit/creditMpCustomer/model/index.ts b/src/api/credit/creditMpCustomer/model/index.ts index a8cc303..138f31d 100644 --- a/src/api/credit/creditMpCustomer/model/index.ts +++ b/src/api/credit/creditMpCustomer/model/index.ts @@ -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; } /** diff --git a/src/credit/mp-customer/detail.tsx b/src/credit/mp-customer/detail.tsx index 77ffd8e..866c105 100644 --- a/src/credit/mp-customer/detail.tsx +++ b/src/credit/mp-customer/detail.tsx @@ -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 = { + 0: '未受理', + 1: '已受理', + 2: '材料提交', + 3: '合同签订', + 4: '执行回款', + 5: '完结' +} + const safeParseJSON = (v: any): T | null => { try { if (!v) return null @@ -56,6 +67,43 @@ const splitPhones = (raw?: string) => { const uniq = (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(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(null) const [row, setRow] = useState(null) const [staffList, setStaffList] = useState([]) + const [attachments, setAttachments] = useState([]) + 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() @@ -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() { )} + + + 图片 + + + {imageAttachments.length ? ( + + {imageAttachments.map(a => ( + previewAttachment(a)} + onLongPress={() => onAttachmentAction(a)} + > + + + + { + e.stopPropagation() + saveAttachments(attachments.filter(x => x.id !== a.id)).then() + }} + > + + + + ))} + + ) : ( + 未上传图片 + )} + 长按图片可重新上传或删除 + + + + + 文件 + + + {fileAttachments.length ? ( + + {fileAttachments.map(a => ( + previewAttachment(a)} + onLongPress={() => onAttachmentAction(a)} + > + + {a.name} + + { + e.stopPropagation() + saveAttachments(attachments.filter(x => x.id !== a.id)).then() + }} + > + + + + ))} + + ) : ( + 未上传文件 + )} + 长按文件可重新上传或删除 + + 跟进人 {followerTags.length ? ( @@ -317,7 +699,7 @@ export default function CreditMpCustomerDetailPage() { - + diff --git a/src/credit/mp-customer/follow-step1.tsx b/src/credit/mp-customer/follow-step1.tsx index f68ff76..db641e2 100644 --- a/src/credit/mp-customer/follow-step1.tsx +++ b/src/credit/mp-customer/follow-step1.tsx @@ -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 = (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 = (v: any): T | null => { try { if (!v) return null @@ -65,9 +33,122 @@ const safeParseJSON = (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(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(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(null) - const [company, setCompany] = useState(null) + const [row, setRow] = useState(null) const [submitted, setSubmitted] = useState(false) - const [audioSelected, setAudioSelected] = useState(false) - const [smsShots, setSmsShots] = useState>([]) - const [callShots, setCallShots] = useState>([]) + const [submitting, setSubmitting] = useState(false) + + const [audio, setAudio] = useState(null) + const [smsShots, setSmsShots] = useState([]) + const [callShots, setCallShots] = useState([]) const [remark, setRemark] = useState('') const [intention, setIntention] = useState(undefined) - const loadDraft = useCallback(() => { - if (!companyId) return - const raw = Taro.getStorageSync(getDraftKey(companyId)) - const saved = safeParseJSON(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(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() { {error} - - ) : !company ? ( + ) : !row ? ( @@ -290,16 +542,36 @@ export default function CreditCompanyFollowStep1Page() { 联系电话 - {phone || '—'} + phone && Taro.makePhoneCall({ phoneNumber: phone }).catch(() => {})} + > + {phone || '—'} + 电话录音 - + {audio ? ( + + + {audio.name || '录音'} + + + + ) : ( + + )} @@ -313,16 +585,30 @@ export default function CreditCompanyFollowStep1Page() { {smsShots.map((x, idx) => ( onImageAction('sms', x)} > - 图{idx + 1} + {x.url ? ( + + ) : ( + + 图{idx + 1} + + )} ))} = 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400' }`} - onClick={addSmsShot} + onClick={() => chooseAndUploadImages('sms')} > + @@ -338,16 +624,30 @@ export default function CreditCompanyFollowStep1Page() { {callShots.map((x, idx) => ( onImageAction('call', x)} > - 图{idx + 1} + {x.url ? ( + + ) : ( + + 图{idx + 1} + + )} ))} = 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400' }`} - onClick={addCallShot} + onClick={() => chooseAndUploadImages('call')} > + @@ -411,14 +711,14 @@ export default function CreditCompanyFollowStep1Page() {