222 lines
7.2 KiB
TypeScript
222 lines
7.2 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 { 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 },
|
||
|
|
)
|
||
|
|
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>
|
||
|
|
)
|
||
|
|
}
|