import { useCallback, useEffect, useMemo, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { api, ApiError } from '../api/client' import { ExpandGraphJobPanel } from '../components/ExpandGraphJobPanel' import { PlacementFlowNav } from '../components/PlacementFlowNav' import { PlacementScanJobPanel } from '../components/PlacementScanJobPanel' import { ResearchMapOverview } from '../components/ResearchMapOverview' import { rememberTopicId } from '../lib/brandContext' import { patrolTagContextFromPlacementTopic, resolvePatrolKeywords, savedPatrolKeywords, type ExpandKnowledgeGraphData, type KnowledgeGraphData, } from '../lib/knowledgeGraph' import { placementFlowPath } from '../lib/placementFlow' import { activeExpandJobHint, expandGraphActionLabel, expandJobTerminalMessage, formatActiveJobConflictError, isActiveJobStatus, isTerminalJobStatus, } from '../lib/jobStatus' import { topicResearchMapPath, topicSettingsPath, topicTitle } from '../lib/placementTopics' import type { ResearchMapDraft } from '../components/ResearchMapEditor' import { hasResearchMap } from '../lib/placementTopics' import type { BrandData } from '../types/brand' import type { ListPlacementTopicsData, PlacementTopicData } from '../types/placementTopic' import type { JobData } from '../types/api' import { AcLink, Button, Card, ErrorText, Notice, PageTitle, SuccessText } from '../components/ui' type LocationState = { expandJobId?: string } export function PlacementTopicResearchMapPage() { const navigate = useNavigate() const location = useLocation() const { id = '' } = useParams() const [topic, setTopic] = useState(null) const [catalogBrand, setCatalogBrand] = useState(null) const [graph, setGraph] = useState(null) const [expandJob, setExpandJob] = useState(null) const [expandJobId, setExpandJobId] = useState(null) const [loading, setLoading] = useState(true) const [expanding, setExpanding] = useState(false) const [scanJob, setScanJob] = useState(null) const [scanJobId, setScanJobId] = useState(null) const [scanning, setScanning] = useState(false) const [error, setError] = useState('') const [message, setMessage] = useState('') const [topics, setTopics] = useState([]) const reloadGraph = useCallback(async () => { if (!id) return null try { const data = await api.get( `/api/v1/placement/topics/${encodeURIComponent(id)}/knowledge-graph`, { auth: true }, ) setGraph(data) return data } catch (e) { if (e instanceof ApiError && /not found|找不到/i.test(e.message)) { setGraph(null) return null } throw e } }, [id]) const reloadTopic = useCallback(async () => { if (!id) return null const topicRes = await api.get(`/api/v1/placement/topics/${encodeURIComponent(id)}`, { auth: true }) const brandRes = await api.get( `/api/v1/brands/${encodeURIComponent(topicRes.brand_id)}`, { auth: true }, ) setTopic(topicRes) setCatalogBrand(brandRes) await reloadGraph().catch(() => null) return topicRes }, [id, reloadGraph]) const loadActiveJobs = useCallback(async () => { if (!id) return const data = await api.get<{ list: JobData[] }>('/api/v1/jobs', { auth: true, query: { page: 1, pageSize: 20, scope: 'placement_topic', scope_id: id }, }) const activeExpand = (data.list ?? []).find( (job) => job.template_type === 'expand-graph' && isActiveJobStatus(job.status), ) const activeScan = (data.list ?? []).find( (job) => job.template_type === 'placement-scan' && isActiveJobStatus(job.status), ) setExpandJob(activeExpand ?? null) setExpandJobId(activeExpand?.id ?? null) setScanJob(activeScan ?? null) setScanJobId(activeScan?.id ?? null) if (activeExpand) { setMessage(activeExpandJobHint(activeExpand)) setError('') } }, [id]) const refreshExpandJob = useCallback( async (jobId: string) => { try { const job = await api.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { auth: true }) if (isTerminalJobStatus(job.status)) { setExpandJob(null) setExpandJobId(null) const terminalMsg = expandJobTerminalMessage(job.status) if (job.status === 'succeeded') { await reloadTopic() } if (terminalMsg) { setMessage(terminalMsg) setError('') } else { setError(job.error?.trim() || job.progress?.summary || '研究地圖產生失敗') setMessage('') } return } setExpandJob(job) setExpandJobId(job.id) setMessage(activeExpandJobHint(job)) setError('') await reloadTopic() } catch (e) { setExpandJob(null) setExpandJobId(null) setError(e instanceof ApiError ? e.message : '無法追蹤產生進度') } }, [reloadTopic], ) useEffect(() => { api .get('/api/v1/placement/topics/', { auth: true }) .then((data) => setTopics(data.list ?? [])) .catch(() => setTopics([])) }, []) useEffect(() => { if (!id) return setLoading(true) setError('') Promise.all([reloadTopic(), loadActiveJobs()]) .catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗')) .finally(() => setLoading(false)) }, [id, reloadTopic, loadActiveJobs]) useEffect(() => { const state = location.state as LocationState | null if (state?.expandJobId) { setExpandJobId(state.expandJobId) navigate(topicResearchMapPath(id), { replace: true, state: null }) } }, [id, location.state, navigate]) useEffect(() => { if (!expandJobId) return void refreshExpandJob(expandJobId) const timer = window.setInterval(() => { refreshExpandJob(expandJobId).catch(() => undefined) }, 3000) return () => window.clearInterval(timer) }, [expandJobId, refreshExpandJob]) const refreshScanJob = useCallback( async (jobId: string) => { try { const job = await api.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { auth: true }) if (isTerminalJobStatus(job.status)) { setScanJob(null) setScanJobId(null) if (job.status === 'succeeded') { setMessage(job.progress?.summary || '海巡完成,可到獲客台查看貼文') setError('') } else { setError(job.error?.trim() || job.progress?.summary || '海巡失敗') setMessage('') } return } setScanJob(job) setScanJobId(job.id) setMessage(job.progress?.summary || '雙軌海巡進行中…') setError('') } catch (e) { setScanJob(null) setScanJobId(null) setError(e instanceof ApiError ? e.message : '無法追蹤海巡進度') } }, [], ) useEffect(() => { if (!scanJobId) return void refreshScanJob(scanJobId) const timer = window.setInterval(() => { refreshScanJob(scanJobId).catch(() => undefined) }, 3000) return () => window.clearInterval(timer) }, [scanJobId, refreshScanJob]) const saveResearchMap = useCallback( async (draft: ResearchMapDraft) => { if (!id) return const updated = await api.patch( `/api/v1/placement/topics/${encodeURIComponent(id)}`, { audience_summary: draft.audience_summary, content_goal: draft.content_goal, questions: draft.questions, pillars: draft.pillars, exclusions: draft.exclusions, patrol_keywords: draft.patrol_keywords, }, { auth: true }, ) setTopic(updated) setMessage( savedPatrolKeywords(updated.research_map).length > 0 ? `研究地圖已儲存(${savedPatrolKeywords(updated.research_map).length} 組海巡關鍵字)` : '研究地圖已儲存', ) setError('') }, [id], ) const startPatrolScan = async () => { if (!id || loading || scanning || scanActive || jobActive) return const keywords = resolvePatrolKeywords( topic?.research_map, graph?.nodes ?? [], patrolTagContextFromPlacementTopic(topic, catalogBrand), ) if (!keywords.length) { setError('請先完成研究地圖產生,系統會依受眾提問自動整理海巡關鍵字') setMessage('') return } setScanning(true) setError('') setMessage('正在建立海巡任務…') try { rememberTopicId(id) const data = await api.post<{ job_id: string; message?: string }>( `/api/v1/placement/topics/${encodeURIComponent(id)}/scan-jobs`, { dual_track: true, patrol_mode: true, patrol_keywords: keywords }, { auth: true }, ) setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`) setScanJobId(data.job_id) await refreshScanJob(data.job_id) } catch (e) { const msg = e instanceof ApiError ? e.message : '啟動海巡失敗' setError(msg) setMessage('') } finally { setScanning(false) } } const expandMap = async () => { const seed = topic?.seed_query?.trim() if (!id || !seed) { setError('請先到主題設定填寫種子關鍵字') return } setExpanding(true) setError('') setMessage('') try { const job = await api.post( `/api/v1/placement/topics/${encodeURIComponent(id)}/knowledge-graph/expand`, { seed_query: seed, regenerate_map: true }, { auth: true }, ) setExpandJobId(job.job_id) await refreshExpandJob(job.job_id) setMessage(job.message || '研究地圖產生中…') } catch (e) { const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗' setError(formatActiveJobConflictError(raw)) void loadActiveJobs().catch(() => undefined) } finally { setExpanding(false) } } if (!id) { return (

未指定主題。

) } const title = topic ? topicTitle(topic) : '研究地圖' const seedReady = !!topic?.seed_query?.trim() const jobActive = expanding || (expandJob != null && isActiveJobStatus(expandJob.status)) const mapLoading = jobActive && expandJob?.status !== 'cancel_requested' && !hasResearchMap(topic) && !(graph?.nodes?.length) const actionLabel = expandGraphActionLabel(expandJob?.status, hasResearchMap(topic)) const actionTitle = expandJob?.status === 'cancel_requested' ? '任務取消中,請稍候完成後再重新產生' : jobActive ? '研究地圖產生中,請稍候' : undefined const patrolContext = useMemo( () => patrolTagContextFromPlacementTopic(topic, catalogBrand), [topic, catalogBrand], ) const patrolKeywords = useMemo( () => resolvePatrolKeywords(topic?.research_map, graph?.nodes ?? [], patrolContext), [topic?.research_map, graph?.nodes, patrolContext], ) const scanActive = scanning || (scanJob != null && isActiveJobStatus(scanJob.status)) const patrolBlockedReason = jobActive ? '研究地圖產生中,請稍候再海巡' : scanActive ? '海巡任務進行中' : patrolKeywords.length === 0 ? '請先產生研究地圖(會自動整理海巡關鍵字)' : '' const onTopicChange = (nextId: string) => { rememberTopicId(nextId) navigate(topicResearchMapPath(nextId)) } return (
← 找 TA 主題
{hasResearchMap(topic) || graph?.nodes?.length ? ( ) : null}
{patrolBlockedReason && !loading && !scanning && !scanActive && !jobActive ? ( ) : null} {!seedReady && !loading ? (

尚未設定種子關鍵字。請到 填寫並儲存後,再按「產生研究地圖」。

) : null} {patrolKeywords.length > 0 ? ( ) : hasResearchMap(topic) ? ( ) : null}
{jobActive ? (
{expandJob ? ( ) : (

研究地圖產生中,正在建立背景任務…

)}
) : null} {scanJob ? (

雙軌海巡進度

{scanJob.status === 'succeeded' ? ( 前往獲客台 → ) : null}
) : null}
) }