2919 lines
109 KiB
TypeScript
2919 lines
109 KiB
TypeScript
"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>
|
||
);
|
||
}
|