2026-06-24 16:48:56 +00:00
|
|
|
|
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'
|
2026-06-24 17:30:47 +00:00
|
|
|
|
import { PlacementFlowNav } from '../components/PlacementFlowNav'
|
2026-06-24 16:48:56 +00:00
|
|
|
|
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'
|
2026-06-24 17:30:47 +00:00
|
|
|
|
import type { ListPlacementTopicsData, PlacementTopicData } from '../types/placementTopic'
|
2026-06-24 16:48:56 +00:00
|
|
|
|
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<PlacementTopicData | null>(null)
|
|
|
|
|
|
const [catalogBrand, setCatalogBrand] = useState<BrandData | null>(null)
|
|
|
|
|
|
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
|
|
|
|
|
|
const [expandJob, setExpandJob] = useState<JobData | null>(null)
|
|
|
|
|
|
const [expandJobId, setExpandJobId] = useState<string | null>(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [expanding, setExpanding] = useState(false)
|
|
|
|
|
|
const [scanJob, setScanJob] = useState<JobData | null>(null)
|
|
|
|
|
|
const [scanJobId, setScanJobId] = useState<string | null>(null)
|
|
|
|
|
|
const [scanning, setScanning] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
const [message, setMessage] = useState('')
|
2026-06-24 17:30:47 +00:00
|
|
|
|
const [topics, setTopics] = useState<PlacementTopicData[]>([])
|
2026-06-24 16:48:56 +00:00
|
|
|
|
|
|
|
|
|
|
const reloadGraph = useCallback(async () => {
|
|
|
|
|
|
if (!id) return null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.get<KnowledgeGraphData>(
|
|
|
|
|
|
`/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<PlacementTopicData>(`/api/v1/placement/topics/${encodeURIComponent(id)}`, { auth: true })
|
|
|
|
|
|
const brandRes = await api.get<BrandData>(
|
|
|
|
|
|
`/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<JobData>(`/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],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-24 17:30:47 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
api
|
|
|
|
|
|
.get<ListPlacementTopicsData>('/api/v1/placement/topics/', { auth: true })
|
|
|
|
|
|
.then((data) => setTopics(data.list ?? []))
|
|
|
|
|
|
.catch(() => setTopics([]))
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-06-24 16:48:56 +00:00
|
|
|
|
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<JobData>(`/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<PlacementTopicData>(
|
|
|
|
|
|
`/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`,
|
2026-06-24 17:30:47 +00:00
|
|
|
|
{ dual_track: true, patrol_mode: true, patrol_keywords: keywords },
|
2026-06-24 16:48:56 +00:00
|
|
|
|
{ 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<ExpandKnowledgeGraphData>(
|
|
|
|
|
|
`/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 (
|
|
|
|
|
|
<Card className="py-8 text-center">
|
|
|
|
|
|
<p className="text-base text-muted">未指定主題。</p>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
? '請先產生研究地圖(會自動整理海巡關鍵字)'
|
|
|
|
|
|
: ''
|
|
|
|
|
|
|
2026-06-24 17:30:47 +00:00
|
|
|
|
const onTopicChange = (nextId: string) => {
|
|
|
|
|
|
rememberTopicId(nextId)
|
|
|
|
|
|
navigate(topicResearchMapPath(nextId))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 16:48:56 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="mx-auto w-full max-w-6xl space-y-6">
|
|
|
|
|
|
<AcLink to="/placement/topics" className="inline-flex items-center gap-1.5 text-sm">
|
|
|
|
|
|
← 找 TA 主題
|
|
|
|
|
|
</AcLink>
|
|
|
|
|
|
|
2026-06-24 17:30:47 +00:00
|
|
|
|
<PlacementFlowNav
|
|
|
|
|
|
active="research"
|
|
|
|
|
|
topicId={id}
|
|
|
|
|
|
topics={topics}
|
|
|
|
|
|
onTopicChange={onTopicChange}
|
|
|
|
|
|
topicLoading={loading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-06-24 16:48:56 +00:00
|
|
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
|
|
|
|
<PageTitle title="研究地圖" subtitle={loading ? '載入中…' : `「${title}」的受眾方向與延伸知識。`} />
|
|
|
|
|
|
<div className="hx-page-actions">
|
|
|
|
|
|
<Button variant="ghost" onClick={() => navigate(topicSettingsPath(id))}>
|
|
|
|
|
|
主題設定
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="soft"
|
|
|
|
|
|
onClick={() => void expandMap()}
|
|
|
|
|
|
disabled={loading || jobActive || !seedReady}
|
|
|
|
|
|
title={actionTitle}
|
|
|
|
|
|
>
|
|
|
|
|
|
{actionLabel}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{hasResearchMap(topic) || graph?.nodes?.length ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={() => void startPatrolScan()}
|
|
|
|
|
|
disabled={loading || scanning || scanActive || jobActive}
|
|
|
|
|
|
title={patrolBlockedReason || undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{scanActive ? '海巡進行中…' : scanning ? '建立任務中…' : '開始海巡'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<SuccessText message={message} />
|
|
|
|
|
|
<ErrorText message={error} />
|
|
|
|
|
|
|
|
|
|
|
|
{patrolBlockedReason && !loading && !scanning && !scanActive && !jobActive ? (
|
|
|
|
|
|
<Notice tone="warning" title="還不能開始海巡" message={patrolBlockedReason} />
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{!seedReady && !loading ? (
|
|
|
|
|
|
<Card className="border-warning/30 bg-warning-soft/40">
|
|
|
|
|
|
<p className="text-sm text-ink">
|
|
|
|
|
|
尚未設定種子關鍵字。請到
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="mx-1 font-semibold text-brand hover:underline"
|
|
|
|
|
|
onClick={() => navigate(topicSettingsPath(id))}
|
|
|
|
|
|
>
|
|
|
|
|
|
主題設定
|
|
|
|
|
|
</button>
|
|
|
|
|
|
填寫並儲存後,再按「產生研究地圖」。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{patrolKeywords.length > 0 ? (
|
|
|
|
|
|
<Notice
|
|
|
|
|
|
tone="info"
|
|
|
|
|
|
title={`將海巡 ${patrolKeywords.length} 組搜尋短句`}
|
|
|
|
|
|
message="會使用畫面上的優先海巡關鍵字;相關軌+近期軌(7 天優先)。搜尋管道依帳號連線設定(API / Brave / 爬蟲)。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : hasResearchMap(topic) ? (
|
|
|
|
|
|
<Notice
|
|
|
|
|
|
tone="warning"
|
|
|
|
|
|
title="尚未設定海巡關鍵字"
|
|
|
|
|
|
message="請按「編輯研究地圖」,填入你想回覆的 Threads 搜尋短句並儲存,再按「開始海巡」。"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hx-research-map-workspace">
|
|
|
|
|
|
{jobActive ? (
|
|
|
|
|
|
<div className="mb-5">
|
|
|
|
|
|
{expandJob ? (
|
|
|
|
|
|
<ExpandGraphJobPanel job={expandJob} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<p className="text-sm text-ink-secondary">研究地圖產生中,正在建立背景任務…</p>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{scanJob ? (
|
|
|
|
|
|
<Card className="mb-5 space-y-4">
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
|
<p className="text-sm font-bold text-ink">雙軌海巡進度</p>
|
|
|
|
|
|
{scanJob.status === 'succeeded' ? (
|
|
|
|
|
|
<AcLink
|
|
|
|
|
|
to={placementFlowPath('/outreach', id)}
|
|
|
|
|
|
className="ac-btn-secondary inline-flex min-h-10 items-center px-4 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
前往獲客台 →
|
|
|
|
|
|
</AcLink>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<PlacementScanJobPanel job={scanJob} />
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<ResearchMapOverview
|
|
|
|
|
|
map={topic?.research_map}
|
|
|
|
|
|
graph={graph}
|
|
|
|
|
|
brand={topic}
|
|
|
|
|
|
catalogBrand={catalogBrand}
|
|
|
|
|
|
patrolKeywords={patrolKeywords}
|
|
|
|
|
|
loading={loading || mapLoading}
|
|
|
|
|
|
onSaveResearchMap={saveResearchMap}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|