thread-master/frontend/src/pages/PlacementTopicSettingsPage.tsx

224 lines
7.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api, ApiError } from '../api/client'
import { BrandProductPicker } from '../components/BrandProductPicker'
import { rememberTopicId } from '../lib/brandContext'
import { brandHasCatalogProducts } from '../lib/productCatalog'
import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { formatActiveJobConflictError } from '../lib/jobStatus'
import { hasResearchMap, topicResearchMapPath, topicTitle } from '../lib/placementTopics'
import type { BrandData, ListBrandsData } from '../types/brand'
import type { PlacementTopicData } from '../types/placementTopic'
import { AcLink, Button, Card, ErrorText, Field, Input, PageTitle, SuccessText, Textarea } from '../components/ui'
export function PlacementTopicSettingsPage() {
const navigate = useNavigate()
const { id = '' } = useParams()
const [catalogBrands, setCatalogBrands] = useState<BrandData[]>([])
const [topic, setTopic] = useState<PlacementTopicData | null>(null)
const [brandId, setBrandId] = useState('')
const [productId, setProductId] = useState<string | null>(null)
const [topicName, setTopicName] = useState('')
const [seedQuery, setSeedQuery] = useState('')
const [brief, setBrief] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [generating, setGenerating] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const selectedBrand = useMemo(
() => catalogBrands.find((item) => item.id === brandId) ?? null,
[brandId, catalogBrands],
)
const load = useCallback(async () => {
if (!id) return
setLoading(true)
setError('')
try {
const [brandsRes, topicRes] = await Promise.all([
api.get<ListBrandsData>('/api/v1/brands/', { auth: true }),
api.get<PlacementTopicData>(`/api/v1/placement/topics/${encodeURIComponent(id)}`, { auth: true }),
])
setCatalogBrands(brandsRes.list ?? [])
setTopic(topicRes)
setBrandId(topicRes.brand_id)
setProductId(topicRes.product_id?.trim() || null)
setTopicName(topicRes.topic_name ?? '')
setSeedQuery(topicRes.seed_query ?? '')
setBrief(topicRes.brief ?? '')
rememberTopicId(id)
} catch (e) {
setError(e instanceof ApiError ? e.message : '載入失敗')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
void load()
}, [load])
const validateForm = () => {
if (!topicName.trim()) {
setError('請填寫主題名稱')
return false
}
if (!seedQuery.trim() || !brief.trim()) {
setError('請填寫種子關鍵字與主題目標')
return false
}
if (!brandHasCatalogProducts(selectedBrand)) {
setError('請先到品牌庫為此品牌新增至少一個產品')
return false
}
return true
}
const saveTopic = async () => {
if (!id) return null
const updated = await api.patch<PlacementTopicData>(
`/api/v1/placement/topics/${encodeURIComponent(id)}`,
{
brand_id: brandId,
topic_name: topicName.trim(),
seed_query: seedQuery.trim(),
brief: brief.trim(),
product_id: productId ?? '',
},
{ auth: true },
)
setTopic(updated)
rememberTopicId(id)
return updated
}
const save = async () => {
if (!id) return
if (!validateForm()) return
setSaving(true)
setError('')
setMessage('')
try {
await saveTopic()
setMessage('已儲存主題設定')
} catch (e) {
setError(e instanceof ApiError ? e.message : '儲存失敗')
} finally {
setSaving(false)
}
}
const generateMap = async () => {
if (!id) return
if (!validateForm()) return
setGenerating(true)
setError('')
setMessage('')
try {
await saveTopic()
const job = await api.post<ExpandKnowledgeGraphData>(
`/api/v1/placement/topics/${encodeURIComponent(id)}/knowledge-graph/expand`,
{ seed_query: seedQuery.trim(), regenerate_map: true },
{ auth: true },
)
requestJobMonitorRefresh()
navigate(topicResearchMapPath(id), { state: { expandJobId: job.job_id } })
} catch (e) {
const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗'
setError(formatActiveJobConflictError(raw))
} finally {
setGenerating(false)
}
}
if (!id) {
return (
<Card className="py-8 text-center">
<p className="text-base text-muted"></p>
</Card>
)
}
const title = topic ? topicTitle(topic) : '主題設定'
return (
<div className="mx-auto w-full max-w-3xl space-y-8">
<AcLink to="/placement/topics" className="inline-flex items-center gap-1.5 text-sm">
TA
</AcLink>
<PageTitle title="主題設定" subtitle={loading ? '載入中…' : `調整「${title}」的種子詞、目標與置入產品。`} />
<Card className="grid gap-5">
{loading ? (
<p className="text-base text-muted"></p>
) : (
<>
<BrandProductPicker
brands={catalogBrands}
brandId={brandId}
productId={productId}
onBrandChange={setBrandId}
onProductChange={setProductId}
/>
<Field label="主題名稱">
<Input
value={topicName}
onChange={(e) => setTopicName(e.target.value)}
placeholder="例:敏感肌換季保養"
/>
</Field>
<Field label="種子關鍵字">
<Input
value={seedQuery}
onChange={(e) => setSeedQuery(e.target.value)}
placeholder="例:敏感肌、換季泛紅"
/>
</Field>
<Field label="主題目標">
<Textarea
rows={3}
value={brief}
onChange={(e) => setBrief(e.target.value)}
placeholder="想服務誰、什麼情境、希望找到什麼留言機會"
/>
</Field>
</>
)}
<div className="flex flex-wrap gap-2 border-t border-line/60 pt-4">
<Button variant="ghost" onClick={() => navigate('/placement/topics')} disabled={saving || generating}>
</Button>
{hasResearchMap(topic) ? (
<Button
variant="soft"
onClick={() => navigate(topicResearchMapPath(id))}
disabled={loading || generating}
>
</Button>
) : (
<Button
variant="soft"
onClick={() => void generateMap()}
disabled={loading || saving || generating}
>
{generating ? '產生中…' : '產生研究地圖'}
</Button>
)}
<Button onClick={() => void save()} disabled={loading || saving || generating}>
{saving ? '儲存中…' : '儲存設定'}
</Button>
</div>
</Card>
<SuccessText message={message} />
<ErrorText message={error} />
</div>
)
}