| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419 |
- import { useEffect, useState } from 'react'
- import { useParams, useNavigate } from 'react-router-dom'
- import {
- Card, Descriptions, Tag, Badge, Button, Space, Typography, Timeline, Input,
- message, Spin, Select, Row, Col, Modal, Form,
- } from 'antd'
- import {
- ArrowLeftOutlined, EditOutlined, LinkOutlined, TeamOutlined, UserSwitchOutlined, SyncOutlined,
- } from '@ant-design/icons'
- import {
- getMerchant, getMemberGroups, getMerchantNotes, addMerchantNote,
- updateFollowStatus, getLevelMap, getAssignableUsers, assignMerchant,
- updateMerchantClean, startTask, recheckMerchant,
- type MerchantClean, type GroupMember, type MerchantNote, type AssignableUser,
- } from '../api'
- import { useAppStore } from '../store'
- const { Text, Title } = Typography
- const { Option } = Select
- const defaultLevelColor: Record<string, string> = { Hot: 'red', Warm: 'orange', Cold: 'blue' }
- const followStatusLabels: Record<string, string> = {
- pending: '待跟进', contacted: '已联系', cooperating: '已合作', rejected: '已拒绝',
- }
- const followStatusBadge: Record<string, 'default' | 'processing' | 'success' | 'error'> = {
- pending: 'default', contacted: 'processing', cooperating: 'success', rejected: 'error',
- }
- const statusBadgeMap: Record<string, 'success' | 'error' | 'warning' | 'default'> = {
- valid: 'success', invalid: 'error', bot: 'warning', duplicate: 'default',
- }
- const sourceTypeColor: Record<string, string> = { web: 'blue', tg_channel: 'orange', github: 'geekblue' }
- interface SourceInfo {
- source_type: string
- source_name: string
- source_url: string
- }
- function formatDateTime(dateStr: string) {
- return new Date(dateStr).toLocaleString('zh-CN')
- }
- export default function MerchantDetail() {
- const { id } = useParams<{ id: string }>()
- const navigate = useNavigate()
- const { isOperator, hasAction } = useAppStore()
- const [merchant, setMerchant] = useState<MerchantClean | null>(null)
- const [loading, setLoading] = useState(true)
- const [notes, setNotes] = useState<MerchantNote[]>([])
- const [noteInput, setNoteInput] = useState('')
- const [noteLoading, setNoteLoading] = useState(false)
- const [groups, setGroups] = useState<GroupMember[]>([])
- const [levelMap, setLevelMap] = useState<Record<string, { label: string; color: string; description: string }>>({})
- const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
- // Edit modal
- const [editModalOpen, setEditModalOpen] = useState(false)
- const [editForm] = Form.useForm()
- const [editLoading, setEditLoading] = useState(false)
- useEffect(() => {
- getLevelMap().then(r => setLevelMap(r.data)).catch(() => {})
- getAssignableUsers().then(r => setAssignableUsers(r.data || [])).catch(() => {})
- }, [])
- const loadMerchant = (merchantId: number) => {
- getMerchant(merchantId).then(res => {
- const d = res.data as { source: string; data: MerchantClean }
- setMerchant(d.data)
- if (d.data.tg_username) {
- getMemberGroups(d.data.tg_username).then(r => setGroups(r.data || [])).catch(() => {})
- }
- getMerchantNotes(merchantId).then(r => setNotes(r.data || [])).catch(() => {})
- }).catch(() => {
- message.error('商户不存在')
- navigate('/merchants')
- })
- }
- useEffect(() => {
- if (!id) return
- setLoading(true)
- getMerchant(Number(id)).then(res => {
- const d = res.data as { source: string; data: MerchantClean }
- setMerchant(d.data)
- if (d.data.tg_username) {
- getMemberGroups(d.data.tg_username).then(r => setGroups(r.data || [])).catch(() => {})
- }
- getMerchantNotes(Number(id)).then(r => setNotes(r.data || [])).catch(() => {})
- }).catch(() => {
- message.error('商户不存在')
- navigate('/merchants')
- }).finally(() => setLoading(false))
- }, [id, navigate])
- const getLevelLabel = (key: string) => levelMap[key]?.label || key
- const getLevelColor = (key: string) => levelMap[key]?.color || defaultLevelColor[key] || 'default'
- const parseSources = (): SourceInfo[] => {
- if (!merchant) return []
- try {
- if (Array.isArray(merchant.all_sources)) return merchant.all_sources as SourceInfo[]
- if (typeof merchant.all_sources === 'string') return JSON.parse(merchant.all_sources)
- } catch { /* ignore */ }
- return []
- }
- const handleFollowStatusChange = async (val: string) => {
- if (!merchant) return
- try {
- await updateFollowStatus(merchant.id, val)
- setMerchant({ ...merchant, follow_status: val })
- message.success('跟进状态已更新')
- } catch {
- message.error('更新失败')
- }
- }
- const handleAssign = async (val: string) => {
- if (!merchant) return
- try {
- const res = await assignMerchant(merchant.id, val)
- setMerchant(res.data)
- message.success('分配成功')
- } catch {
- message.error('分配失败')
- }
- }
- const handleAddNote = async () => {
- if (!merchant || !noteInput.trim()) return
- setNoteLoading(true)
- try {
- await addMerchantNote(merchant.id, noteInput.trim())
- setNoteInput('')
- message.success('备注已添加')
- const res = await getMerchantNotes(merchant.id)
- setNotes(res.data || [])
- } catch {
- message.error('添加失败')
- } finally {
- setNoteLoading(false)
- }
- }
- const openEditModal = () => {
- if (!merchant) return
- editForm.setFieldsValue({
- merchant_name: merchant.merchant_name,
- industry_tag: merchant.industry_tag,
- website: merchant.website,
- email: merchant.email,
- phone: merchant.phone,
- remark: merchant.remark || '',
- })
- setEditModalOpen(true)
- }
- const handleEditSave = async () => {
- if (!merchant) return
- try {
- const values = await editForm.validateFields()
- setEditLoading(true)
- const res = await updateMerchantClean(merchant.id, values)
- setMerchant(res.data)
- message.success('商户信息已更新')
- setEditModalOpen(false)
- } catch {
- message.error('更新失败')
- } finally {
- setEditLoading(false)
- }
- }
- const handleCollectGroup = (username: string) => {
- Modal.confirm({
- title: `采集群 @${username}`,
- content: `将使用 TG 采集器采集群 @${username} 中的成员和联系方式。`,
- okText: '开始采集',
- onOk: async () => {
- try {
- await startTask({ plugin_name: 'tg_collector', target_group: username })
- message.success(`已启动对 @${username} 的采集任务`)
- } catch {
- message.error('启动采集失败')
- }
- },
- })
- }
- if (loading) return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>
- if (!merchant) return null
- const sources = parseSources()
- return (
- <div style={{ maxWidth: 1200, margin: '0 auto' }}>
- {/* Header */}
- <div style={{ marginBottom: 20 }}>
- <Button type="link" icon={<ArrowLeftOutlined />} onClick={() => navigate('/merchants')}
- style={{ padding: 0, marginBottom: 12 }}>
- 返回列表
- </Button>
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
- <Space size="middle">
- <Title level={4} style={{ margin: 0 }}>
- <a href={`https://t.me/${merchant.tg_username}`} target="_blank" rel="noreferrer">
- @{merchant.tg_username}
- </a>
- </Title>
- {merchant.merchant_name && <Text type="secondary" style={{ fontSize: 16 }}>{merchant.merchant_name}</Text>}
- <Tag color={getLevelColor(merchant.level)}>{getLevelLabel(merchant.level)}</Tag>
- <Badge status={statusBadgeMap[merchant.status] ?? 'default'} text={merchant.status} />
- </Space>
- <Space>
- {hasAction('merchant_edit') && (
- <Button icon={<SyncOutlined />} onClick={async () => {
- try {
- await recheckMerchant(merchant.id)
- message.success('已标记为待重新检查')
- } catch { message.error('操作失败') }
- }}>重新检查</Button>
- )}
- {hasAction('merchant_edit') && (
- <Button icon={<EditOutlined />} onClick={openEditModal}>编辑</Button>
- )}
- </Space>
- </div>
- </div>
- <Row gutter={24}>
- {/* Left column */}
- <Col span={16}>
- {/* Basic info */}
- <Card title="基本信息" size="small" style={{ marginBottom: 16 }}>
- <Descriptions column={2} size="small">
- <Descriptions.Item label="TG链接">
- <a href={merchant.tg_link || `https://t.me/${merchant.tg_username}`} target="_blank" rel="noreferrer">
- {merchant.tg_link || `https://t.me/${merchant.tg_username}`}
- </a>
- </Descriptions.Item>
- <Descriptions.Item label="行业">{merchant.industry_tag || '-'}</Descriptions.Item>
- <Descriptions.Item label="网站" span={2}>
- {merchant.website ? <a href={merchant.website} target="_blank" rel="noreferrer">{merchant.website}</a> : '-'}
- </Descriptions.Item>
- <Descriptions.Item label="邮箱">{merchant.email || '-'}</Descriptions.Item>
- <Descriptions.Item label="电话">{merchant.phone || '-'}</Descriptions.Item>
- <Descriptions.Item label="存活状态">
- {merchant.is_alive ? <Tag color="green">存活</Tag> : <Tag color="red">失效</Tag>}
- </Descriptions.Item>
- <Descriptions.Item label="最后检查">
- {merchant.last_checked_at ? formatDateTime(merchant.last_checked_at) : '-'}
- </Descriptions.Item>
- <Descriptions.Item label="创建时间">{formatDateTime(merchant.created_at)}</Descriptions.Item>
- <Descriptions.Item label="更新时间">{formatDateTime(merchant.updated_at)}</Descriptions.Item>
- {merchant.remark && (
- <Descriptions.Item label="备注" span={2}>{merchant.remark}</Descriptions.Item>
- )}
- </Descriptions>
- </Card>
- {/* Sources */}
- <Card title={`来源记录 (${sources.length})`} size="small" style={{ marginBottom: 16 }}>
- {sources.length === 0 ? <Text type="secondary">无来源记录</Text> : sources.map((src, idx) => (
- <div key={idx} style={{
- background: '#fafafa', border: '1px solid #f0f0f0', borderRadius: 6,
- padding: '10px 14px', marginBottom: 8,
- }}>
- <Row align="middle" gutter={8}>
- <Col><Tag color={sourceTypeColor[src.source_type] ?? 'default'}>{src.source_type}</Tag></Col>
- <Col flex="auto"><Text strong style={{ fontSize: 13 }}>{src.source_name || '未知来源'}</Text></Col>
- </Row>
- {src.source_url && (
- <div style={{ marginTop: 6 }}>
- <LinkOutlined style={{ color: '#1890ff', marginRight: 4 }} />
- <a href={src.source_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, wordBreak: 'break-all' }}>
- {src.source_url}
- </a>
- </div>
- )}
- </div>
- ))}
- </Card>
- {/* Groups */}
- {groups.length > 0 && (
- <Card title={<><TeamOutlined /> 所属群/频道 ({groups.length})</>} size="small" style={{ marginBottom: 16 }}>
- {groups.map((gm, idx) => (
- <div key={idx} style={{
- background: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: 6,
- padding: '8px 14px', marginBottom: 6,
- display: 'flex', justifyContent: 'space-between', alignItems: 'center',
- }}>
- <div>
- <a href={`https://t.me/${gm.group_username}`} target="_blank" rel="noreferrer">
- @{gm.group_username}
- </a>
- {gm.group_title && <Text type="secondary" style={{ marginLeft: 8 }}>{gm.group_title}</Text>}
- <Tag color="green" style={{ marginLeft: 8 }}>{gm.source_type}</Tag>
- </div>
- {hasAction('task_start') && (
- <Button size="small" onClick={() => handleCollectGroup(gm.group_username)}>采集此群</Button>
- )}
- </div>
- ))}
- </Card>
- )}
- {/* TG Preview */}
- <Card title="TG 页面预览" size="small">
- <div style={{ border: '1px solid #f0f0f0', borderRadius: 6, overflow: 'hidden' }}>
- <iframe
- src={`https://t.me/${merchant.tg_username}`}
- style={{ width: '100%', height: 300, border: 'none' }}
- sandbox="allow-scripts allow-same-origin"
- title="TG Preview"
- />
- </div>
- </Card>
- </Col>
- {/* Right column */}
- <Col span={8}>
- {/* Status controls */}
- <Card title="跟进信息" size="small" style={{ marginBottom: 16 }}>
- <div style={{ marginBottom: 12 }}>
- <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>跟进状态</Text>
- <Select
- value={merchant.follow_status || 'pending'}
- style={{ width: '100%' }}
- onChange={handleFollowStatusChange}
- disabled={!hasAction('merchant_edit')}
- >
- {Object.entries(followStatusLabels).map(([k, v]) => (
- <Option key={k} value={k}>
- <Badge status={followStatusBadge[k] ?? 'default'} text={v} />
- </Option>
- ))}
- </Select>
- </div>
- <div>
- <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
- <UserSwitchOutlined /> 负责人
- </Text>
- <Select
- value={merchant.assigned_to || undefined}
- placeholder="未分配"
- style={{ width: '100%' }}
- onChange={handleAssign}
- allowClear
- disabled={!hasAction('merchant_assign')}
- >
- {assignableUsers.map(u => (
- <Option key={u.username} value={u.username}>{u.nickname || u.username}</Option>
- ))}
- </Select>
- </div>
- </Card>
- {/* Notes timeline */}
- <Card title={`跟进备注 (${notes.length})`} size="small">
- {hasAction('merchant_edit') && (
- <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
- <Input.TextArea
- placeholder="输入备注..."
- value={noteInput}
- onChange={(e) => setNoteInput(e.target.value)}
- autoSize={{ minRows: 2, maxRows: 4 }}
- style={{ flex: 1 }}
- />
- <Button type="primary" loading={noteLoading} onClick={handleAddNote}
- disabled={!noteInput.trim()} style={{ alignSelf: 'flex-end' }}>
- 添加
- </Button>
- </div>
- )}
- {notes.length === 0 ? (
- <Text type="secondary">暂无备注</Text>
- ) : (
- <Timeline
- items={notes.map(note => ({
- children: (
- <div>
- <div style={{ fontSize: 13 }}>{note.content}</div>
- <div style={{ fontSize: 11, color: '#999', marginTop: 4 }}>
- {note.created_by} · {formatDateTime(note.created_at)}
- </div>
- </div>
- ),
- }))}
- />
- )}
- </Card>
- </Col>
- </Row>
- {/* Edit Modal */}
- <Modal
- title="编辑商户信息"
- open={editModalOpen}
- onCancel={() => setEditModalOpen(false)}
- onOk={handleEditSave}
- confirmLoading={editLoading}
- okText="保存"
- cancelText="取消"
- >
- <Form form={editForm} layout="vertical">
- <Form.Item name="merchant_name" label="商户名"><Input /></Form.Item>
- <Form.Item name="industry_tag" label="行业标签"><Input /></Form.Item>
- <Form.Item name="website" label="网站"><Input /></Form.Item>
- <Form.Item name="email" label="邮箱"><Input /></Form.Item>
- <Form.Item name="phone" label="电话"><Input /></Form.Item>
- <Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
- </Form>
- </Modal>
- </div>
- )
- }
|