MerchantDetail.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import { useEffect, useState } from 'react'
  2. import { useParams, useNavigate } from 'react-router-dom'
  3. import {
  4. Card, Descriptions, Tag, Badge, Button, Space, Typography, Timeline, Input,
  5. message, Spin, Select, Row, Col, Modal, Form,
  6. } from 'antd'
  7. import {
  8. ArrowLeftOutlined, EditOutlined, LinkOutlined, TeamOutlined, UserSwitchOutlined, SyncOutlined,
  9. } from '@ant-design/icons'
  10. import {
  11. getMerchant, getMemberGroups, getMerchantNotes, addMerchantNote,
  12. updateFollowStatus, getLevelMap, getAssignableUsers, assignMerchant,
  13. updateMerchantClean, startTask, recheckMerchant,
  14. type MerchantClean, type GroupMember, type MerchantNote, type AssignableUser,
  15. } from '../api'
  16. import { useAppStore } from '../store'
  17. const { Text, Title } = Typography
  18. const { Option } = Select
  19. const defaultLevelColor: Record<string, string> = { Hot: 'red', Warm: 'orange', Cold: 'blue' }
  20. const followStatusLabels: Record<string, string> = {
  21. pending: '待跟进', contacted: '已联系', cooperating: '已合作', rejected: '已拒绝',
  22. }
  23. const followStatusBadge: Record<string, 'default' | 'processing' | 'success' | 'error'> = {
  24. pending: 'default', contacted: 'processing', cooperating: 'success', rejected: 'error',
  25. }
  26. const statusBadgeMap: Record<string, 'success' | 'error' | 'warning' | 'default'> = {
  27. valid: 'success', invalid: 'error', bot: 'warning', duplicate: 'default',
  28. }
  29. const sourceTypeColor: Record<string, string> = { web: 'blue', tg_channel: 'orange', github: 'geekblue' }
  30. interface SourceInfo {
  31. source_type: string
  32. source_name: string
  33. source_url: string
  34. }
  35. function formatDateTime(dateStr: string) {
  36. return new Date(dateStr).toLocaleString('zh-CN')
  37. }
  38. export default function MerchantDetail() {
  39. const { id } = useParams<{ id: string }>()
  40. const navigate = useNavigate()
  41. const { isOperator, hasAction } = useAppStore()
  42. const [merchant, setMerchant] = useState<MerchantClean | null>(null)
  43. const [loading, setLoading] = useState(true)
  44. const [notes, setNotes] = useState<MerchantNote[]>([])
  45. const [noteInput, setNoteInput] = useState('')
  46. const [noteLoading, setNoteLoading] = useState(false)
  47. const [groups, setGroups] = useState<GroupMember[]>([])
  48. const [levelMap, setLevelMap] = useState<Record<string, { label: string; color: string; description: string }>>({})
  49. const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
  50. // Edit modal
  51. const [editModalOpen, setEditModalOpen] = useState(false)
  52. const [editForm] = Form.useForm()
  53. const [editLoading, setEditLoading] = useState(false)
  54. useEffect(() => {
  55. getLevelMap().then(r => setLevelMap(r.data)).catch(() => {})
  56. getAssignableUsers().then(r => setAssignableUsers(r.data || [])).catch(() => {})
  57. }, [])
  58. const loadMerchant = (merchantId: number) => {
  59. getMerchant(merchantId).then(res => {
  60. const d = res.data as { source: string; data: MerchantClean }
  61. setMerchant(d.data)
  62. if (d.data.tg_username) {
  63. getMemberGroups(d.data.tg_username).then(r => setGroups(r.data || [])).catch(() => {})
  64. }
  65. getMerchantNotes(merchantId).then(r => setNotes(r.data || [])).catch(() => {})
  66. }).catch(() => {
  67. message.error('商户不存在')
  68. navigate('/merchants')
  69. })
  70. }
  71. useEffect(() => {
  72. if (!id) return
  73. setLoading(true)
  74. getMerchant(Number(id)).then(res => {
  75. const d = res.data as { source: string; data: MerchantClean }
  76. setMerchant(d.data)
  77. if (d.data.tg_username) {
  78. getMemberGroups(d.data.tg_username).then(r => setGroups(r.data || [])).catch(() => {})
  79. }
  80. getMerchantNotes(Number(id)).then(r => setNotes(r.data || [])).catch(() => {})
  81. }).catch(() => {
  82. message.error('商户不存在')
  83. navigate('/merchants')
  84. }).finally(() => setLoading(false))
  85. }, [id, navigate])
  86. const getLevelLabel = (key: string) => levelMap[key]?.label || key
  87. const getLevelColor = (key: string) => levelMap[key]?.color || defaultLevelColor[key] || 'default'
  88. const parseSources = (): SourceInfo[] => {
  89. if (!merchant) return []
  90. try {
  91. if (Array.isArray(merchant.all_sources)) return merchant.all_sources as SourceInfo[]
  92. if (typeof merchant.all_sources === 'string') return JSON.parse(merchant.all_sources)
  93. } catch { /* ignore */ }
  94. return []
  95. }
  96. const handleFollowStatusChange = async (val: string) => {
  97. if (!merchant) return
  98. try {
  99. await updateFollowStatus(merchant.id, val)
  100. setMerchant({ ...merchant, follow_status: val })
  101. message.success('跟进状态已更新')
  102. } catch {
  103. message.error('更新失败')
  104. }
  105. }
  106. const handleAssign = async (val: string) => {
  107. if (!merchant) return
  108. try {
  109. const res = await assignMerchant(merchant.id, val)
  110. setMerchant(res.data)
  111. message.success('分配成功')
  112. } catch {
  113. message.error('分配失败')
  114. }
  115. }
  116. const handleAddNote = async () => {
  117. if (!merchant || !noteInput.trim()) return
  118. setNoteLoading(true)
  119. try {
  120. await addMerchantNote(merchant.id, noteInput.trim())
  121. setNoteInput('')
  122. message.success('备注已添加')
  123. const res = await getMerchantNotes(merchant.id)
  124. setNotes(res.data || [])
  125. } catch {
  126. message.error('添加失败')
  127. } finally {
  128. setNoteLoading(false)
  129. }
  130. }
  131. const openEditModal = () => {
  132. if (!merchant) return
  133. editForm.setFieldsValue({
  134. merchant_name: merchant.merchant_name,
  135. industry_tag: merchant.industry_tag,
  136. website: merchant.website,
  137. email: merchant.email,
  138. phone: merchant.phone,
  139. remark: merchant.remark || '',
  140. })
  141. setEditModalOpen(true)
  142. }
  143. const handleEditSave = async () => {
  144. if (!merchant) return
  145. try {
  146. const values = await editForm.validateFields()
  147. setEditLoading(true)
  148. const res = await updateMerchantClean(merchant.id, values)
  149. setMerchant(res.data)
  150. message.success('商户信息已更新')
  151. setEditModalOpen(false)
  152. } catch {
  153. message.error('更新失败')
  154. } finally {
  155. setEditLoading(false)
  156. }
  157. }
  158. const handleCollectGroup = (username: string) => {
  159. Modal.confirm({
  160. title: `采集群 @${username}`,
  161. content: `将使用 TG 采集器采集群 @${username} 中的成员和联系方式。`,
  162. okText: '开始采集',
  163. onOk: async () => {
  164. try {
  165. await startTask({ plugin_name: 'tg_collector', target_group: username })
  166. message.success(`已启动对 @${username} 的采集任务`)
  167. } catch {
  168. message.error('启动采集失败')
  169. }
  170. },
  171. })
  172. }
  173. if (loading) return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>
  174. if (!merchant) return null
  175. const sources = parseSources()
  176. return (
  177. <div style={{ maxWidth: 1200, margin: '0 auto' }}>
  178. {/* Header */}
  179. <div style={{ marginBottom: 20 }}>
  180. <Button type="link" icon={<ArrowLeftOutlined />} onClick={() => navigate('/merchants')}
  181. style={{ padding: 0, marginBottom: 12 }}>
  182. 返回列表
  183. </Button>
  184. <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
  185. <Space size="middle">
  186. <Title level={4} style={{ margin: 0 }}>
  187. <a href={`https://t.me/${merchant.tg_username}`} target="_blank" rel="noreferrer">
  188. @{merchant.tg_username}
  189. </a>
  190. </Title>
  191. {merchant.merchant_name && <Text type="secondary" style={{ fontSize: 16 }}>{merchant.merchant_name}</Text>}
  192. <Tag color={getLevelColor(merchant.level)}>{getLevelLabel(merchant.level)}</Tag>
  193. <Badge status={statusBadgeMap[merchant.status] ?? 'default'} text={merchant.status} />
  194. </Space>
  195. <Space>
  196. {hasAction('merchant_edit') && (
  197. <Button icon={<SyncOutlined />} onClick={async () => {
  198. try {
  199. await recheckMerchant(merchant.id)
  200. message.success('已标记为待重新检查')
  201. } catch { message.error('操作失败') }
  202. }}>重新检查</Button>
  203. )}
  204. {hasAction('merchant_edit') && (
  205. <Button icon={<EditOutlined />} onClick={openEditModal}>编辑</Button>
  206. )}
  207. </Space>
  208. </div>
  209. </div>
  210. <Row gutter={24}>
  211. {/* Left column */}
  212. <Col span={16}>
  213. {/* Basic info */}
  214. <Card title="基本信息" size="small" style={{ marginBottom: 16 }}>
  215. <Descriptions column={2} size="small">
  216. <Descriptions.Item label="TG链接">
  217. <a href={merchant.tg_link || `https://t.me/${merchant.tg_username}`} target="_blank" rel="noreferrer">
  218. {merchant.tg_link || `https://t.me/${merchant.tg_username}`}
  219. </a>
  220. </Descriptions.Item>
  221. <Descriptions.Item label="行业">{merchant.industry_tag || '-'}</Descriptions.Item>
  222. <Descriptions.Item label="网站" span={2}>
  223. {merchant.website ? <a href={merchant.website} target="_blank" rel="noreferrer">{merchant.website}</a> : '-'}
  224. </Descriptions.Item>
  225. <Descriptions.Item label="邮箱">{merchant.email || '-'}</Descriptions.Item>
  226. <Descriptions.Item label="电话">{merchant.phone || '-'}</Descriptions.Item>
  227. <Descriptions.Item label="存活状态">
  228. {merchant.is_alive ? <Tag color="green">存活</Tag> : <Tag color="red">失效</Tag>}
  229. </Descriptions.Item>
  230. <Descriptions.Item label="最后检查">
  231. {merchant.last_checked_at ? formatDateTime(merchant.last_checked_at) : '-'}
  232. </Descriptions.Item>
  233. <Descriptions.Item label="创建时间">{formatDateTime(merchant.created_at)}</Descriptions.Item>
  234. <Descriptions.Item label="更新时间">{formatDateTime(merchant.updated_at)}</Descriptions.Item>
  235. {merchant.remark && (
  236. <Descriptions.Item label="备注" span={2}>{merchant.remark}</Descriptions.Item>
  237. )}
  238. </Descriptions>
  239. </Card>
  240. {/* Sources */}
  241. <Card title={`来源记录 (${sources.length})`} size="small" style={{ marginBottom: 16 }}>
  242. {sources.length === 0 ? <Text type="secondary">无来源记录</Text> : sources.map((src, idx) => (
  243. <div key={idx} style={{
  244. background: '#fafafa', border: '1px solid #f0f0f0', borderRadius: 6,
  245. padding: '10px 14px', marginBottom: 8,
  246. }}>
  247. <Row align="middle" gutter={8}>
  248. <Col><Tag color={sourceTypeColor[src.source_type] ?? 'default'}>{src.source_type}</Tag></Col>
  249. <Col flex="auto"><Text strong style={{ fontSize: 13 }}>{src.source_name || '未知来源'}</Text></Col>
  250. </Row>
  251. {src.source_url && (
  252. <div style={{ marginTop: 6 }}>
  253. <LinkOutlined style={{ color: '#1890ff', marginRight: 4 }} />
  254. <a href={src.source_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, wordBreak: 'break-all' }}>
  255. {src.source_url}
  256. </a>
  257. </div>
  258. )}
  259. </div>
  260. ))}
  261. </Card>
  262. {/* Groups */}
  263. {groups.length > 0 && (
  264. <Card title={<><TeamOutlined /> 所属群/频道 ({groups.length})</>} size="small" style={{ marginBottom: 16 }}>
  265. {groups.map((gm, idx) => (
  266. <div key={idx} style={{
  267. background: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: 6,
  268. padding: '8px 14px', marginBottom: 6,
  269. display: 'flex', justifyContent: 'space-between', alignItems: 'center',
  270. }}>
  271. <div>
  272. <a href={`https://t.me/${gm.group_username}`} target="_blank" rel="noreferrer">
  273. @{gm.group_username}
  274. </a>
  275. {gm.group_title && <Text type="secondary" style={{ marginLeft: 8 }}>{gm.group_title}</Text>}
  276. <Tag color="green" style={{ marginLeft: 8 }}>{gm.source_type}</Tag>
  277. </div>
  278. {hasAction('task_start') && (
  279. <Button size="small" onClick={() => handleCollectGroup(gm.group_username)}>采集此群</Button>
  280. )}
  281. </div>
  282. ))}
  283. </Card>
  284. )}
  285. {/* TG Preview */}
  286. <Card title="TG 页面预览" size="small">
  287. <div style={{ border: '1px solid #f0f0f0', borderRadius: 6, overflow: 'hidden' }}>
  288. <iframe
  289. src={`https://t.me/${merchant.tg_username}`}
  290. style={{ width: '100%', height: 300, border: 'none' }}
  291. sandbox="allow-scripts allow-same-origin"
  292. title="TG Preview"
  293. />
  294. </div>
  295. </Card>
  296. </Col>
  297. {/* Right column */}
  298. <Col span={8}>
  299. {/* Status controls */}
  300. <Card title="跟进信息" size="small" style={{ marginBottom: 16 }}>
  301. <div style={{ marginBottom: 12 }}>
  302. <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>跟进状态</Text>
  303. <Select
  304. value={merchant.follow_status || 'pending'}
  305. style={{ width: '100%' }}
  306. onChange={handleFollowStatusChange}
  307. disabled={!hasAction('merchant_edit')}
  308. >
  309. {Object.entries(followStatusLabels).map(([k, v]) => (
  310. <Option key={k} value={k}>
  311. <Badge status={followStatusBadge[k] ?? 'default'} text={v} />
  312. </Option>
  313. ))}
  314. </Select>
  315. </div>
  316. <div>
  317. <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
  318. <UserSwitchOutlined /> 负责人
  319. </Text>
  320. <Select
  321. value={merchant.assigned_to || undefined}
  322. placeholder="未分配"
  323. style={{ width: '100%' }}
  324. onChange={handleAssign}
  325. allowClear
  326. disabled={!hasAction('merchant_assign')}
  327. >
  328. {assignableUsers.map(u => (
  329. <Option key={u.username} value={u.username}>{u.nickname || u.username}</Option>
  330. ))}
  331. </Select>
  332. </div>
  333. </Card>
  334. {/* Notes timeline */}
  335. <Card title={`跟进备注 (${notes.length})`} size="small">
  336. {hasAction('merchant_edit') && (
  337. <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
  338. <Input.TextArea
  339. placeholder="输入备注..."
  340. value={noteInput}
  341. onChange={(e) => setNoteInput(e.target.value)}
  342. autoSize={{ minRows: 2, maxRows: 4 }}
  343. style={{ flex: 1 }}
  344. />
  345. <Button type="primary" loading={noteLoading} onClick={handleAddNote}
  346. disabled={!noteInput.trim()} style={{ alignSelf: 'flex-end' }}>
  347. 添加
  348. </Button>
  349. </div>
  350. )}
  351. {notes.length === 0 ? (
  352. <Text type="secondary">暂无备注</Text>
  353. ) : (
  354. <Timeline
  355. items={notes.map(note => ({
  356. children: (
  357. <div>
  358. <div style={{ fontSize: 13 }}>{note.content}</div>
  359. <div style={{ fontSize: 11, color: '#999', marginTop: 4 }}>
  360. {note.created_by} · {formatDateTime(note.created_at)}
  361. </div>
  362. </div>
  363. ),
  364. }))}
  365. />
  366. )}
  367. </Card>
  368. </Col>
  369. </Row>
  370. {/* Edit Modal */}
  371. <Modal
  372. title="编辑商户信息"
  373. open={editModalOpen}
  374. onCancel={() => setEditModalOpen(false)}
  375. onOk={handleEditSave}
  376. confirmLoading={editLoading}
  377. okText="保存"
  378. cancelText="取消"
  379. >
  380. <Form form={editForm} layout="vertical">
  381. <Form.Item name="merchant_name" label="商户名"><Input /></Form.Item>
  382. <Form.Item name="industry_tag" label="行业标签"><Input /></Form.Item>
  383. <Form.Item name="website" label="网站"><Input /></Form.Item>
  384. <Form.Item name="email" label="邮箱"><Input /></Form.Item>
  385. <Form.Item name="phone" label="电话"><Input /></Form.Item>
  386. <Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
  387. </Form>
  388. </Modal>
  389. </div>
  390. )
  391. }