feat(credit): 完善客户详情页面功能

- 移除自定义导航栏样式配置
- 添加员工列表加载和缓存机制
- 实现客户状态显示和状态选项功能
- 添加客户电话号码提取和展示功能
- 集成跟进人员标签显示功能
- 实现客户联系方式拨打功能
- 添加跟进历史记录存储机制
- 更新页面背景色和样式
- 添加固定底部跟进按钮
- 修改API基础URL配置
This commit is contained in:
2026-03-20 17:08:36 +08:00
parent c3c8fa2c3b
commit 26e0a11bae
3 changed files with 902 additions and 142 deletions

View File

@@ -8,6 +8,24 @@ export interface CreditMpCustomer {
id?: number; id?: number;
// 拖欠方 // 拖欠方
toUser?: string; toUser?: string;
// 联系人(如后端提供)
contact?: string;
// 联系人姓名(兼容字段)
contactName?: string;
// 联系电话(主)
tel?: string;
// 其他联系电话(多个用分隔符拼接)
moreTel?: string;
// 联系电话(兼容字段)
phone?: string;
// 手机号(兼容字段)
mobile?: string;
// 联系人手机(兼容字段)
contactPhone?: string;
// 其他电话(兼容字段)
otherPhone?: string;
// 其他电话集合(兼容字段)
otherPhones?: string;
// 拖欠金额 // 拖欠金额
price?: string; price?: string;
// 拖欠年数 // 拖欠年数
@@ -16,6 +34,10 @@ export interface CreditMpCustomer {
url?: string; url?: string;
// 状态 // 状态
statusTxt?: string; statusTxt?: string;
// 状态(兼容字段)
statusText?: string;
// 客户状态(兼容字段)
customerStatus?: string;
// 企业ID // 企业ID
companyId?: number; companyId?: number;
// 所在省份 // 所在省份
@@ -30,8 +52,33 @@ export interface CreditMpCustomer {
hasData?: string; hasData?: string;
// 步骤 // 步骤
step?: number; step?: number;
// 步骤(兼容字段)
stepStatus?: number;
// 步骤(兼容字段)
stepNum?: number;
// 步骤(兼容字段)
stepCode?: number;
// 步骤文字(兼容字段)
stepTxt?: string;
// 步骤文字(兼容字段)
stepText?: string;
// 备注 // 备注
comments?: string; comments?: string;
// 跟进人ID列表兼容字段
followUserIds?: number[];
// 跟进人列表(兼容字段)
followUsers?: Array<{
userId?: number;
realName?: string;
nickname?: string;
username?: string;
}>;
// 跟进人姓名(兼容字段)
realName?: string;
// 跟进人姓名(兼容字段)
userRealName?: string;
// 跟进人姓名(兼容字段)
followRealName?: string;
// 是否推荐 // 是否推荐
recommend?: number; recommend?: number;
// 排序(数字越小越靠前) // 排序(数字越小越靠前)
@@ -48,6 +95,37 @@ export interface CreditMpCustomer {
createTime?: string; createTime?: string;
// 修改时间 // 修改时间
updateTime?: 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;
} }
/** /**

View File

@@ -1,13 +1,15 @@
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro' import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { View, Text } from '@tarojs/components' import { Image, View, Text } from '@tarojs/components'
import { Cell, CellGroup, ConfigProvider, Empty, Loading, Tag } from '@nutui/nutui-react-taro' import { Button, Cell, CellGroup, ConfigProvider, Empty, Loading, Tag } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs' 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 type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import { listUsers } from '@/api/system/user' import { listUsers } from '@/api/system/user'
import type { User } from '@/api/system/user/model' import type { User } from '@/api/system/user/model'
import { uploadFileByPath } from '@/api/system/file'
import FixedButton from '@/components/FixedButton' import FixedButton from '@/components/FixedButton'
const fmtTime = (t?: string) => { const fmtTime = (t?: string) => {
@@ -34,6 +36,15 @@ type CustomerStatus = '保护期内' | '已签约' | '已完成' | '保护期外
const STATUS_OPTIONS: CustomerStatus[] = ['保护期内', '已签约', '已完成', '保护期外'] const STATUS_OPTIONS: CustomerStatus[] = ['保护期内', '已签约', '已完成', '保护期外']
const FOLLOW_HISTORY_KEY_PREFIX = 'credit_mp_customer_follow_user_history:' 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 => { const safeParseJSON = <T,>(v: any): T | null => {
try { try {
if (!v) return null if (!v) return null
@@ -56,6 +67,43 @@ const splitPhones = (raw?: string) => {
const uniq = <T,>(arr: T[]) => Array.from(new Set(arr)) 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 pickPhoneLike = (raw?: string) => {
const txt = String(raw || '').trim() const txt = String(raw || '').trim()
if (!txt) return [] 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() { export default function CreditMpCustomerDetailPage() {
const router = useRouter() const router = useRouter()
const rowId = useMemo(() => { const rowId = useMemo(() => {
@@ -113,6 +202,9 @@ export default function CreditMpCustomerDetailPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [row, setRow] = useState<CreditMpCustomer | null>(null) const [row, setRow] = useState<CreditMpCustomer | null>(null)
const [staffList, setStaffList] = useState<User[]>([]) const [staffList, setStaffList] = useState<User[]>([])
const [attachments, setAttachments] = useState<Attachment[]>([])
const [uploading, setUploading] = useState(false)
const [savingFiles, setSavingFiles] = useState(false)
const reload = useCallback(async () => { const reload = useCallback(async () => {
setError(null) setError(null)
@@ -121,6 +213,7 @@ export default function CreditMpCustomerDetailPage() {
if (!rowId) throw new Error('缺少客户ID') if (!rowId) throw new Error('缺少客户ID')
const res = await getCreditMpCustomer(rowId) const res = await getCreditMpCustomer(rowId)
setRow((res || null) as CreditMpCustomer | null) setRow((res || null) as CreditMpCustomer | null)
setAttachments(parseFilesToAttachments((res as any)?.files))
} catch (e) { } catch (e) {
console.error('加载客户详情失败:', e) console.error('加载客户详情失败:', e)
setRow(null) setRow(null)
@@ -159,6 +252,15 @@ export default function CreditMpCustomerDetailPage() {
const title = String(row?.toUser || '').trim() || '—' const title = String(row?.toUser || '').trim() || '—'
const desc = buildDesc(row) const desc = buildDesc(row)
const statusText = useMemo(() => getCustomerStatusText(row), [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 staffNameMap = useMemo(() => {
const map = new Map<number, string>() const map = new Map<number, string>()
@@ -171,6 +273,210 @@ export default function CreditMpCustomerDetailPage() {
const phones = useMemo(() => getCustomerPhones(row), [row]) 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(() => { const followerIds = useMemo(() => {
if (!rowId) return [] if (!rowId) return []
const anyRow = row as any const anyRow = row as any
@@ -292,6 +598,82 @@ export default function CreditMpCustomerDetailPage() {
)} )}
</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={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="mt-3 bg-white rounded-xl border border-gray-100 p-4">
<View className="text-sm font-medium text-gray-900 mb-2"></View> <View className="text-sm font-medium text-gray-900 mb-2"></View>
{followerTags.length ? ( {followerTags.length ? (
@@ -317,7 +699,7 @@ export default function CreditMpCustomerDetailPage() {
<View className="mt-3 bg-white rounded-xl border border-gray-100 p-2"> <View className="mt-3 bg-white rounded-xl border border-gray-100 p-2">
<CellGroup> <CellGroup>
<Cell title="客户ID" description={String(row.id ?? '—')} /> <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={buildLocation(row) || '—'} />
<Cell title="创建时间" description={fmtTime(row.createTime) || '—'} /> <Cell title="创建时间" description={fmtTime(row.createTime) || '—'} />
<Cell title="更新时间" description={fmtTime(row.updateTime) || '—'} /> <Cell title="更新时间" description={fmtTime(row.updateTime) || '—'} />

View File

@@ -5,30 +5,12 @@ import { Button, ConfigProvider, Empty, Loading, TextArea } from '@nutui/nutui-r
import { Setting } from '@nutui/icons-react-taro' import { Setting } from '@nutui/icons-react-taro'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { getCreditCompany } from '@/api/credit/creditCompany' import { getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
import type { CreditCompany } from '@/api/credit/creditCompany/model' import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
import { uploadFileByPath } from '@/api/system/file'
type Intention = '无意向' | '有意向' 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 splitPhones = (raw?: string) => {
const text = String(raw || '').trim() const text = String(raw || '').trim()
if (!text) return [] if (!text) return []
@@ -40,20 +22,6 @@ const splitPhones = (raw?: string) => {
const uniq = <T,>(arr: T[]) => Array.from(new Set(arr)) 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 => { const safeParseJSON = <T,>(v: any): T | null => {
try { try {
if (!v) return null 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 router = useRouter()
const companyId = useMemo(() => { const customerId = useMemo(() => {
const id = Number((router?.params as any)?.id) const id = Number((router?.params as any)?.id)
return Number.isFinite(id) && id > 0 ? id : undefined return Number.isFinite(id) && id > 0 ? id : undefined
}, [router?.params]) }, [router?.params])
@@ -83,85 +164,211 @@ export default function CreditCompanyFollowStep1Page() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) 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 [submitted, setSubmitted] = useState(false)
const [audioSelected, setAudioSelected] = useState(false) const [submitting, setSubmitting] = useState(false)
const [smsShots, setSmsShots] = useState<Array<{ id: string }>>([])
const [callShots, setCallShots] = useState<Array<{ id: string }>>([]) const [audio, setAudio] = useState<AudioAttachment | null>(null)
const [smsShots, setSmsShots] = useState<Attachment[]>([])
const [callShots, setCallShots] = useState<Attachment[]>([])
const [remark, setRemark] = useState('') const [remark, setRemark] = useState('')
const [intention, setIntention] = useState<Intention | undefined>(undefined) const [intention, setIntention] = useState<Intention | undefined>(undefined)
const loadDraft = useCallback(() => { const reload = useCallback(async () => {
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 () => {
setError(null) setError(null)
setLoading(true) setLoading(true)
try { try {
if (!companyId) throw new Error('缺少客户ID') if (!customerId) throw new Error('缺少客户ID')
const res = await getCreditCompany(companyId) const res = await getCreditMpCustomer(customerId)
setCompany(res as CreditCompany) 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) { } catch (e) {
console.error('加载客户信息失败:', e) console.error('加载客户信息失败:', e)
setCompany(null) setRow(null)
setError(String((e as any)?.message || '加载失败')) setError(String((e as any)?.message || '加载失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [companyId]) }, [customerId])
useDidShow(() => { useDidShow(() => {
reloadCompany().then() reload().then()
loadDraft()
}) })
const contact = useMemo(() => parseContactFromComments(company?.comments), [company?.comments]) const contact = useMemo(() => getCustomerContact(row), [row])
const phone = useMemo(() => String(getCustomerPhones(row)[0] || '').trim(), [row])
const phone = useMemo(() => {
const arr = uniq([...splitPhones(company?.tel), ...splitPhones(company?.moreTel)])
return String(arr[0] || '').trim()
}, [company?.moreTel, company?.tel])
const canEdit = !submitted const canEdit = !submitted
const addSmsShot = () => { const chooseAndUploadImages = useCallback(
async (kind: 'sms' | 'call') => {
if (!canEdit) return if (!canEdit) return
if (smsShots.length >= 6) { const current = kind === 'sms' ? smsShots : callShots
Taro.showToast({ title: '短信截图已达上限(6张)', icon: 'none' }) const remain = Math.max(0, 6 - current.length)
if (!remain) {
Taro.showToast({ title: '已达上限(6张)', icon: 'none' })
return return
} }
setSmsShots(prev => prev.concat(makeThumb()))
}
const addCallShot = () => { let res: any
if (!canEdit) return try {
if (callShots.length >= 6) { res = await Taro.chooseImage({
Taro.showToast({ title: '电话沟通截图已达上限(6张)', icon: 'none' }) 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 return
} }
setCallShots(prev => prev.concat(makeThumb()))
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
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
} }
const chooseAudio = async () => { // @ts-ignore
if (!canEdit) return const picked = (res?.tempFiles || []) as Array<{ path?: string; name?: string }>
// 本步骤仅做“选择录音文件”的交互模拟 const path = String(picked?.[0]?.path || '').trim()
setAudioSelected(true) const name = String(picked?.[0]?.name || '').trim()
Taro.showToast({ title: '已选择录音文件(模拟)', icon: 'none' }) 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()
} }
}, [canEdit])
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 () => { const submit = async () => {
if (!companyId) { if (!customerId) {
Taro.showToast({ title: '缺少客户ID', icon: 'none' }) Taro.showToast({ title: '缺少客户ID', icon: 'none' })
return return
} }
@@ -174,43 +381,88 @@ export default function CreditCompanyFollowStep1Page() {
return return
} }
if (!smsShots.length && !callShots.length) { if (submitting) return
Taro.showToast({ title: '建议至少上传1张截图非必填', icon: 'none' }) if (!row) return
}
const prevCountRaw = Taro.getStorageSync(getCountKey(companyId))
const prevCount = Number(prevCountRaw || 0)
const communicationCount = (Number.isFinite(prevCount) ? prevCount : 0) + 1
Taro.setStorageSync(getCountKey(companyId), communicationCount)
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 needApproval = communicationCount > 1 && intention === '无意向'
const isApproved = false // 模拟:默认未审核;后续步骤可检查该标志
const payload: FollowStep1Draft = { const smsPayload = smsShots.map(a => ({ name: a.name, url: a.url, thumbnail: a.thumbnail, isImage: true }))
submitted: true, const callPayload = callShots.map(a => ({ name: a.name, url: a.url, thumbnail: a.thumbnail, isImage: true }))
submittedAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
companyId, const nextStep = (() => {
contact, const raw = anyRow?.step ?? anyRow?.stepStatus ?? anyRow?.stepNum ?? anyRow?.stepCode ?? undefined
phone, const n = Number(raw)
audioSelected, if (Number.isInteger(n) && n >= 0) return Math.max(1, n)
smsShots: smsShots.length, return 1
callShots: callShots.length, })()
remark: remark.trim(),
intention, await updateCreditMpCustomer({
communicationCount, ...(row as any),
needApproval, id: customerId,
isApproved 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))
} }
Taro.setStorageSync(getDraftKey(companyId), payload)
setSubmitted(true) 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({ await Taro.showModal({
title: '提示', title: '提示',
content: '跟进信息已提交\n请等待管理员审核', content: needApproval ? '跟进信息已提交\n请等待管理员审核' : '跟进信息已提交',
showCancel: false showCancel: false
}) })
Taro.navigateBack().catch(() => {})
} catch (e) {
console.error('提交跟进失败:', e)
Taro.showToast({ title: (e as any)?.message || '提交失败', icon: 'none' })
} finally {
setSubmitting(false)
}
} }
const headerOffset = statusBarHeight + 80 const headerOffset = statusBarHeight + 80
@@ -239,7 +491,7 @@ export default function CreditCompanyFollowStep1Page() {
onClick={async () => { onClick={async () => {
try { try {
const res = await Taro.showActionSheet({ itemList: ['刷新'] }) const res = await Taro.showActionSheet({ itemList: ['刷新'] })
if (res.tapIndex === 0) reloadCompany() if (res.tapIndex === 0) reload()
} catch (e) { } catch (e) {
const msg = String((e as any)?.errMsg || (e as any)?.message || e || '') const msg = String((e as any)?.errMsg || (e as any)?.message || e || '')
if (msg.includes('cancel')) return 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="bg-white rounded-xl border border-pink-100 p-6">
<View className="text-red-500 text-sm">{error}</View> <View className="text-red-500 text-sm">{error}</View>
<View className="mt-4"> <View className="mt-4">
<Button type="primary" onClick={reloadCompany}> <Button type="primary" onClick={reload}>
</Button> </Button>
</View> </View>
</View> </View>
) : !company ? ( ) : !row ? (
<View className="bg-white rounded-xl border border-pink-100 py-10"> <View className="bg-white rounded-xl border border-pink-100 py-10">
<Empty description="暂无客户信息" /> <Empty description="暂无客户信息" />
</View> </View>
@@ -290,17 +542,37 @@ export default function CreditCompanyFollowStep1Page() {
<Text className="text-gray-500"> <Text className="text-gray-500">
</Text> </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>
<View className="flex items-center justify-between gap-3"> <View className="flex items-center justify-between gap-3">
<Text className="text-gray-500"> <Text className="text-gray-500">
</Text> </Text>
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAudio}> {audio ? (
{audioSelected ? '已选择' : '点击上传'} <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> </Button>
</View> </View>
) : (
<Button size="small" fill="outline" disabled={!canEdit} onClick={chooseAndUploadAudio}>
</Button>
)}
</View>
<View className="pt-2 border-t border-pink-100" /> <View className="pt-2 border-t border-pink-100" />
@@ -313,16 +585,30 @@ export default function CreditCompanyFollowStep1Page() {
{smsShots.map((x, idx) => ( {smsShots.map((x, idx) => (
<View <View
key={x.id} 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)}
> >
{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} {idx + 1}
</View> </View>
)}
</View>
))} ))}
<View <View
className={`w-16 h-16 rounded-lg border border-dashed flex items-center justify-center text-2xl ${ 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' !canEdit || smsShots.length >= 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`} }`}
onClick={addSmsShot} onClick={() => chooseAndUploadImages('sms')}
> >
+ +
</View> </View>
@@ -338,16 +624,30 @@ export default function CreditCompanyFollowStep1Page() {
{callShots.map((x, idx) => ( {callShots.map((x, idx) => (
<View <View
key={x.id} 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)}
> >
{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} {idx + 1}
</View> </View>
)}
</View>
))} ))}
<View <View
className={`w-16 h-16 rounded-lg border border-dashed flex items-center justify-center text-2xl ${ 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' !canEdit || callShots.length >= 6 ? 'border-gray-200 text-gray-200' : 'border-gray-300 text-gray-400'
}`} }`}
onClick={addCallShot} onClick={() => chooseAndUploadImages('call')}
> >
+ +
</View> </View>
@@ -411,14 +711,14 @@ export default function CreditCompanyFollowStep1Page() {
<Button <Button
type="primary" type="primary"
block block
disabled={!canEdit} disabled={!canEdit || submitting}
style={{ style={{
background: submitted ? '#94a3b8' : '#ef4444', background: submitted ? '#94a3b8' : '#ef4444',
borderColor: submitted ? '#94a3b8' : '#ef4444' borderColor: submitted ? '#94a3b8' : '#ef4444'
}} }}
onClick={submit} onClick={submit}
> >
{submitting ? '提交中...' : '确定'}
</Button> </Button>
</View> </View>
</ConfigProvider> </ConfigProvider>