"use client"; import { startTransition, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { downloadCompetitorAnalysisWorkbook, downloadCompetitorWorkbook, downloadKeywordAnalysisWorkbook, downloadKeywordInsightGroupsWorkbook, downloadKeywordInsightSuggestionsWorkbook, downloadMarkdownFile, downloadSalesWorkbook, } from "@/lib/product-planning/export"; import { PRODUCT_PLANNING_ADVANCED_DISABLED, PRODUCT_PLANNING_ADVANCED_DISABLED_MESSAGE, } from "@/lib/product-planning/advanced-disabled"; import { parseKeywordInputFile, parseManualSegmentationFile, parseQaImportFile, parseReviewImportFile, } from "@/lib/product-planning/file-parsers"; import { analyzeKeywordInputs } from "@/lib/product-planning/keyword-segmentation"; import type { KeywordAnalysisRow, ProductPlanningAnalyzeResponse, ProductPlanningCompetitorAnalysisRecord, ProductPlanningKeywordInsightGroup, ProductPlanningKeywordInsightResponse, ProductPlanningKeywordInsightSuggestion, ProductPlanningMarketplaceItemRef, ProductPlanningMarketplaceItemsResponse, ProductPlanningPlatform, ProductPlanningPropertyRow, ProductPlanningRowsResponse, ProductPlanningTaobaoItem, ProductPlanningTemplate, ProductPlanningTemplateListResponse, ProductPlanningTemplateMutationResponse, TaobaoQaRow, TaobaoReviewRow, } from "@/lib/product-planning/types"; import { extractTaobaoItemId, parseMarketplaceItemReference, uniqueBy, } from "@/lib/product-planning/utils"; type FetchProgressState = { running: boolean; total: number; completed: number; failed: number; currentLabel: string; }; type MarketplaceItemRef = ProductPlanningMarketplaceItemRef; type CompetitorImageAnalyzeParams = { item: ProductPlanningTaobaoItem; imageUrl: string; imageLabel: string; imageKey: string; }; type ProductPlanningSessionState = { version: 1; mainTab: string; manualSegmentationMode: boolean; categoryWords: string; keywordRows: KeywordAnalysisRow[]; productInput: string; minPage: string; maxPage: string; reviewRows: TaobaoReviewRow[]; qaRows: TaobaoQaRow[]; resultTab: string; reviewMarkdown: string | null; qaMarkdown: string | null; advancedTab: string; competitorInput: string; competitorItems: ProductPlanningTaobaoItem[]; competitorAnalysisRecords: Record< string, ProductPlanningCompetitorAnalysisRecord >; activeCompetitorAnalysisKey: string | null; insightKeyword: string; insightSuggestions: ProductPlanningKeywordInsightSuggestion[]; insightGroups: ProductPlanningKeywordInsightGroup[]; salesInput: string; salesItems: ProductPlanningTaobaoItem[]; templateSearch: string; savedSnapshots: ProductPlanningSavedSnapshot[]; activeSnapshotId: string | null; snapshotSearch: string; snapshotKindFilter: SnapshotKindFilter; }; type ProductPlanningSavedSnapshot = { id: string; name: string; kind: "review" | "qa" | "competitor"; title: string; markdown: string; createdAt: string; }; type SnapshotKindFilter = "all" | ProductPlanningSavedSnapshot["kind"]; const PRODUCT_PLANNING_SESSION_KEY = "product-planning-session-v1"; function parseCategoryWords(value: string) { return value .split(/[\n,,;;\s]+/) .map((item) => item.trim()) .filter(Boolean); } function ErrorText({ message }: { message: string | null }) { if (!message) return null; return

{message}

; } function MarkdownPanel({ title, markdown, actions, }: { title: string; markdown: string | null; actions?: import("react").ReactNode; }) { if (!markdown) return null; return (
{title} {actions ? (
{actions}
) : null}
{markdown}
); } function KeywordResultsTable({ rows }: { rows: KeywordAnalysisRow[] }) { if (rows.length === 0) return null; return (
{rows.map((row) => ( ))}
词语 拆分片段 标签分类 出现次数 搜索人气 支付转化率
{row.keyword} {row.segments.join(" / ")} {row.category} {row.occurrenceCount} {typeof row.searchPopularity === "number" ? row.searchPopularity : "-"} {typeof row.conversionRate === "number" ? `${(row.conversionRate * 100).toFixed(2)}%` : "-"}
); } function ReviewTable({ rows }: { rows: TaobaoReviewRow[] }) { if (rows.length === 0) return (
还没有评价数据。
); return (
{rows.map((row, index) => ( ))}
商品ID 评论时间 评价内容 追评 SKU 买家ID
{row.itemId || "-"} {row.date || "-"} {row.content || "-"} {row.appendComment || "-"} {row.sku || "-"} {row.userId || "-"}
); } function QaTable({ rows }: { rows: TaobaoQaRow[] }) { if (rows.length === 0) return (
还没有问大家数据。
); return (
{rows.map((row, index) => ( ))}
商品ID 问题 问题补充 回答 回答用户 回答时间 官方
{row.itemId || "-"} {row.question || "-"} {row.questionDetail || "-"} {row.answer || "-"} {row.answerUser || "-"} {row.answerDate || "-"} {row.isOfficial ? "是" : "否"}
); } function formatInteger(value: number | null | undefined) { if (typeof value !== "number" || Number.isNaN(value)) return "-"; return value.toLocaleString("zh-CN"); } function formatPrice(value: number | null | undefined) { if (typeof value !== "number" || Number.isNaN(value)) return "-"; return `¥${value.toFixed(2)}`; } function formatPlatform(platform: ProductPlanningPlatform, isTmall = false) { if (platform === "jd") return "京东"; return isTmall ? "天猫" : "淘宝"; } function formatDataSource(value: ProductPlanningTaobaoItem["dataSource"]) { if (value === "detail-plus") return "详情增强"; if (value === "legacy") return "旧接口兜底"; if (value === "jd") return "京东接口"; return "未标记"; } function formatPropertyPreview( properties: ProductPlanningPropertyRow[], limit = 8 ) { if (properties.length === 0) return "暂无商品参数"; return properties .slice(0, limit) .map((entry) => `${entry.name}:${entry.value}`) .join(" | "); } function createSnapshotId() { if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { return crypto.randomUUID(); } return `snapshot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } function formatDateTime(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return "-"; return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } function formatFileSize(value: number) { if (!Number.isFinite(value) || value <= 0) return "-"; if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; return `${(value / (1024 * 1024)).toFixed(1)} MB`; } function downloadBlobFile(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = fileName; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); } function CompetitorTable({ rows, analyzingItemKey, analyzingSkuKey, onAnalyzeImage, onAnalyzeSku, onViewAnalysis, analysisRecords, activeAnalysisKey, }: { rows: ProductPlanningTaobaoItem[]; analyzingItemKey: string | null; analyzingSkuKey: string | null; onAnalyzeImage: (params: CompetitorImageAnalyzeParams) => void; onAnalyzeSku: (item: ProductPlanningTaobaoItem) => void; onViewAnalysis: (key: string) => void; analysisRecords: Record; activeAnalysisKey: string | null; }) { if (rows.length === 0) { return (
还没有竞品数据。
); } return (
{rows.map((row) => { const mainImageKey = `${row.platform}:${row.itemId}:main`; const skuKey = `${row.platform}:${row.itemId}:sku`; return ( ); })}
数据来源 平台 商品ID 主图 标题 店铺 当前价格 划线价格 总销量 近期销量 类目ID 材质 标准品类 市场分类 SKU概览 图数 参数预览 操作
{formatDataSource(row.dataSource)} {formatPlatform(row.platform, row.isTmall)} {row.itemId} {row.mainImage ? (
{row.title}
) : ( "-" )}

{row.title}

{row.detailUrl ? ( 查看商品链接 ) : null}
{row.shopName || "-"} {formatPrice(row.price)} {formatPrice(row.originalPrice)} {formatInteger(row.totalSales)} {formatInteger(row.recentSales)} {row.categoryId || "-"} {row.material || "-"} {row.standardProductType || "-"} {row.marketCategoryName || "-"} {row.skus.length > 0 ? (
{row.skus.slice(0, 4).map((sku) => (
{sku.name}:{formatPrice(sku.price)}
))}
) : ( "-" )}
主图 {row.itemImages.length} / 详情 {row.detailImages.length} {formatPropertyPreview(row.properties, 10)}
); } function SalesTable({ rows }: { rows: ProductPlanningTaobaoItem[] }) { if (rows.length === 0) { return (
还没有销量数据。
); } return (
{rows.map((row) => ( ))}
平台 商品ID 标题 近期销量 当前价格 店铺 发货地 材质
{formatPlatform(row.platform, row.isTmall)} {row.itemId}

{row.title}

{row.detailUrl ? ( 查看商品链接 ) : null}
{formatInteger(row.recentSales)} {formatPrice(row.price)} {row.shopName || "-"} {row.location || "-"} {row.material || "-"}
); } function KeywordInsightSuggestionTable({ rows, }: { rows: ProductPlanningKeywordInsightSuggestion[]; }) { if (rows.length === 0) { return (
还没有搜索建议数据。
); } return (
{rows.map((row) => ( ))}
排名 关键词 权重
{row.rank} {row.keyword} {row.weight.toLocaleString("zh-CN")}
); } function KeywordInsightGroupTable({ rows, }: { rows: ProductPlanningKeywordInsightGroup[]; }) { if (rows.length === 0) { return (
还没有标签分组数据。
); } return (
{rows.map((row) => ( ))}
组序号 分组类型 标签
{row.index} {row.type}
{row.tags.map((tag) => ( {tag.title} ))}
); } export function ProductPlanningPage({ canManageTemplates = false, }: { canManageTemplates?: boolean; }) { const sessionHydratedRef = useRef(false); const [mainTab, setMainTab] = useState("keywords"); const [manualSegmentationMode, setManualSegmentationMode] = useState(false); const [categoryWords, setCategoryWords] = useState(""); const [keywordRows, setKeywordRows] = useState([]); const [keywordError, setKeywordError] = useState(null); const [keywordAnalyzing, setKeywordAnalyzing] = useState(false); const keywordFileRef = useRef(null); const manualFileRef = useRef(null); const [templates, setTemplates] = useState([]); const [templateError, setTemplateError] = useState(null); const [templatesLoading, setTemplatesLoading] = useState(false); const [templateSearch, setTemplateSearch] = useState(""); const [templateUsingId, setTemplateUsingId] = useState(null); const [templateDownloadingId, setTemplateDownloadingId] = useState< string | null >(null); const [templateUploading, setTemplateUploading] = useState(false); const [templateDeletingId, setTemplateDeletingId] = useState( null ); const [templateCategoryName, setTemplateCategoryName] = useState(""); const [templateDescription, setTemplateDescription] = useState(""); const templateUploadRef = useRef(null); const [productInput, setProductInput] = useState(""); const [minPage, setMinPage] = useState("1"); const [maxPage, setMaxPage] = useState("3"); const [reviewRows, setReviewRows] = useState([]); const [qaRows, setQaRows] = useState([]); const [reviewError, setReviewError] = useState(null); const [qaError, setQaError] = useState(null); const [resultTab, setResultTab] = useState("qa"); const [reviewMarkdown, setReviewMarkdown] = useState(null); const [qaMarkdown, setQaMarkdown] = useState(null); const [reviewAnalyzing, setReviewAnalyzing] = useState(false); const [qaAnalyzing, setQaAnalyzing] = useState(false); const [fetchProgress, setFetchProgress] = useState({ running: false, total: 0, completed: 0, failed: 0, currentLabel: "", }); const cancelFetchRef = useRef(false); const reviewImportRef = useRef(null); const qaImportRef = useRef(null); const [advancedTab, setAdvancedTab] = useState("competitor"); const [competitorInput, setCompetitorInput] = useState(""); const [competitorItems, setCompetitorItems] = useState< ProductPlanningTaobaoItem[] >([]); const [competitorError, setCompetitorError] = useState(null); const [competitorLoading, setCompetitorLoading] = useState(false); const [competitorImageError, setCompetitorImageError] = useState< string | null >(null); const [competitorImageLoadingId, setCompetitorImageLoadingId] = useState< string | null >(null); const [competitorSkuError, setCompetitorSkuError] = useState( null ); const [competitorSkuLoadingKey, setCompetitorSkuLoadingKey] = useState< string | null >(null); const [competitorAnalysisRecords, setCompetitorAnalysisRecords] = useState< Record >({}); const [activeCompetitorAnalysisKey, setActiveCompetitorAnalysisKey] = useState(null); const [insightKeyword, setInsightKeyword] = useState(""); const [insightSuggestions, setInsightSuggestions] = useState< ProductPlanningKeywordInsightSuggestion[] >([]); const [insightGroups, setInsightGroups] = useState< ProductPlanningKeywordInsightGroup[] >([]); const [insightError, setInsightError] = useState(null); const [insightLoading, setInsightLoading] = useState(false); const [salesInput, setSalesInput] = useState(""); const [salesItems, setSalesItems] = useState([]); const [salesError, setSalesError] = useState(null); const [salesLoading, setSalesLoading] = useState(false); const [savedSnapshots, setSavedSnapshots] = useState< ProductPlanningSavedSnapshot[] >([]); const [activeSnapshotId, setActiveSnapshotId] = useState(null); const [snapshotSearch, setSnapshotSearch] = useState(""); const [snapshotKindFilter, setSnapshotKindFilter] = useState("all"); async function handleKeywordAnalyze() { const keywordFile = keywordFileRef.current?.files?.[0]; const manualFile = manualFileRef.current?.files?.[0]; setKeywordError(null); setKeywordAnalyzing(true); try { if (manualSegmentationMode) { if (!manualFile) { throw new Error("请先上传手动分词文件"); } const rows = await parseManualSegmentationFile(manualFile); startTransition(() => setKeywordRows(rows)); toast.success(`已导入 ${rows.length} 条手动分词结果`); return; } if (!keywordFile) { throw new Error("请先上传关键词文件"); } const inputs = await parseKeywordInputFile(keywordFile); const rows = analyzeKeywordInputs(inputs, { categoryWords: parseCategoryWords(categoryWords), }); startTransition(() => setKeywordRows(rows)); toast.success(`已完成 ${inputs.length} 条关键词分析`); } catch (error) { const message = error instanceof Error ? error.message : "关键词分析失败"; setKeywordError(message); toast.error(message); } finally { setKeywordAnalyzing(false); } } async function handleKeywordExport() { if (keywordRows.length === 0) { toast.error("当前没有可导出的关键词结果"); return; } try { await downloadKeywordAnalysisWorkbook(keywordRows); toast.success("关键词分析结果已导出"); } catch (error) { console.error("[ProductPlanning] export failed:", error); toast.error("导出关键词分析结果失败"); } } async function loadTemplates(showToast = false) { setTemplatesLoading(true); try { const response = await fetch("/api/product-planning/templates", { method: "GET", cache: "no-store", }); const payload = (await response.json()) as ProductPlanningTemplateListResponse; if (!response.ok || !payload.success) { throw new Error(payload.error || "模板库加载失败"); } setTemplateError(null); setTemplates(payload.templates); if (showToast) { toast.success("模板库已刷新"); } } catch (error) { const message = error instanceof Error ? error.message : "模板库加载失败"; setTemplateError(message); if (showToast) { toast.error(message); } } finally { setTemplatesLoading(false); } } useEffect(() => { void loadTemplates(); }, []); useEffect(() => { if (PRODUCT_PLANNING_ADVANCED_DISABLED && mainTab === "advanced") { setMainTab("keywords"); toast.info(PRODUCT_PLANNING_ADVANCED_DISABLED_MESSAGE); } }, [mainTab]); useEffect(() => { try { const raw = sessionStorage.getItem(PRODUCT_PLANNING_SESSION_KEY); if (!raw) { sessionHydratedRef.current = true; return; } const parsed = JSON.parse( raw ) as Partial | null; if (!parsed || parsed.version !== 1) { sessionHydratedRef.current = true; return; } if (typeof parsed.mainTab === "string") { setMainTab( PRODUCT_PLANNING_ADVANCED_DISABLED && parsed.mainTab === "advanced" ? "keywords" : parsed.mainTab ); } if (typeof parsed.manualSegmentationMode === "boolean") { setManualSegmentationMode(parsed.manualSegmentationMode); } if (typeof parsed.categoryWords === "string") setCategoryWords(parsed.categoryWords); if (Array.isArray(parsed.keywordRows)) setKeywordRows(parsed.keywordRows as KeywordAnalysisRow[]); if (typeof parsed.productInput === "string") setProductInput(parsed.productInput); if (typeof parsed.minPage === "string") setMinPage(parsed.minPage); if (typeof parsed.maxPage === "string") setMaxPage(parsed.maxPage); if (Array.isArray(parsed.reviewRows)) setReviewRows(parsed.reviewRows as TaobaoReviewRow[]); if (Array.isArray(parsed.qaRows)) setQaRows(parsed.qaRows as TaobaoQaRow[]); if (typeof parsed.resultTab === "string") setResultTab(parsed.resultTab); if ( typeof parsed.reviewMarkdown === "string" || parsed.reviewMarkdown === null ) { setReviewMarkdown(parsed.reviewMarkdown ?? null); } if (typeof parsed.qaMarkdown === "string" || parsed.qaMarkdown === null) { setQaMarkdown(parsed.qaMarkdown ?? null); } if (typeof parsed.advancedTab === "string") setAdvancedTab(parsed.advancedTab); if (typeof parsed.competitorInput === "string") setCompetitorInput(parsed.competitorInput); if (Array.isArray(parsed.competitorItems)) { setCompetitorItems( parsed.competitorItems as ProductPlanningTaobaoItem[] ); } if ( parsed.competitorAnalysisRecords && typeof parsed.competitorAnalysisRecords === "object" && !Array.isArray(parsed.competitorAnalysisRecords) ) { setCompetitorAnalysisRecords( parsed.competitorAnalysisRecords as Record< string, ProductPlanningCompetitorAnalysisRecord > ); } if ( typeof parsed.activeCompetitorAnalysisKey === "string" || parsed.activeCompetitorAnalysisKey === null ) { setActiveCompetitorAnalysisKey( parsed.activeCompetitorAnalysisKey ?? null ); } if (typeof parsed.insightKeyword === "string") setInsightKeyword(parsed.insightKeyword); if (Array.isArray(parsed.insightSuggestions)) { setInsightSuggestions( parsed.insightSuggestions as ProductPlanningKeywordInsightSuggestion[] ); } if (Array.isArray(parsed.insightGroups)) { setInsightGroups( parsed.insightGroups as ProductPlanningKeywordInsightGroup[] ); } if (typeof parsed.salesInput === "string") setSalesInput(parsed.salesInput); if (Array.isArray(parsed.salesItems)) { setSalesItems(parsed.salesItems as ProductPlanningTaobaoItem[]); } if (typeof parsed.templateSearch === "string") setTemplateSearch(parsed.templateSearch); if (Array.isArray(parsed.savedSnapshots)) { setSavedSnapshots( parsed.savedSnapshots as ProductPlanningSavedSnapshot[] ); } if ( typeof parsed.activeSnapshotId === "string" || parsed.activeSnapshotId === null ) { setActiveSnapshotId(parsed.activeSnapshotId ?? null); } if (typeof parsed.snapshotSearch === "string") setSnapshotSearch(parsed.snapshotSearch); if ( parsed.snapshotKindFilter === "all" || parsed.snapshotKindFilter === "review" || parsed.snapshotKindFilter === "qa" || parsed.snapshotKindFilter === "competitor" ) { setSnapshotKindFilter(parsed.snapshotKindFilter); } } catch (error) { console.warn("[ProductPlanning] restore session failed:", error); } finally { sessionHydratedRef.current = true; } }, []); async function fetchTemplateBlob(template: ProductPlanningTemplate) { const response = await fetch( `/api/product-planning/templates/${encodeURIComponent(template.id)}`, { method: "GET", } ); if (!response.ok) { const payload = (await response .json() .catch(() => null)) as ProductPlanningTemplateMutationResponse | null; throw new Error(payload?.error || "模板下载失败"); } return await response.blob(); } async function handleTemplateUse(template: ProductPlanningTemplate) { setTemplateUsingId(template.id); setKeywordError(null); try { const blob = await fetchTemplateBlob(template); const file = new File([blob], template.fileName, { type: template.contentType || blob.type || "application/octet-stream", }); const inputs = await parseKeywordInputFile(file); const nextCategoryWords = template.categoryName; const rows = analyzeKeywordInputs(inputs, { categoryWords: parseCategoryWords(nextCategoryWords), }); startTransition(() => { setManualSegmentationMode(false); setCategoryWords(nextCategoryWords); setKeywordRows(rows); }); toast.success(`已加载模板“${template.categoryName}”并完成拆词`); } catch (error) { const message = error instanceof Error ? error.message : "模板加载失败"; setKeywordError(message); toast.error(message); } finally { setTemplateUsingId(null); } } async function handleTemplateDownload(template: ProductPlanningTemplate) { setTemplateDownloadingId(template.id); try { const blob = await fetchTemplateBlob(template); downloadBlobFile(blob, template.fileName); toast.success(`模板“${template.categoryName}”已开始下载`); } catch (error) { const message = error instanceof Error ? error.message : "模板下载失败"; setTemplateError(message); toast.error(message); } finally { setTemplateDownloadingId(null); } } async function handleTemplateUpload() { const file = templateUploadRef.current?.files?.[0]; if (!file) { toast.error("请先选择要上传的模板文件"); return; } const categoryName = templateCategoryName.trim(); if (!categoryName) { toast.error("请先填写模板名称"); return; } setTemplateUploading(true); try { const formData = new FormData(); formData.set("categoryName", categoryName); formData.set("description", templateDescription.trim()); formData.set("file", file); const response = await fetch("/api/product-planning/templates", { method: "POST", body: formData, }); const payload = (await response.json()) as ProductPlanningTemplateMutationResponse; if (!response.ok || !payload.success || !payload.template) { throw new Error(payload.error || "模板上传失败"); } setTemplateCategoryName(""); setTemplateDescription(""); if (templateUploadRef.current) { templateUploadRef.current.value = ""; } setTemplateError(null); await loadTemplates(); toast.success(`模板“${payload.template.categoryName}”上传成功`); } catch (error) { const message = error instanceof Error ? error.message : "模板上传失败"; setTemplateError(message); toast.error(message); } finally { setTemplateUploading(false); } } async function handleTemplateDelete(template: ProductPlanningTemplate) { if (!window.confirm(`确认删除模板“${template.categoryName}”?`)) { return; } setTemplateDeletingId(template.id); try { const response = await fetch( `/api/product-planning/templates/${encodeURIComponent(template.id)}`, { method: "DELETE", } ); const payload = (await response.json()) as ProductPlanningTemplateMutationResponse; if (!response.ok || !payload.success) { throw new Error(payload.error || "模板删除失败"); } if (templateUsingId === template.id) { setTemplateUsingId(null); } setTemplateError(null); await loadTemplates(); toast.success(`模板“${template.categoryName}”已删除`); } catch (error) { const message = error instanceof Error ? error.message : "模板删除失败"; setTemplateError(message); toast.error(message); } finally { setTemplateDeletingId(null); } } function cancelBatchFetch() { cancelFetchRef.current = true; setFetchProgress((current) => ({ ...current, running: false, currentLabel: "已取消抓取", })); } async function fetchRowsForItem(itemId: string, page: number) { const [reviewResponse, qaResponse] = await Promise.all([ fetch( `/api/product-planning/taobao/reviews?id=${encodeURIComponent(itemId)}&page=${page}`, { method: "GET", } ), fetch("/api/product-planning/taobao/qa", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ itemId, page }), }), ]); const reviewPayload = (await reviewResponse.json()) as ProductPlanningRowsResponse; const qaPayload = (await qaResponse.json()) as ProductPlanningRowsResponse; return { reviewOk: reviewResponse.ok && reviewPayload.success, qaOk: qaResponse.ok && qaPayload.success, reviewPayload, qaPayload, }; } async function handleBatchFetch() { const productIds = productInput .split(/\r?\n/) .map((line) => extractTaobaoItemId(line)) .filter((item): item is string => Boolean(item)); if (productIds.length === 0) { const message = "请输入淘宝链接或商品 ID"; setReviewError(message); setQaError(message); toast.error(message); return; } if (productIds.length > 20) { const message = "最多支持 20 个商品同时抓取"; setReviewError(message); setQaError(message); toast.error(message); return; } const min = Math.max(Number.parseInt(minPage, 10) || 1, 1); const max = Math.min(Number.parseInt(maxPage, 10) || 1, 20); if (min > max) { const message = "最小页数不能大于最大页数"; setReviewError(message); setQaError(message); toast.error(message); return; } cancelFetchRef.current = false; setReviewError(null); setQaError(null); setReviewMarkdown(null); setQaMarkdown(null); const totalSteps = productIds.length * (max - min + 1); const nextReviewRows: TaobaoReviewRow[] = []; const nextQaRows: TaobaoQaRow[] = []; let failures = 0; let completed = 0; setFetchProgress({ running: true, total: totalSteps, completed: 0, failed: 0, currentLabel: "开始抓取数据...", }); try { for (const itemId of productIds) { for (let page = min; page <= max; page += 1) { if (cancelFetchRef.current) { toast("已取消批量抓取"); break; } setFetchProgress((current) => ({ ...current, currentLabel: `正在抓取商品 ${itemId} 第 ${page} 页`, })); try { const result = await fetchRowsForItem(itemId, page); if (result.reviewOk) { nextReviewRows.push(...result.reviewPayload.rows); } else { failures += 1; setReviewError(result.reviewPayload.error || "评价接口返回失败"); } if (result.qaOk) { nextQaRows.push(...result.qaPayload.rows); } else { failures += 1; setQaError(result.qaPayload.error || "问大家接口返回失败"); } } catch (error) { failures += 2; console.error("[ProductPlanning] batch fetch failed:", error); setReviewError("部分评价数据抓取失败"); setQaError("部分问大家数据抓取失败"); } finally { completed += 1; setFetchProgress((current) => ({ ...current, completed, failed: failures, })); } } if (cancelFetchRef.current) break; } if (!cancelFetchRef.current) { startTransition(() => { setReviewRows( uniqueBy(nextReviewRows, (row) => [ row.itemId, row.date, row.content, row.appendComment || "", row.userId || "", ].join("::") ) ); setQaRows( uniqueBy(nextQaRows, (row) => [ row.itemId, row.question, row.questionDetail || "", row.answer, row.answerUser || "", row.answerDate || "", row.isOfficial ? "1" : "0", ].join("::") ) ); }); toast.success("批量抓取完成"); } } finally { setFetchProgress((current) => ({ ...current, running: false, currentLabel: cancelFetchRef.current ? "已取消抓取" : "抓取完成", })); } } async function handleReviewImport(file: File) { try { const rows = await parseReviewImportFile(file); setReviewError(null); setReviewMarkdown(null); startTransition(() => setReviewRows(rows)); setResultTab("review"); toast.success(`已导入 ${rows.length} 条评价数据`); } catch (error) { const message = error instanceof Error ? error.message : "导入评价表失败"; setReviewError(message); toast.error(message); } } async function handleQaImport(file: File) { try { const rows = await parseQaImportFile(file); setQaError(null); setQaMarkdown(null); startTransition(() => setQaRows(rows)); setResultTab("qa"); toast.success(`已导入 ${rows.length} 条问大家数据`); } catch (error) { const message = error instanceof Error ? error.message : "导入问大家表失败"; setQaError(message); toast.error(message); } } async function handleAnalyze(kind: "review" | "qa") { const rows = kind === "review" ? reviewRows : qaRows; if (rows.length === 0) { toast.error( kind === "review" ? "当前没有可分析的评价数据" : "当前没有可分析的问大家数据" ); return; } if (kind === "review") { setReviewAnalyzing(true); setReviewMarkdown(null); } else { setQaAnalyzing(true); setQaMarkdown(null); } try { const response = await fetch("/api/product-planning/analyze", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ kind, rows }), }); const payload = (await response.json()) as ProductPlanningAnalyzeResponse; if (!response.ok || !payload.success || !payload.markdown) { throw new Error(payload.error || "分析失败"); } if (kind === "review") { setReviewMarkdown(payload.markdown); } else { setQaMarkdown(payload.markdown); } toast.success(kind === "review" ? "评价分析完成" : "问大家分析完成"); } catch (error) { const message = error instanceof Error ? error.message : "AI 分析失败"; if (kind === "review") { setReviewError(message); } else { setQaError(message); } toast.error(message); } finally { if (kind === "review") { setReviewAnalyzing(false); } else { setQaAnalyzing(false); } } } async function fetchMarketplaceItemsBatch( references: MarketplaceItemRef[], options: { includeMonthSale?: boolean } = {} ) { const response = await fetch("/api/product-planning/marketplace/items", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ items: references, includeMonthSale: options.includeMonthSale !== false, }), }); const payload = (await response.json()) as ProductPlanningMarketplaceItemsResponse; if (!response.ok || !payload.items) { throw new Error(payload.error || "批量获取商品详情失败"); } const rows = payload.items .filter((result) => result.success && result.item) .map((result) => result.item as ProductPlanningTaobaoItem); const errors = payload.items .filter((result) => !result.success) .map((result) => `${result.platform}:${result.itemId} ${result.error || "查询失败"}`); if (rows.length === 0) { throw new Error(errors[0] || payload.error || "批量查询未获取到可用商品"); } return { rows, errors }; } function parseMarketplaceItemLines(value: string) { return value .split(/\r?\n/) .map((line) => parseMarketplaceItemReference(line)) .filter((item): item is MarketplaceItemRef => Boolean(item)); } async function handleCompetitorFetch() { const itemRefs = parseMarketplaceItemLines(competitorInput); if (itemRefs.length === 0) { const message = "请输入至少一个淘宝/京东链接,或使用 jd:商品ID 的形式输入京东商品"; setCompetitorError(message); toast.error(message); return; } setCompetitorLoading(true); setCompetitorError(null); setCompetitorImageError(null); setCompetitorSkuError(null); setCompetitorAnalysisRecords({}); setActiveCompetitorAnalysisKey(null); try { const references = uniqueBy( itemRefs, (item) => `${item.platform}:${item.itemId}` ); const { rows, errors } = await fetchMarketplaceItemsBatch(references, { includeMonthSale: true, }); startTransition(() => setCompetitorItems(rows)); if (errors.length > 0) { const message = `成功 ${rows.length} 个,失败 ${errors.length} 个:${errors.slice(0, 3).join(";")}`; setCompetitorError(message); toast.warning(message); } else { toast.success(`已获取 ${rows.length} 个竞品商品数据`); } } catch (error) { const message = error instanceof Error ? error.message : "获取竞品数据失败"; setCompetitorError(message); toast.error(message); } finally { setCompetitorLoading(false); } } async function handleCompetitorExport() { if (competitorItems.length === 0) { toast.error("当前没有可导出的竞品数据"); return; } try { await downloadCompetitorWorkbook(competitorItems); toast.success("竞品分析结果已导出"); } catch (error) { console.error("[ProductPlanning] competitor export failed:", error); toast.error("导出竞品分析结果失败"); } } async function handleCompetitorImageAnalyze( params: CompetitorImageAnalyzeParams ) { const { item, imageUrl, imageLabel, imageKey } = params; if (!imageUrl) { toast.error("当前图片不存在,无法分析"); return; } setCompetitorImageLoadingId(imageKey); setCompetitorImageError(null); try { const response = await fetch("/api/product-planning/analyze-image", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ itemId: item.itemId, imageUrl, title: item.title, }), }); const payload = (await response.json()) as ProductPlanningAnalyzeResponse; if (!response.ok || !payload.success || !payload.markdown) { throw new Error(payload.error || "竞品主图分析失败"); } const markdown = payload.markdown; const title = `${item.title || item.itemId} - ${imageLabel}`; setCompetitorAnalysisRecords((current) => ({ ...current, [imageKey]: { title, markdown, kind: "image", updatedAt: new Date().toISOString(), }, })); setActiveCompetitorAnalysisKey(imageKey); toast.success(`${imageLabel}分析完成`); } catch (error) { const message = error instanceof Error ? error.message : "竞品图片分析失败"; setCompetitorImageError(message); toast.error(message); } finally { setCompetitorImageLoadingId(null); } } async function handleCompetitorSkuAnalyze(item: ProductPlanningTaobaoItem) { if (item.skus.length === 0) { toast.error("当前商品没有可分析的 SKU 数据"); return; } const skuKey = `${item.platform}:${item.itemId}:sku`; setCompetitorSkuLoadingKey(skuKey); setCompetitorSkuError(null); try { const response = await fetch("/api/product-planning/analyze-sku", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ itemId: item.itemId, title: item.title, platform: item.platform, skus: item.skus, }), }); const payload = (await response.json()) as ProductPlanningAnalyzeResponse; if (!response.ok || !payload.success || !payload.markdown) { throw new Error(payload.error || "SKU 卖点分析失败"); } const markdown = payload.markdown; setCompetitorAnalysisRecords((current) => ({ ...current, [skuKey]: { title: `${item.title || item.itemId} - SKU 卖点分析`, markdown, kind: "sku", updatedAt: new Date().toISOString(), }, })); setActiveCompetitorAnalysisKey(skuKey); toast.success("SKU 卖点分析完成"); } catch (error) { const message = error instanceof Error ? error.message : "SKU 卖点分析失败"; setCompetitorSkuError(message); toast.error(message); } finally { setCompetitorSkuLoadingKey(null); } } async function handleKeywordInsightSearch() { const keyword = insightKeyword.trim(); if (!keyword) { const message = "请输入要分析的关键词"; setInsightError(message); toast.error(message); return; } setInsightLoading(true); setInsightError(null); try { const response = await fetch( `/api/product-planning/keyword-insight?q=${encodeURIComponent(keyword)}` ); const payload = (await response.json()) as ProductPlanningKeywordInsightResponse; if (!response.ok || !payload.success) { throw new Error(payload.error || "关键词洞察分析失败"); } setInsightSuggestions(payload.suggestions); setInsightGroups(payload.groups); toast.success(`已获取 ${payload.suggestions.length} 条搜索建议`); } catch (error) { const message = error instanceof Error ? error.message : "关键词洞察分析失败"; setInsightError(message); toast.error(message); } finally { setInsightLoading(false); } } async function handleInsightSuggestionsExport() { if (insightSuggestions.length === 0) { toast.error("当前没有可导出的搜索建议"); return; } try { await downloadKeywordInsightSuggestionsWorkbook(insightSuggestions); toast.success("搜索建议已导出"); } catch (error) { console.error( "[ProductPlanning] insight suggestions export failed:", error ); toast.error("导出搜索建议失败"); } } async function handleInsightGroupsExport() { if (insightGroups.length === 0) { toast.error("当前没有可导出的标签分组"); return; } try { await downloadKeywordInsightGroupsWorkbook(insightGroups); toast.success("标签分组已导出"); } catch (error) { console.error("[ProductPlanning] insight groups export failed:", error); toast.error("导出标签分组失败"); } } async function handleSalesFetch() { const itemRefs = parseMarketplaceItemLines(salesInput); if (itemRefs.length === 0) { const message = "请输入至少一个淘宝/京东链接,或使用 jd:商品ID 的形式输入京东商品"; setSalesError(message); toast.error(message); return; } setSalesLoading(true); setSalesError(null); try { const references = uniqueBy( itemRefs, (item) => `${item.platform}:${item.itemId}` ); const { rows, errors } = await fetchMarketplaceItemsBatch(references, { includeMonthSale: true, }); startTransition(() => setSalesItems(rows)); if (errors.length > 0) { const message = `成功 ${rows.length} 个,失败 ${errors.length} 个:${errors.slice(0, 3).join(";")}`; setSalesError(message); toast.warning(message); } else { toast.success(`已获取 ${rows.length} 个商品的销量数据`); } } catch (error) { const message = error instanceof Error ? error.message : "获取销量数据失败"; setSalesError(message); toast.error(message); } finally { setSalesLoading(false); } } async function handleSalesExport() { if (salesItems.length === 0) { toast.error("当前没有可导出的销量数据"); return; } try { await downloadSalesWorkbook(salesItems); toast.success("销量结果已导出"); } catch (error) { console.error("[ProductPlanning] sales export failed:", error); toast.error("导出销量结果失败"); } } function handleMarkdownExport( markdown: string | null, fileName: string, errorMessage: string ) { if (!markdown) { toast.error(errorMessage); return; } try { downloadMarkdownFile(markdown, fileName); toast.success("分析结果已导出"); } catch (error) { console.error("[ProductPlanning] markdown export failed:", error); toast.error("导出分析结果失败"); } } async function handleCompetitorAnalysisWorkbookExport() { if (Object.keys(competitorAnalysisRecords).length === 0) { toast.error("当前没有可导出的竞品分析结果"); return; } try { await downloadCompetitorAnalysisWorkbook(competitorAnalysisRecords); toast.success("竞品分析结果已导出"); } catch (error) { console.error( "[ProductPlanning] competitor analysis export failed:", error ); toast.error("导出竞品分析结果失败"); } } function handleViewCompetitorAnalysis(key: string) { if (!competitorAnalysisRecords[key]) { toast.error("该分析结果还没有生成"); return; } setActiveCompetitorAnalysisKey(key); } function handleSaveSnapshot(params: { kind: ProductPlanningSavedSnapshot["kind"]; defaultName: string; title: string; markdown: string | null; }) { if (!params.markdown) { toast.error("当前没有可保存的分析结果"); return; } const inputName = window .prompt("请输入快照名称", params.defaultName) ?.trim(); if (!inputName) { return; } const snapshot: ProductPlanningSavedSnapshot = { id: createSnapshotId(), name: inputName, kind: params.kind, title: params.title, markdown: params.markdown, createdAt: new Date().toISOString(), }; setSavedSnapshots((current) => [snapshot, ...current].slice(0, 30)); setActiveSnapshotId(snapshot.id); toast.success("分析快照已保存"); } function handleDeleteSnapshot(snapshotId: string) { setSavedSnapshots((current) => current.filter((snapshot) => snapshot.id !== snapshotId) ); setActiveSnapshotId((current) => (current === snapshotId ? null : current)); toast.success("快照已删除"); } function handleRenameSnapshot(snapshotId: string) { const target = savedSnapshots.find( (snapshot) => snapshot.id === snapshotId ); if (!target) { toast.error("未找到该快照"); return; } const nextName = window.prompt("请输入新的快照名称", target.name)?.trim(); if (!nextName || nextName === target.name) { return; } setSavedSnapshots((current) => current.map((snapshot) => snapshot.id === snapshotId ? { ...snapshot, name: nextName, } : snapshot ) ); toast.success("快照名称已更新"); } function handleViewSnapshot(snapshotId: string) { if (!savedSnapshots.some((snapshot) => snapshot.id === snapshotId)) { toast.error("未找到该快照"); return; } setActiveSnapshotId(snapshotId); } function handleClearSession() { if (!window.confirm("确认清空当前商品策划会话内容并刷新页面?")) { return; } sessionStorage.removeItem(PRODUCT_PLANNING_SESSION_KEY); window.location.reload(); } const progressValue = fetchProgress.total > 0 ? Math.round((fetchProgress.completed / fetchProgress.total) * 100) : 0; const normalizedTemplateSearch = templateSearch.trim().toLowerCase(); const filteredTemplates = templates.filter((template) => { if (!normalizedTemplateSearch) return true; return [ template.categoryName, template.description || "", template.fileName, ].some((value) => value.toLowerCase().includes(normalizedTemplateSearch)); }); const activeCompetitorAnalysis = activeCompetitorAnalysisKey ? competitorAnalysisRecords[activeCompetitorAnalysisKey] || null : null; const activeSnapshot = activeSnapshotId ? savedSnapshots.find((snapshot) => snapshot.id === activeSnapshotId) || null : null; const normalizedSnapshotSearch = snapshotSearch.trim().toLowerCase(); const filteredSnapshots = savedSnapshots.filter((snapshot) => { if (snapshotKindFilter !== "all" && snapshot.kind !== snapshotKindFilter) { return false; } if (!normalizedSnapshotSearch) return true; return [snapshot.name, snapshot.title, snapshot.markdown].some((value) => value.toLowerCase().includes(normalizedSnapshotSearch) ); }); useEffect(() => { if (!sessionHydratedRef.current) return; const payload: ProductPlanningSessionState = { version: 1, mainTab, manualSegmentationMode, categoryWords, keywordRows, productInput, minPage, maxPage, reviewRows, qaRows, resultTab, reviewMarkdown, qaMarkdown, advancedTab, competitorInput, competitorItems, competitorAnalysisRecords, activeCompetitorAnalysisKey, insightKeyword, insightSuggestions, insightGroups, salesInput, salesItems, templateSearch, savedSnapshots, activeSnapshotId, snapshotSearch, snapshotKindFilter, }; try { sessionStorage.setItem( PRODUCT_PLANNING_SESSION_KEY, JSON.stringify(payload) ); } catch (error) { console.warn("[ProductPlanning] persist session failed:", error); } }, [ activeCompetitorAnalysisKey, advancedTab, categoryWords, competitorAnalysisRecords, competitorInput, competitorItems, insightGroups, insightKeyword, insightSuggestions, keywordRows, mainTab, manualSegmentationMode, maxPage, minPage, productInput, qaMarkdown, qaRows, resultTab, reviewMarkdown, reviewRows, savedSnapshots, salesInput, salesItems, snapshotKindFilter, snapshotSearch, templateSearch, activeSnapshotId, ]); return (
第一阶段 商品策划工具

商品策划工具

在当前项目内完成关键词拆词、评价/问大家抓取、本地表格导入和 AI 分析总结。

分析快照 将当前分析结果保存为会话内命名快照,便于回看、导出和对比。
已保存 {savedSnapshots.length} 条
setSnapshotSearch(event.target.value)} placeholder="搜索快照名称、标题或内容" value={snapshotSearch} />
{( [ ["all", "全部"], ["review", "评价"], ["qa", "问大家"], ["competitor", "竞品"], ] as Array<[SnapshotKindFilter, string]> ).map(([value, label]) => ( ))}
{savedSnapshots.length === 0 ? (
还没有保存任何分析快照。你可以在评价、问大家或竞品分析结果卡片里点击“保存快照”。
) : filteredSnapshots.length === 0 ? (
没有匹配当前筛选条件的快照。
) : (
{filteredSnapshots.map((snapshot) => (

{snapshot.name}

{snapshot.kind === "review" ? "评价分析" : snapshot.kind === "qa" ? "问大家分析" : "竞品分析"}

{formatDateTime(snapshot.createdAt)}

{snapshot.title}

))}
)}
{ if (PRODUCT_PLANNING_ADVANCED_DISABLED && value === "advanced") { toast.info(PRODUCT_PLANNING_ADVANCED_DISABLED_MESSAGE); setMainTab("keywords"); return; } setMainTab(value); }} value={mainTab} > 关键词拆词 评价 / 问大家分析 {PRODUCT_PLANNING_ADVANCED_DISABLED ? null : ( 拓展分析 )} 关键词拆词分析 支持 txt、xlsx、csv 关键词文件,也支持直接导入手动分词结果。

手动分词模式

开启后会跳过自动拆词,直接使用你上传表格中的“词语 / 标签分类”等结果。

{manualSegmentationMode ? "手动分词模式支持 xlsx / csv,至少包含“词语”和“标签分类”列。" : "普通模式支持 txt / xlsx / csv,并自动识别关键词、搜索人气、支付转化率等列。"}