haixunMaster/haixun-backend/web/src/pages/PlacementTopicResearchMapPa...

440 lines
15 KiB
TypeScript
Raw Normal View History

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'
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 { 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<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('')
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],
)
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`,
{ dual_track: true, patrol_mode: true },
{ 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
? '請先產生研究地圖(會自動整理海巡關鍵字)'
: ''
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>
<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>
)
}