ai-chatbot/components/product-planning/product-planning-page.tsx
Your Name 478562c312
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: migrate product planning workspace
2026-03-26 16:38:48 +08:00

2919 lines
109 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.

"use client";
import { ChevronDown } from "lucide-react";
import { Streamdown } from "streamdown";
import { startTransition, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 { analyzeKeywordInputs } from "@/lib/product-planning/keyword-segmentation";
import {
parseKeywordInputFile,
parseManualSegmentationFile,
parseQaImportFile,
parseReviewImportFile,
} from "@/lib/product-planning/file-parsers";
import {
downloadCompetitorAnalysisWorkbook,
downloadCompetitorWorkbook,
downloadKeywordAnalysisWorkbook,
downloadKeywordInsightGroupsWorkbook,
downloadKeywordInsightSuggestionsWorkbook,
downloadMarkdownFile,
downloadSalesWorkbook,
} from "@/lib/product-planning/export";
import type {
KeywordAnalysisRow,
ProductPlanningCompetitorAnalysisRecord,
ProductPlanningKeywordInsightGroup,
ProductPlanningKeywordInsightResponse,
ProductPlanningKeywordInsightSuggestion,
ProductPlanningAnalyzeResponse,
ProductPlanningPropertyRow,
ProductPlanningRowsResponse,
ProductPlanningPlatform,
ProductPlanningTemplate,
ProductPlanningTemplateListResponse,
ProductPlanningTemplateMutationResponse,
ProductPlanningTaobaoItem,
ProductPlanningTaobaoItemResponse,
ProductPlanningMonthSaleResponse,
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 = {
platform: ProductPlanningPlatform;
itemId: string;
};
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 <p className="text-sm text-destructive">{message}</p>;
}
function MarkdownPanel({
title,
markdown,
actions,
}: {
title: string;
markdown: string | null;
actions?: import("react").ReactNode;
}) {
if (!markdown) return null;
return (
<Card className="border-primary/20">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">{title}</CardTitle>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
</CardHeader>
<CardContent>
<div className="prose prose-sm max-w-none dark:prose-invert">
<Streamdown>{markdown}</Streamdown>
</div>
</CardContent>
</Card>
);
}
function KeywordResultsTable({ rows }: { rows: KeywordAnalysisRow[] }) {
if (rows.length === 0) return null;
return (
<div className="overflow-x-auto rounded-lg border">
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">词语</th>
<th className="px-3 py-2 text-left font-medium">拆分片段</th>
<th className="px-3 py-2 text-left font-medium">标签分类</th>
<th className="px-3 py-2 text-left font-medium">出现次数</th>
<th className="px-3 py-2 text-left font-medium">搜索人气</th>
<th className="px-3 py-2 text-left font-medium">支付转化率</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.keyword} className="border-t">
<td className="px-3 py-2 font-medium">{row.keyword}</td>
<td className="px-3 py-2 text-muted-foreground">{row.segments.join(" / ")}</td>
<td className="px-3 py-2">{row.category}</td>
<td className="px-3 py-2">{row.occurrenceCount}</td>
<td className="px-3 py-2">
{typeof row.searchPopularity === "number" ? row.searchPopularity : "-"}
</td>
<td className="px-3 py-2">
{typeof row.conversionRate === "number"
? `${(row.conversionRate * 100).toFixed(2)}%`
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function ReviewTable({ rows }: { rows: TaobaoReviewRow[] }) {
if (rows.length === 0) return (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
还没有评价数据。
</div>
);
return (
<div className="overflow-x-auto rounded-lg border">
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">商品ID</th>
<th className="px-3 py-2 text-left font-medium">评论时间</th>
<th className="px-3 py-2 text-left font-medium">评价内容</th>
<th className="px-3 py-2 text-left font-medium">追评</th>
<th className="px-3 py-2 text-left font-medium">SKU</th>
<th className="px-3 py-2 text-left font-medium">买家ID</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr
key={`${row.itemId}-${row.date}-${index}`}
className="border-t align-top"
>
<td className="px-3 py-2">{row.itemId || "-"}</td>
<td className="px-3 py-2">{row.date || "-"}</td>
<td className="px-3 py-2 whitespace-pre-wrap">{row.content || "-"}</td>
<td className="px-3 py-2 whitespace-pre-wrap">{row.appendComment || "-"}</td>
<td className="px-3 py-2">{row.sku || "-"}</td>
<td className="px-3 py-2">{row.userId || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function QaTable({ rows }: { rows: TaobaoQaRow[] }) {
if (rows.length === 0) return (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
还没有问大家数据。
</div>
);
return (
<div className="overflow-x-auto rounded-lg border">
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">商品ID</th>
<th className="px-3 py-2 text-left font-medium">问题</th>
<th className="px-3 py-2 text-left font-medium">问题补充</th>
<th className="px-3 py-2 text-left font-medium">回答</th>
<th className="px-3 py-2 text-left font-medium">回答用户</th>
<th className="px-3 py-2 text-left font-medium">回答时间</th>
<th className="px-3 py-2 text-left font-medium">官方</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr
key={`${row.itemId}-${row.question}-${row.answer}-${index}`}
className="border-t align-top"
>
<td className="px-3 py-2">{row.itemId || "-"}</td>
<td className="px-3 py-2 whitespace-pre-wrap">{row.question || "-"}</td>
<td className="px-3 py-2 whitespace-pre-wrap">{row.questionDetail || "-"}</td>
<td className="px-3 py-2 whitespace-pre-wrap">{row.answer || "-"}</td>
<td className="px-3 py-2">{row.answerUser || "-"}</td>
<td className="px-3 py-2">{row.answerDate || "-"}</td>
<td className="px-3 py-2">{row.isOfficial ? "是" : "否"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
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 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 getCompetitorImageEntries(item: ProductPlanningTaobaoItem) {
const entries = [
item.mainImage
? {
key: `${item.platform}:${item.itemId}:main`,
label: "主图",
imageUrl: item.mainImage,
}
: null,
...item.detailImages.map((imageUrl, index) => ({
key: `${item.platform}:${item.itemId}:detail:${index}`,
label: `详情图 ${index + 1}`,
imageUrl,
})),
].filter(
(
entry
): entry is {
key: string;
label: string;
imageUrl: string;
} => Boolean(entry?.imageUrl)
);
return uniqueBy(entries, (entry) => entry.imageUrl);
}
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<string, ProductPlanningCompetitorAnalysisRecord>;
activeAnalysisKey: string | null;
}) {
if (rows.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
还没有竞品数据。
</div>
);
}
return (
<div className="overflow-x-auto rounded-lg border">
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">平台</th>
<th className="px-3 py-2 text-left font-medium">商品ID</th>
<th className="px-3 py-2 text-left font-medium">主图</th>
<th className="px-3 py-2 text-left font-medium">标题</th>
<th className="px-3 py-2 text-left font-medium">店铺</th>
<th className="px-3 py-2 text-left font-medium">当前价格</th>
<th className="px-3 py-2 text-left font-medium">总销量</th>
<th className="px-3 py-2 text-left font-medium">近期销量</th>
<th className="px-3 py-2 text-left font-medium">材质</th>
<th className="px-3 py-2 text-left font-medium">SKU概览</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const mainImageKey = `${row.platform}:${row.itemId}:main`;
const skuKey = `${row.platform}:${row.itemId}:sku`;
return (
<tr key={`${row.platform}-${row.itemId}`} className="border-t align-top">
<td className="px-3 py-2">{formatPlatform(row.platform, row.isTmall)}</td>
<td className="px-3 py-2">{row.itemId}</td>
<td className="px-3 py-2">
{row.mainImage ? (
<div className="space-y-2">
<img
alt={row.title}
className="h-16 w-16 rounded-md object-cover"
src={row.mainImage}
/>
<Button
disabled={analyzingItemKey === mainImageKey}
onClick={() =>
onAnalyzeImage({
item: row,
imageUrl: row.mainImage || "",
imageLabel: "主图",
imageKey: mainImageKey,
})
}
size="sm"
type="button"
variant="outline"
>
{analyzingItemKey === mainImageKey
? "分析中..."
: "AI 分析主图"}
</Button>
<Button
disabled={!analysisRecords[mainImageKey]}
onClick={() => onViewAnalysis(mainImageKey)}
size="sm"
type="button"
variant={activeAnalysisKey === mainImageKey ? "default" : "ghost"}
>
查看结果
</Button>
</div>
) : (
"-"
)}
</td>
<td className="px-3 py-2">
<div className="min-w-[280px] space-y-1">
<p className="font-medium">{row.title}</p>
{row.detailUrl ? (
<a
className="text-xs text-primary underline-offset-2 hover:underline"
href={row.detailUrl}
rel="noreferrer"
target="_blank"
>
查看商品链接
</a>
) : null}
</div>
</td>
<td className="px-3 py-2">{row.shopName || "-"}</td>
<td className="px-3 py-2">{formatPrice(row.price)}</td>
<td className="px-3 py-2">{formatInteger(row.totalSales)}</td>
<td className="px-3 py-2">{formatInteger(row.recentSales)}</td>
<td className="px-3 py-2 whitespace-pre-wrap">{row.material || "-"}</td>
<td className="px-3 py-2">
{row.skus.length > 0 ? (
<div className="min-w-[220px] space-y-2 text-xs text-muted-foreground">
<div className="space-y-1">
{row.skus.slice(0, 4).map((sku) => (
<div key={`${row.itemId}-${sku.name}`}>
{sku.name}:{formatPrice(sku.price)}
</div>
))}
</div>
<Button
disabled={analyzingSkuKey === skuKey}
onClick={() => onAnalyzeSku(row)}
size="sm"
type="button"
variant="outline"
>
{analyzingSkuKey === skuKey
? "分析中..."
: "AI 分析 SKU"}
</Button>
<Button
disabled={!analysisRecords[skuKey]}
onClick={() => onViewAnalysis(skuKey)}
size="sm"
type="button"
variant={activeAnalysisKey === skuKey ? "default" : "ghost"}
>
查看结果
</Button>
</div>
) : (
"-"
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function SalesTable({ rows }: { rows: ProductPlanningTaobaoItem[] }) {
if (rows.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
还没有销量数据。
</div>
);
}
return (
<div className="overflow-x-auto rounded-lg border">
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">平台</th>
<th className="px-3 py-2 text-left font-medium">商品ID</th>
<th className="px-3 py-2 text-left font-medium">标题</th>
<th className="px-3 py-2 text-left font-medium">近期销量</th>
<th className="px-3 py-2 text-left font-medium">当前价格</th>
<th className="px-3 py-2 text-left font-medium">店铺</th>
<th className="px-3 py-2 text-left font-medium">发货地</th>
<th className="px-3 py-2 text-left font-medium">材质</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.platform}-${row.itemId}`} className="border-t align-top">
<td className="px-3 py-2">{formatPlatform(row.platform, row.isTmall)}</td>
<td className="px-3 py-2">{row.itemId}</td>
<td className="px-3 py-2">
<div className="min-w-[280px] space-y-1">
<p className="font-medium">{row.title}</p>
{row.detailUrl ? (
<a
className="text-xs text-primary underline-offset-2 hover:underline"
href={row.detailUrl}
rel="noreferrer"
target="_blank"
>
查看商品链接
</a>
) : null}
</div>
</td>
<td className="px-3 py-2 font-medium">{formatInteger(row.recentSales)}</td>
<td className="px-3 py-2">{formatPrice(row.price)}</td>
<td className="px-3 py-2">{row.shopName || "-"}</td>
<td className="px-3 py-2">{row.location || "-"}</td>
<td className="px-3 py-2">{row.material || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function CompetitorGallery({
items,
analyzingImageKey,
onAnalyzeImage,
onViewAnalysis,
analysisRecords,
activeAnalysisKey,
}: {
items: ProductPlanningTaobaoItem[];
analyzingImageKey: string | null;
onAnalyzeImage: (params: CompetitorImageAnalyzeParams) => void;
onViewAnalysis: (key: string) => void;
analysisRecords: Record<string, ProductPlanningCompetitorAnalysisRecord>;
activeAnalysisKey: string | null;
}) {
if (items.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
还没有可展示的竞品图集。
</div>
);
}
return (
<div className="space-y-4">
{items.map((item) => {
const imageEntries = getCompetitorImageEntries(item);
const detailCount = Math.max(imageEntries.length - (item.mainImage ? 1 : 0), 0);
return (
<Collapsible
className="rounded-xl border bg-card"
defaultOpen={false}
key={`${item.platform}:${item.itemId}`}
>
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-4 px-4 py-3 text-left">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatPlatform(item.platform, item.isTmall)}</Badge>
<span className="font-medium">{item.title}</span>
</div>
<p className="text-xs text-muted-foreground">
商品ID:{item.itemId},图集 {imageEntries.length} 张
{detailCount > 0 ? `,其中详情图 ${detailCount} 张` : ""}
</p>
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="border-t px-4 py-4">
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">品牌 / 店铺</p>
<p className="mt-1 text-sm font-medium">
{item.brand || "未识别品牌"}
{item.shopName ? ` / ${item.shopName}` : ""}
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">价格 / 发货地</p>
<p className="mt-1 text-sm font-medium">
{formatPrice(item.price)}
{item.location ? ` / ${item.location}` : ""}
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">图集统计</p>
<p className="mt-1 text-sm font-medium">
主图 {item.itemImages.length} 张 / 详情图 {item.detailImages.length} 张
</p>
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">媒体信息</p>
<p className="mt-1 text-sm font-medium">
{item.videoUrl ? "含主图视频" : "无主图视频"}
</p>
</div>
</div>
<div className="rounded-lg border p-4">
<div className="space-y-2">
<p className="text-sm font-medium">商品参数预览</p>
<p className="text-sm text-muted-foreground">
{formatPropertyPreview(item.properties)}
</p>
{item.detailedMaterial ? (
<p className="text-xs text-muted-foreground">
详细材质:{item.detailedMaterial}
</p>
) : null}
</div>
</div>
{imageEntries.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
当前商品没有可展示的图片。
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{imageEntries.map((entry) => (
<div
className="space-y-3 rounded-xl border p-3"
key={entry.key}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{entry.label}</p>
<div className="flex items-center gap-2">
<Button
disabled={analyzingImageKey === entry.key}
onClick={() =>
onAnalyzeImage({
item,
imageUrl: entry.imageUrl,
imageLabel: entry.label,
imageKey: entry.key,
})
}
size="sm"
type="button"
variant="outline"
>
{analyzingImageKey === entry.key ? "分析中..." : "AI 分析"}
</Button>
<Button
disabled={!analysisRecords[entry.key]}
onClick={() => onViewAnalysis(entry.key)}
size="sm"
type="button"
variant={activeAnalysisKey === entry.key ? "default" : "ghost"}
>
查看结果
</Button>
</div>
</div>
<img
alt={`${item.title}-${entry.label}`}
className="aspect-square w-full rounded-lg object-cover"
src={entry.imageUrl}
/>
</div>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
);
}
function KeywordInsightSuggestionTable({
rows,
}: {
rows: ProductPlanningKeywordInsightSuggestion[];
}) {
if (rows.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
还没有搜索建议数据。
</div>
);
}
return (
<div className="overflow-x-auto rounded-lg border">
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">排名</th>
<th className="px-3 py-2 text-left font-medium">关键词</th>
<th className="px-3 py-2 text-left font-medium">权重</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.rank}-${row.keyword}`} className="border-t">
<td className="px-3 py-2">{row.rank}</td>
<td className="px-3 py-2 font-medium">{row.keyword}</td>
<td className="px-3 py-2">{row.weight.toLocaleString("zh-CN")}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function KeywordInsightGroupTable({
rows,
}: {
rows: ProductPlanningKeywordInsightGroup[];
}) {
if (rows.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
还没有标签分组数据。
</div>
);
}
return (
<div className="overflow-x-auto rounded-lg border">
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
<th className="px-3 py-2 text-left font-medium">组序号</th>
<th className="px-3 py-2 text-left font-medium">分组类型</th>
<th className="px-3 py-2 text-left font-medium">标签</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.index}-${row.type}`} className="border-t align-top">
<td className="px-3 py-2">{row.index}</td>
<td className="px-3 py-2">{row.type}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-2">
{row.tags.map((tag) => (
<Badge key={`${row.index}-${tag.title}`} variant={tag.type === "hot" ? "default" : "secondary"}>
{tag.title}
</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
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<KeywordAnalysisRow[]>([]);
const [keywordError, setKeywordError] = useState<string | null>(null);
const [keywordAnalyzing, setKeywordAnalyzing] = useState(false);
const keywordFileRef = useRef<HTMLInputElement | null>(null);
const manualFileRef = useRef<HTMLInputElement | null>(null);
const [templates, setTemplates] = useState<ProductPlanningTemplate[]>([]);
const [templateError, setTemplateError] = useState<string | null>(null);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [templateSearch, setTemplateSearch] = useState("");
const [templateUsingId, setTemplateUsingId] = useState<string | null>(null);
const [templateDownloadingId, setTemplateDownloadingId] = useState<string | null>(null);
const [templateUploading, setTemplateUploading] = useState(false);
const [templateDeletingId, setTemplateDeletingId] = useState<string | null>(null);
const [templateCategoryName, setTemplateCategoryName] = useState("");
const [templateDescription, setTemplateDescription] = useState("");
const templateUploadRef = useRef<HTMLInputElement | null>(null);
const [productInput, setProductInput] = useState("");
const [minPage, setMinPage] = useState("1");
const [maxPage, setMaxPage] = useState("3");
const [reviewRows, setReviewRows] = useState<TaobaoReviewRow[]>([]);
const [qaRows, setQaRows] = useState<TaobaoQaRow[]>([]);
const [reviewError, setReviewError] = useState<string | null>(null);
const [qaError, setQaError] = useState<string | null>(null);
const [resultTab, setResultTab] = useState("qa");
const [reviewMarkdown, setReviewMarkdown] = useState<string | null>(null);
const [qaMarkdown, setQaMarkdown] = useState<string | null>(null);
const [reviewAnalyzing, setReviewAnalyzing] = useState(false);
const [qaAnalyzing, setQaAnalyzing] = useState(false);
const [fetchProgress, setFetchProgress] = useState<FetchProgressState>({
running: false,
total: 0,
completed: 0,
failed: 0,
currentLabel: "",
});
const cancelFetchRef = useRef(false);
const reviewImportRef = useRef<HTMLInputElement | null>(null);
const qaImportRef = useRef<HTMLInputElement | null>(null);
const [advancedTab, setAdvancedTab] = useState("competitor");
const [competitorInput, setCompetitorInput] = useState("");
const [competitorItems, setCompetitorItems] = useState<ProductPlanningTaobaoItem[]>([]);
const [competitorError, setCompetitorError] = useState<string | null>(null);
const [competitorLoading, setCompetitorLoading] = useState(false);
const [competitorImageError, setCompetitorImageError] = useState<string | null>(null);
const [competitorImageLoadingId, setCompetitorImageLoadingId] = useState<string | null>(null);
const [competitorSkuError, setCompetitorSkuError] = useState<string | null>(null);
const [competitorSkuLoadingKey, setCompetitorSkuLoadingKey] = useState<string | null>(null);
const [competitorAnalysisRecords, setCompetitorAnalysisRecords] = useState<
Record<string, ProductPlanningCompetitorAnalysisRecord>
>({});
const [activeCompetitorAnalysisKey, setActiveCompetitorAnalysisKey] = useState<string | null>(
null
);
const [insightKeyword, setInsightKeyword] = useState("");
const [insightSuggestions, setInsightSuggestions] = useState<ProductPlanningKeywordInsightSuggestion[]>([]);
const [insightGroups, setInsightGroups] = useState<ProductPlanningKeywordInsightGroup[]>([]);
const [insightError, setInsightError] = useState<string | null>(null);
const [insightLoading, setInsightLoading] = useState(false);
const [salesInput, setSalesInput] = useState("");
const [salesItems, setSalesItems] = useState<ProductPlanningTaobaoItem[]>([]);
const [salesError, setSalesError] = useState<string | null>(null);
const [salesLoading, setSalesLoading] = useState(false);
const [savedSnapshots, setSavedSnapshots] = useState<ProductPlanningSavedSnapshot[]>([]);
const [activeSnapshotId, setActiveSnapshotId] = useState<string | null>(null);
const [snapshotSearch, setSnapshotSearch] = useState("");
const [snapshotKindFilter, setSnapshotKindFilter] = useState<SnapshotKindFilter>("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(() => {
try {
const raw = sessionStorage.getItem(PRODUCT_PLANNING_SESSION_KEY);
if (!raw) {
sessionHydratedRef.current = true;
return;
}
const parsed = JSON.parse(raw) as Partial<ProductPlanningSessionState> | null;
if (!parsed || parsed.version !== 1) {
sessionHydratedRef.current = true;
return;
}
if (typeof parsed.mainTab === "string") setMainTab(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<TaobaoReviewRow>;
const qaPayload = (await qaResponse.json()) as ProductPlanningRowsResponse<TaobaoQaRow>;
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 fetchMarketplaceItem(reference: MarketplaceItemRef) {
const endpoint =
reference.platform === "jd"
? "/api/product-planning/jd/item"
: "/api/product-planning/taobao/item";
const response = await fetch(`${endpoint}?id=${encodeURIComponent(reference.itemId)}`);
const payload = (await response.json()) as ProductPlanningTaobaoItemResponse;
if (!response.ok || !payload.success || !payload.item) {
throw new Error(payload.error || "获取商品详情失败");
}
return payload.item;
}
async function fetchMonthSale(itemId: string) {
const response = await fetch(
`/api/product-planning/taobao/month-sale?id=${encodeURIComponent(itemId)}`
);
const payload = (await response.json()) as ProductPlanningMonthSaleResponse;
if (!response.ok || !payload.success) {
throw new Error(payload.error || "获取近期销量失败");
}
return payload.recentSales;
}
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 rows: ProductPlanningTaobaoItem[] = [];
for (const reference of uniqueBy(itemRefs, (item) => `${item.platform}:${item.itemId}`)) {
const item = await fetchMarketplaceItem(reference);
if (reference.platform === "taobao") {
try {
item.recentSales = await fetchMonthSale(reference.itemId);
} catch (error) {
console.warn("[ProductPlanning] competitor month sale failed:", error);
}
}
rows.push(item);
}
startTransition(() => setCompetitorItems(rows));
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 rows: ProductPlanningTaobaoItem[] = [];
for (const reference of uniqueBy(itemRefs, (item) => `${item.platform}:${item.itemId}`)) {
const item = await fetchMarketplaceItem(reference);
if (reference.platform === "taobao") {
item.recentSales = await fetchMonthSale(reference.itemId);
} else {
item.recentSales = item.recentSales ?? item.totalSales;
}
rows.push(item);
}
startTransition(() => setSalesItems(rows));
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 (
<div className="flex h-full w-full flex-col overflow-y-auto">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-6">
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge variant="secondary">第一阶段</Badge>
<Badge variant="outline">商品策划工具</Badge>
</div>
<Button onClick={handleClearSession} type="button" variant="outline">
清空当前会话
</Button>
</div>
<h1 className="text-3xl font-semibold tracking-tight">商品策划工具</h1>
<p className="text-sm text-muted-foreground">
在当前项目内完成关键词拆词、评价/问大家抓取、本地表格导入和 AI 分析总结。
</p>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<CardTitle>分析快照</CardTitle>
<CardDescription>
将当前分析结果保存为会话内命名快照,便于回看、导出和对比。
</CardDescription>
</div>
<Badge variant="secondary">已保存 {savedSnapshots.length} 条</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<Input
onChange={(event) => setSnapshotSearch(event.target.value)}
placeholder="搜索快照名称、标题或内容"
value={snapshotSearch}
/>
<div className="flex flex-wrap gap-2">
{([
["all", "全部"],
["review", "评价"],
["qa", "问大家"],
["competitor", "竞品"],
] as Array<[SnapshotKindFilter, string]>).map(([value, label]) => (
<Button
key={value}
onClick={() => setSnapshotKindFilter(value)}
size="sm"
type="button"
variant={snapshotKindFilter === value ? "default" : "outline"}
>
{label}
</Button>
))}
</div>
</div>
{savedSnapshots.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
还没有保存任何分析快照。你可以在评价、问大家或竞品分析结果卡片里点击“保存快照”。
</div>
) : filteredSnapshots.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
没有匹配当前筛选条件的快照。
</div>
) : (
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
<div className="space-y-3">
{filteredSnapshots.map((snapshot) => (
<div
className="rounded-lg border p-3"
key={snapshot.id}
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<p className="font-medium">{snapshot.name}</p>
<p className="text-xs text-muted-foreground">
{snapshot.kind === "review"
? "评价分析"
: snapshot.kind === "qa"
? "问大家分析"
: "竞品分析"}
</p>
</div>
<Badge variant="outline">{formatDateTime(snapshot.createdAt)}</Badge>
</div>
<p className="text-sm text-muted-foreground">{snapshot.title}</p>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => handleViewSnapshot(snapshot.id)}
size="sm"
type="button"
variant={activeSnapshotId === snapshot.id ? "default" : "outline"}
>
查看
</Button>
<Button
onClick={() =>
handleMarkdownExport(
snapshot.markdown,
`商品策划工具_快照_${snapshot.name}`,
"当前快照没有可导出的内容"
)
}
size="sm"
type="button"
variant="ghost"
>
导出
</Button>
<Button
onClick={() => handleRenameSnapshot(snapshot.id)}
size="sm"
type="button"
variant="ghost"
>
重命名
</Button>
<Button
onClick={() => handleDeleteSnapshot(snapshot.id)}
size="sm"
type="button"
variant="ghost"
>
删除
</Button>
</div>
</div>
</div>
))}
</div>
<MarkdownPanel
markdown={activeSnapshot?.markdown || null}
title={activeSnapshot ? `快照预览:${activeSnapshot.name}` : "快照预览"}
/>
</div>
)}
</CardContent>
</Card>
<Tabs onValueChange={setMainTab} value={mainTab}>
<TabsList className="w-full justify-start">
<TabsTrigger value="keywords">关键词拆词</TabsTrigger>
<TabsTrigger value="analysis">评价 / 问大家分析</TabsTrigger>
<TabsTrigger value="advanced">拓展分析</TabsTrigger>
</TabsList>
<TabsContent value="keywords" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>关键词拆词分析</CardTitle>
<CardDescription>
支持 txt、xls、xlsx 关键词文件,也支持直接导入手动分词结果。
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<p className="text-sm font-medium">手动分词模式</p>
<p className="text-xs text-muted-foreground">
开启后会跳过自动拆词,直接使用你上传表格中的“词语 / 标签分类”等结果。
</p>
</div>
<Switch
checked={manualSegmentationMode}
onCheckedChange={setManualSegmentationMode}
/>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">
{manualSegmentationMode ? "上传手动分词文件" : "上传关键词文件"}
</label>
<Input
ref={manualSegmentationMode ? manualFileRef : keywordFileRef}
accept={manualSegmentationMode ? ".xlsx,.xls,.csv" : ".txt,.xlsx,.xls,.csv"}
type="file"
/>
<p className="text-xs text-muted-foreground">
{manualSegmentationMode
? "手动分词模式支持 xls / xlsx / csv,至少包含“词语”和“标签分类”列。"
: "普通模式支持 txt / xls / xlsx / csv,并自动识别关键词、搜索人气、支付转化率等列。"}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">品类大词</label>
<Textarea
disabled={manualSegmentationMode}
onChange={(event) => setCategoryWords(event.target.value)}
placeholder="输入不拆分的品类词,多个词可用逗号、空格或换行分隔"
value={categoryWords}
/>
<p className="text-xs text-muted-foreground">
普通模式下会优先把这些词当作完整词条,不再继续拆开。
</p>
</div>
</div>
<ErrorText message={keywordError} />
<div className="flex flex-wrap gap-3">
<Button disabled={keywordAnalyzing} onClick={handleKeywordAnalyze} type="button">
{keywordAnalyzing ? "分析中..." : "开始拆词"}
</Button>
<Button
disabled={keywordRows.length === 0}
onClick={handleKeywordExport}
type="button"
variant="outline"
>
导出 xlsx
</Button>
{keywordRows.length > 0 ? (
<Badge variant="secondary">已生成 {keywordRows.length} 条结果</Badge>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>分析结果</CardTitle>
<CardDescription>按出现次数聚合展示拆词结果。</CardDescription>
</CardHeader>
<CardContent>
{keywordRows.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
还没有拆词结果。上传文件并点击“开始拆词”后会在这里展示。
</div>
) : (
<KeywordResultsTable rows={keywordRows} />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<CardTitle>云端模板库</CardTitle>
<CardDescription>
共享常用品类关键词源数据,支持一键加载、下载,管理员可维护模板内容。
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">共 {templates.length} 个模板</Badge>
<Button
disabled={templatesLoading}
onClick={() => void loadTemplates(true)}
type="button"
variant="outline"
>
{templatesLoading ? "刷新中..." : "刷新模板"}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium">搜索模板</label>
<Input
onChange={(event) => setTemplateSearch(event.target.value)}
placeholder="按模板名称、描述或文件名筛选"
value={templateSearch}
/>
</div>
{canManageTemplates ? (
<div className="grid gap-4 rounded-lg border p-4 lg:grid-cols-[1.1fr_1fr]">
<div className="space-y-2">
<label className="text-sm font-medium">模板名称</label>
<Input
onChange={(event) => setTemplateCategoryName(event.target.value)}
placeholder="例如:换鞋凳、沙发床、书桌椅"
value={templateCategoryName}
/>
<p className="text-xs text-muted-foreground">
上传同名模板时会自动覆盖旧版本。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">模板描述</label>
<Input
onChange={(event) => setTemplateDescription(event.target.value)}
placeholder="可选,用于标记来源或适用场景"
value={templateDescription}
/>
</div>
<div className="space-y-2 lg:col-span-2">
<label className="text-sm font-medium">上传模板文件</label>
<Input
accept=".txt,.csv,.xls,.xlsx"
ref={templateUploadRef}
type="file"
/>
<div className="flex flex-wrap gap-3">
<Button
disabled={templateUploading}
onClick={() => void handleTemplateUpload()}
type="button"
>
{templateUploading ? "上传中..." : "上传模板"}
</Button>
<p className="text-xs text-muted-foreground">
支持 txt / csv / xls / xlsx,作为关键词源数据模板共享给其他登录用户。
</p>
</div>
</div>
</div>
) : null}
<ErrorText message={templateError} />
{filteredTemplates.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
{templates.length === 0
? "当前还没有可用模板。"
: "没有匹配当前搜索条件的模板。"}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredTemplates.map((template) => (
<div
key={template.id}
className="space-y-4 rounded-xl border bg-card p-4 shadow-sm"
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-base font-medium">{template.categoryName}</p>
<p className="text-xs text-muted-foreground">
更新于 {formatDateTime(template.updatedAt)}
</p>
</div>
<Badge variant="outline">{formatFileSize(template.fileSize)}</Badge>
</div>
<p className="min-h-10 text-sm text-muted-foreground">
{template.description || "未填写模板说明"}
</p>
<p className="text-xs text-muted-foreground">
文件:{template.fileName}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
disabled={templateUsingId === template.id}
onClick={() => void handleTemplateUse(template)}
size="sm"
type="button"
>
{templateUsingId === template.id ? "加载中..." : "使用模板"}
</Button>
<Button
disabled={templateDownloadingId === template.id}
onClick={() => void handleTemplateDownload(template)}
size="sm"
type="button"
variant="outline"
>
{templateDownloadingId === template.id ? "下载中..." : "下载源表"}
</Button>
{canManageTemplates ? (
<Button
disabled={templateDeletingId === template.id}
onClick={() => void handleTemplateDelete(template)}
size="sm"
type="button"
variant="ghost"
>
{templateDeletingId === template.id ? "删除中..." : "删除"}
</Button>
) : null}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="analysis" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>抓取淘宝数据</CardTitle>
<CardDescription>
输入淘宝链接或商品 ID,系统将按页数范围顺序抓取评价与问大家数据。
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium">商品链接或商品 ID</label>
<Textarea
onChange={(event) => setProductInput(event.target.value)}
placeholder="每行输入一个淘宝链接或商品 ID,最多 20 个"
rows={6}
value={productInput}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:max-w-sm">
<div className="space-y-2">
<label className="text-sm font-medium">最小页数</label>
<Input
max={20}
min={1}
onChange={(event) => setMinPage(event.target.value)}
type="number"
value={minPage}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">最大页数</label>
<Input
max={20}
min={1}
onChange={(event) => setMaxPage(event.target.value)}
type="number"
value={maxPage}
/>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button disabled={fetchProgress.running} onClick={handleBatchFetch} type="button">
{fetchProgress.running ? "抓取中..." : "开始抓取"}
</Button>
<Button
disabled={!fetchProgress.running}
onClick={cancelBatchFetch}
type="button"
variant="outline"
>
取消抓取
</Button>
</div>
{fetchProgress.total > 0 ? (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between text-sm">
<span>{fetchProgress.currentLabel || "准备中..."}</span>
<span>
{fetchProgress.completed}/{fetchProgress.total}
</span>
</div>
<Progress value={progressValue} />
<div className="text-xs text-muted-foreground">
已失败请求数:{fetchProgress.failed}
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>本地导入</CardTitle>
<CardDescription>
也可以直接导入本地评价表或问大家表,导入后可直接做 AI 分析。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 lg:grid-cols-2">
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1">
<p className="text-sm font-medium">导入评价表</p>
<p className="text-xs text-muted-foreground">
支持 csv / xls / xlsx,优先识别“评价内容”等核心列。
</p>
</div>
<Input
accept=".csv,.xls,.xlsx"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleReviewImport(file);
}
event.target.value = "";
}}
ref={reviewImportRef}
type="file"
/>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1">
<p className="text-sm font-medium">导入问大家表</p>
<p className="text-xs text-muted-foreground">
支持 csv / xls / xlsx,优先识别“问题”和“回答”等核心列。
</p>
</div>
<Input
accept=".csv,.xls,.xlsx"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleQaImport(file);
}
event.target.value = "";
}}
ref={qaImportRef}
type="file"
/>
</div>
</CardContent>
</Card>
<Tabs onValueChange={setResultTab} value={resultTab}>
<TabsList className="w-full justify-start">
<TabsTrigger value="qa">问大家 ({qaRows.length})</TabsTrigger>
<TabsTrigger value="review">评价 ({reviewRows.length})</TabsTrigger>
</TabsList>
<TabsContent value="qa" className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<CardTitle>问大家数据</CardTitle>
<CardDescription>支持抓取结果与本地导入结果统一分析。</CardDescription>
</div>
<Button
disabled={qaRows.length === 0 || qaAnalyzing}
onClick={() => void handleAnalyze("qa")}
type="button"
>
{qaAnalyzing ? "分析中..." : "AI 分析问大家"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ErrorText message={qaError} />
<QaTable rows={qaRows} />
</CardContent>
</Card>
<MarkdownPanel
actions={
<>
<Button
onClick={() =>
handleSaveSnapshot({
kind: "qa",
defaultName: `问大家分析_${new Date().toISOString().slice(0, 10)}`,
title: "问大家分析结果",
markdown: qaMarkdown,
})
}
size="sm"
type="button"
variant="outline"
>
保存快照
</Button>
<Button
onClick={() =>
handleMarkdownExport(
qaMarkdown,
"商品策划工具_问大家分析结果",
"当前没有可导出的问大家分析结果"
)
}
size="sm"
type="button"
variant="outline"
>
导出 Markdown
</Button>
</>
}
markdown={qaMarkdown}
title="问大家分析结果"
/>
</TabsContent>
<TabsContent value="review" className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<CardTitle>评价数据</CardTitle>
<CardDescription>支持抓取结果与本地导入结果统一分析。</CardDescription>
</div>
<Button
disabled={reviewRows.length === 0 || reviewAnalyzing}
onClick={() => void handleAnalyze("review")}
type="button"
>
{reviewAnalyzing ? "分析中..." : "AI 分析评价"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ErrorText message={reviewError} />
<ReviewTable rows={reviewRows} />
</CardContent>
</Card>
<MarkdownPanel
actions={
<>
<Button
onClick={() =>
handleSaveSnapshot({
kind: "review",
defaultName: `评价分析_${new Date().toISOString().slice(0, 10)}`,
title: "评价分析结果",
markdown: reviewMarkdown,
})
}
size="sm"
type="button"
variant="outline"
>
保存快照
</Button>
<Button
onClick={() =>
handleMarkdownExport(
reviewMarkdown,
"商品策划工具_评价分析结果",
"当前没有可导出的评价分析结果"
)
}
size="sm"
type="button"
variant="outline"
>
导出 Markdown
</Button>
</>
}
markdown={reviewMarkdown}
title="评价分析结果"
/>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="advanced" className="space-y-6">
<Tabs onValueChange={setAdvancedTab} value={advancedTab}>
<TabsList className="w-full justify-start">
<TabsTrigger value="competitor">竞品分析</TabsTrigger>
<TabsTrigger value="insight">关键词洞察</TabsTrigger>
<TabsTrigger value="sales">ID 销量查询</TabsTrigger>
</TabsList>
<TabsContent value="competitor" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>多平台竞品分析</CardTitle>
<CardDescription>
支持淘宝链接、京东链接,以及 `jd:商品ID` 形式的京东输入,系统会聚合商品详情、SKU 和销量做横向对比。
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium">竞品链接或商品 ID</label>
<Textarea
onChange={(event) => setCompetitorInput(event.target.value)}
placeholder={"每行输入一个商品\n淘宝示例:https://item.taobao.com/item.htm?id=123\n京东示例:https://item.jd.com/123.html 或 jd:123"}
rows={6}
value={competitorInput}
/>
<p className="text-xs text-muted-foreground">
纯数字默认按淘宝处理;京东纯数字请写成 `jd:123456789`。
</p>
</div>
<ErrorText message={competitorError} />
<div className="flex flex-wrap gap-3">
<Button
disabled={competitorLoading}
onClick={() => void handleCompetitorFetch()}
type="button"
>
{competitorLoading ? "加载中..." : "获取竞品数据"}
</Button>
<Button
disabled={competitorItems.length === 0}
onClick={() => void handleCompetitorExport()}
type="button"
variant="outline"
>
导出 xlsx
</Button>
{competitorItems.length > 0 ? (
<Badge variant="secondary">已获取 {competitorItems.length} 个竞品</Badge>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>竞品结果</CardTitle>
<CardDescription>
统一展示价格、销量、材质和 SKU 概览,也支持直接分析竞品主图。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ErrorText message={competitorImageError} />
<CompetitorTable
activeAnalysisKey={activeCompetitorAnalysisKey}
analysisRecords={competitorAnalysisRecords}
analyzingItemKey={competitorImageLoadingId}
analyzingSkuKey={competitorSkuLoadingKey}
onAnalyzeImage={(params) => void handleCompetitorImageAnalyze(params)}
onAnalyzeSku={(item) => void handleCompetitorSkuAnalyze(item)}
onViewAnalysis={handleViewCompetitorAnalysis}
rows={competitorItems}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<CardTitle>竞品图集</CardTitle>
<CardDescription>
展开后可查看主图与详情图,并逐张触发 AI 分析。
</CardDescription>
</div>
<Button
disabled={Object.keys(competitorAnalysisRecords).length === 0}
onClick={() => void handleCompetitorAnalysisWorkbookExport()}
size="sm"
type="button"
variant="outline"
>
导出分析汇总
</Button>
</div>
</CardHeader>
<CardContent>
<CompetitorGallery
activeAnalysisKey={activeCompetitorAnalysisKey}
analysisRecords={competitorAnalysisRecords}
analyzingImageKey={competitorImageLoadingId}
items={competitorItems}
onAnalyzeImage={(params) => void handleCompetitorImageAnalyze(params)}
onViewAnalysis={handleViewCompetitorAnalysis}
/>
</CardContent>
</Card>
<MarkdownPanel
actions={
<>
<Button
onClick={() =>
handleSaveSnapshot({
kind: "competitor",
defaultName: `竞品分析_${new Date().toISOString().slice(0, 10)}`,
title: activeCompetitorAnalysis?.title || "竞品分析结果",
markdown: activeCompetitorAnalysis?.markdown || null,
})
}
size="sm"
type="button"
variant="outline"
>
保存快照
</Button>
<Button
onClick={() =>
handleMarkdownExport(
activeCompetitorAnalysis?.markdown || null,
"商品策划工具_竞品分析结果",
"当前没有可导出的竞品分析结果"
)
}
size="sm"
type="button"
variant="outline"
>
导出 Markdown
</Button>
</>
}
markdown={activeCompetitorAnalysis?.markdown || null}
title={
activeCompetitorAnalysis
? `${activeCompetitorAnalysis.kind === "sku" ? "SKU" : "竞品图片"}分析:${activeCompetitorAnalysis.title}`
: "竞品分析结果"
}
/>
<ErrorText message={competitorSkuError} />
</TabsContent>
<TabsContent value="insight" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>关键词洞察</CardTitle>
<CardDescription>
调用淘宝搜索建议接口,输出推荐词和智能标签分组。
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium">搜索关键词</label>
<div className="flex flex-col gap-3 md:flex-row">
<Input
onChange={(event) => setInsightKeyword(event.target.value)}
placeholder="例如:换鞋凳"
value={insightKeyword}
/>
<Button
disabled={insightLoading}
onClick={() => void handleKeywordInsightSearch()}
type="button"
>
{insightLoading ? "分析中..." : "搜索洞察"}
</Button>
</div>
</div>
<ErrorText message={insightError} />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<CardTitle>搜索建议</CardTitle>
<CardDescription>按接口返回的权重排序展示。</CardDescription>
</div>
<Button
disabled={insightSuggestions.length === 0}
onClick={() => void handleInsightSuggestionsExport()}
type="button"
variant="outline"
>
导出搜索建议
</Button>
</div>
</CardHeader>
<CardContent>
<KeywordInsightSuggestionTable rows={insightSuggestions} />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<CardTitle>标签分组</CardTitle>
<CardDescription>保留热门标签与普通标签的分组信息。</CardDescription>
</div>
<Button
disabled={insightGroups.length === 0}
onClick={() => void handleInsightGroupsExport()}
type="button"
variant="outline"
>
导出标签分组
</Button>
</div>
</CardHeader>
<CardContent>
<KeywordInsightGroupTable rows={insightGroups} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="sales" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>ID 销量查询</CardTitle>
<CardDescription>
支持淘宝链接、京东链接,以及 `jd:商品ID` 形式的京东输入,系统会聚合销量、价格、店铺和材质信息。
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium">商品链接或商品 ID</label>
<Textarea
onChange={(event) => setSalesInput(event.target.value)}
placeholder={"每行输入一个商品\n淘宝示例:https://detail.tmall.com/item.htm?id=123\n京东示例:https://item.jd.com/123.html 或 jd:123"}
rows={6}
value={salesInput}
/>
<p className="text-xs text-muted-foreground">
京东暂不走额外月销量接口,优先展示商品详情接口返回的销量字段。
</p>
</div>
<ErrorText message={salesError} />
<div className="flex flex-wrap gap-3">
<Button
disabled={salesLoading}
onClick={() => void handleSalesFetch()}
type="button"
>
{salesLoading ? "查询中..." : "查询销量"}
</Button>
<Button
disabled={salesItems.length === 0}
onClick={() => void handleSalesExport()}
type="button"
variant="outline"
>
导出 xlsx
</Button>
{salesItems.length > 0 ? (
<Badge variant="secondary">已查询 {salesItems.length} 个商品</Badge>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>销量结果</CardTitle>
<CardDescription>展示近期销量、价格和材质摘要。</CardDescription>
</CardHeader>
<CardContent>
<SalesTable rows={salesItems} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</div>
</div>
);
}