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

462 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 [topics, setTopics] = useState<PlacementTopicData[]>([])
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(() => {
api
.get<ListPlacementTopicsData>('/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<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, 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<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
? '請先產生研究地圖(會自動整理海巡關鍵字)'
: ''
const onTopicChange = (nextId: string) => {
rememberTopicId(nextId)
navigate(topicResearchMapPath(nextId))
}
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>
<PlacementFlowNav
active="research"
topicId={id}
topics={topics}
onTopicChange={onTopicChange}
topicLoading={loading}
/>
<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>
)
}