diff --git a/components/product-planning/product-planning-page.tsx b/components/product-planning/product-planning-page.tsx
new file mode 100644
index 000000000..becc108ac
--- /dev/null
+++ b/components/product-planning/product-planning-page.tsx
@@ -0,0 +1,2918 @@
+"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
;
+ activeCompetitorAnalysisKey: string | null;
+ insightKeyword: string;
+ insightSuggestions: ProductPlanningKeywordInsightSuggestion[];
+ insightGroups: ProductPlanningKeywordInsightGroup[];
+ salesInput: string;
+ salesItems: ProductPlanningTaobaoItem[];
+ templateSearch: string;
+ savedSnapshots: ProductPlanningSavedSnapshot[];
+ activeSnapshotId: string | null;
+ snapshotSearch: string;
+ snapshotKindFilter: SnapshotKindFilter;
+};
+
+type ProductPlanningSavedSnapshot = {
+ id: string;
+ name: string;
+ kind: "review" | "qa" | "competitor";
+ title: string;
+ markdown: string;
+ createdAt: string;
+};
+
+type SnapshotKindFilter = "all" | ProductPlanningSavedSnapshot["kind"];
+
+const PRODUCT_PLANNING_SESSION_KEY = "product-planning-session-v1";
+
+function parseCategoryWords(value: string) {
+ return value
+ .split(/[\n,,;;\s]+/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function ErrorText({ message }: { message: string | null }) {
+ if (!message) return null;
+ return {message}
;
+}
+
+function MarkdownPanel({
+ title,
+ markdown,
+ actions,
+}: {
+ title: string;
+ markdown: string | null;
+ actions?: import("react").ReactNode;
+}) {
+ if (!markdown) return null;
+
+ return (
+
+
+
+
{title}
+ {actions ?
{actions}
: null}
+
+
+
+
+ {markdown}
+
+
+
+ );
+}
+
+function KeywordResultsTable({ rows }: { rows: KeywordAnalysisRow[] }) {
+ if (rows.length === 0) return null;
+
+ return (
+
+
+
+
+ | 词语 |
+ 拆分片段 |
+ 标签分类 |
+ 出现次数 |
+ 搜索人气 |
+ 支付转化率 |
+
+
+
+ {rows.map((row) => (
+
+ | {row.keyword} |
+ {row.segments.join(" / ")} |
+ {row.category} |
+ {row.occurrenceCount} |
+
+ {typeof row.searchPopularity === "number" ? row.searchPopularity : "-"}
+ |
+
+ {typeof row.conversionRate === "number"
+ ? `${(row.conversionRate * 100).toFixed(2)}%`
+ : "-"}
+ |
+
+ ))}
+
+
+
+ );
+}
+
+function ReviewTable({ rows }: { rows: TaobaoReviewRow[] }) {
+ if (rows.length === 0) return (
+
+ 还没有评价数据。
+
+ );
+
+ return (
+
+
+
+
+ | 商品ID |
+ 评论时间 |
+ 评价内容 |
+ 追评 |
+ SKU |
+ 买家ID |
+
+
+
+ {rows.map((row, index) => (
+
+ | {row.itemId || "-"} |
+ {row.date || "-"} |
+ {row.content || "-"} |
+ {row.appendComment || "-"} |
+ {row.sku || "-"} |
+ {row.userId || "-"} |
+
+ ))}
+
+
+
+ );
+}
+
+function QaTable({ rows }: { rows: TaobaoQaRow[] }) {
+ if (rows.length === 0) return (
+
+ 还没有问大家数据。
+
+ );
+
+ return (
+
+
+
+
+ | 商品ID |
+ 问题 |
+ 问题补充 |
+ 回答 |
+ 回答用户 |
+ 回答时间 |
+ 官方 |
+
+
+
+ {rows.map((row, index) => (
+
+ | {row.itemId || "-"} |
+ {row.question || "-"} |
+ {row.questionDetail || "-"} |
+ {row.answer || "-"} |
+ {row.answerUser || "-"} |
+ {row.answerDate || "-"} |
+ {row.isOfficial ? "是" : "否"} |
+
+ ))}
+
+
+
+ );
+}
+
+function formatInteger(value: number | null | undefined) {
+ if (typeof value !== "number" || Number.isNaN(value)) return "-";
+ return value.toLocaleString("zh-CN");
+}
+
+function formatPrice(value: number | null | undefined) {
+ if (typeof value !== "number" || Number.isNaN(value)) return "-";
+ return `¥${value.toFixed(2)}`;
+}
+
+function formatPlatform(platform: ProductPlanningPlatform, isTmall = false) {
+ if (platform === "jd") return "京东";
+ return isTmall ? "天猫" : "淘宝";
+}
+
+function 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;
+ activeAnalysisKey: string | null;
+}) {
+ if (rows.length === 0) {
+ return (
+
+ 还没有竞品数据。
+
+ );
+ }
+
+ return (
+
+
+
+
+ | 平台 |
+ 商品ID |
+ 主图 |
+ 标题 |
+ 店铺 |
+ 当前价格 |
+ 总销量 |
+ 近期销量 |
+ 材质 |
+ SKU概览 |
+
+
+
+ {rows.map((row) => {
+ const mainImageKey = `${row.platform}:${row.itemId}:main`;
+ const skuKey = `${row.platform}:${row.itemId}:sku`;
+
+ return (
+
+ | {formatPlatform(row.platform, row.isTmall)} |
+ {row.itemId} |
+
+ {row.mainImage ? (
+
+ 
+
+
+
+ ) : (
+ "-"
+ )}
+ |
+
+
+ {row.title}
+ {row.detailUrl ? (
+
+ 查看商品链接
+
+ ) : null}
+
+ |
+ {row.shopName || "-"} |
+ {formatPrice(row.price)} |
+ {formatInteger(row.totalSales)} |
+ {formatInteger(row.recentSales)} |
+ {row.material || "-"} |
+
+ {row.skus.length > 0 ? (
+
+
+ {row.skus.slice(0, 4).map((sku) => (
+
+ {sku.name}:{formatPrice(sku.price)}
+
+ ))}
+
+
+
+
+ ) : (
+ "-"
+ )}
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+function SalesTable({ rows }: { rows: ProductPlanningTaobaoItem[] }) {
+ if (rows.length === 0) {
+ return (
+
+ 还没有销量数据。
+
+ );
+ }
+
+ return (
+
+
+
+
+ | 平台 |
+ 商品ID |
+ 标题 |
+ 近期销量 |
+ 当前价格 |
+ 店铺 |
+ 发货地 |
+ 材质 |
+
+
+
+ {rows.map((row) => (
+
+ | {formatPlatform(row.platform, row.isTmall)} |
+ {row.itemId} |
+
+
+ {row.title}
+ {row.detailUrl ? (
+
+ 查看商品链接
+
+ ) : null}
+
+ |
+ {formatInteger(row.recentSales)} |
+ {formatPrice(row.price)} |
+ {row.shopName || "-"} |
+ {row.location || "-"} |
+ {row.material || "-"} |
+
+ ))}
+
+
+
+ );
+}
+
+function CompetitorGallery({
+ items,
+ analyzingImageKey,
+ onAnalyzeImage,
+ onViewAnalysis,
+ analysisRecords,
+ activeAnalysisKey,
+}: {
+ items: ProductPlanningTaobaoItem[];
+ analyzingImageKey: string | null;
+ onAnalyzeImage: (params: CompetitorImageAnalyzeParams) => void;
+ onViewAnalysis: (key: string) => void;
+ analysisRecords: Record;
+ activeAnalysisKey: string | null;
+}) {
+ if (items.length === 0) {
+ return (
+
+ 还没有可展示的竞品图集。
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => {
+ const imageEntries = getCompetitorImageEntries(item);
+ const detailCount = Math.max(imageEntries.length - (item.mainImage ? 1 : 0), 0);
+
+ return (
+
+
+
+
+ {formatPlatform(item.platform, item.isTmall)}
+ {item.title}
+
+
+ 商品ID:{item.itemId},图集 {imageEntries.length} 张
+ {detailCount > 0 ? `,其中详情图 ${detailCount} 张` : ""}
+
+
+
+
+
+
+
+
+
品牌 / 店铺
+
+ {item.brand || "未识别品牌"}
+ {item.shopName ? ` / ${item.shopName}` : ""}
+
+
+
+
价格 / 发货地
+
+ {formatPrice(item.price)}
+ {item.location ? ` / ${item.location}` : ""}
+
+
+
+
图集统计
+
+ 主图 {item.itemImages.length} 张 / 详情图 {item.detailImages.length} 张
+
+
+
+
媒体信息
+
+ {item.videoUrl ? "含主图视频" : "无主图视频"}
+
+
+
+
+
+
+
商品参数预览
+
+ {formatPropertyPreview(item.properties)}
+
+ {item.detailedMaterial ? (
+
+ 详细材质:{item.detailedMaterial}
+
+ ) : null}
+
+
+
+ {imageEntries.length === 0 ? (
+
+ 当前商品没有可展示的图片。
+
+ ) : (
+
+ {imageEntries.map((entry) => (
+
+
+
{entry.label}
+
+
+
+
+
+

+
+ ))}
+
+ )}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function KeywordInsightSuggestionTable({
+ rows,
+}: {
+ rows: ProductPlanningKeywordInsightSuggestion[];
+}) {
+ if (rows.length === 0) {
+ return (
+
+ 还没有搜索建议数据。
+
+ );
+ }
+
+ return (
+
+
+
+
+ | 排名 |
+ 关键词 |
+ 权重 |
+
+
+
+ {rows.map((row) => (
+
+ | {row.rank} |
+ {row.keyword} |
+ {row.weight.toLocaleString("zh-CN")} |
+
+ ))}
+
+
+
+ );
+}
+
+function KeywordInsightGroupTable({
+ rows,
+}: {
+ rows: ProductPlanningKeywordInsightGroup[];
+}) {
+ if (rows.length === 0) {
+ return (
+
+ 还没有标签分组数据。
+
+ );
+ }
+
+ return (
+
+
+
+
+ | 组序号 |
+ 分组类型 |
+ 标签 |
+
+
+
+ {rows.map((row) => (
+
+ | {row.index} |
+ {row.type} |
+
+
+ {row.tags.map((tag) => (
+
+ {tag.title}
+
+ ))}
+
+ |
+
+ ))}
+
+
+
+ );
+}
+
+export function ProductPlanningPage({
+ canManageTemplates = false,
+}: {
+ canManageTemplates?: boolean;
+}) {
+ const sessionHydratedRef = useRef(false);
+ const [mainTab, setMainTab] = useState("keywords");
+ const [manualSegmentationMode, setManualSegmentationMode] = useState(false);
+ const [categoryWords, setCategoryWords] = useState("");
+ const [keywordRows, setKeywordRows] = useState([]);
+ const [keywordError, setKeywordError] = useState(null);
+ const [keywordAnalyzing, setKeywordAnalyzing] = useState(false);
+ const keywordFileRef = useRef(null);
+ const manualFileRef = useRef(null);
+ const [templates, setTemplates] = useState([]);
+ const [templateError, setTemplateError] = useState(null);
+ const [templatesLoading, setTemplatesLoading] = useState(false);
+ const [templateSearch, setTemplateSearch] = useState("");
+ const [templateUsingId, setTemplateUsingId] = useState(null);
+ const [templateDownloadingId, setTemplateDownloadingId] = useState(null);
+ const [templateUploading, setTemplateUploading] = useState(false);
+ const [templateDeletingId, setTemplateDeletingId] = useState(null);
+ const [templateCategoryName, setTemplateCategoryName] = useState("");
+ const [templateDescription, setTemplateDescription] = useState("");
+ const templateUploadRef = useRef(null);
+
+ const [productInput, setProductInput] = useState("");
+ const [minPage, setMinPage] = useState("1");
+ const [maxPage, setMaxPage] = useState("3");
+ const [reviewRows, setReviewRows] = useState([]);
+ const [qaRows, setQaRows] = useState([]);
+ const [reviewError, setReviewError] = useState(null);
+ const [qaError, setQaError] = useState(null);
+ const [resultTab, setResultTab] = useState("qa");
+ const [reviewMarkdown, setReviewMarkdown] = useState(null);
+ const [qaMarkdown, setQaMarkdown] = useState(null);
+ const [reviewAnalyzing, setReviewAnalyzing] = useState(false);
+ const [qaAnalyzing, setQaAnalyzing] = useState(false);
+ const [fetchProgress, setFetchProgress] = useState({
+ running: false,
+ total: 0,
+ completed: 0,
+ failed: 0,
+ currentLabel: "",
+ });
+ const cancelFetchRef = useRef(false);
+ const reviewImportRef = useRef(null);
+ const qaImportRef = useRef(null);
+
+ const [advancedTab, setAdvancedTab] = useState("competitor");
+ const [competitorInput, setCompetitorInput] = useState("");
+ const [competitorItems, setCompetitorItems] = useState([]);
+ const [competitorError, setCompetitorError] = useState(null);
+ const [competitorLoading, setCompetitorLoading] = useState(false);
+ const [competitorImageError, setCompetitorImageError] = useState(null);
+ const [competitorImageLoadingId, setCompetitorImageLoadingId] = useState(null);
+ const [competitorSkuError, setCompetitorSkuError] = useState(null);
+ const [competitorSkuLoadingKey, setCompetitorSkuLoadingKey] = useState(null);
+ const [competitorAnalysisRecords, setCompetitorAnalysisRecords] = useState<
+ Record
+ >({});
+ const [activeCompetitorAnalysisKey, setActiveCompetitorAnalysisKey] = useState(
+ null
+ );
+
+ const [insightKeyword, setInsightKeyword] = useState("");
+ const [insightSuggestions, setInsightSuggestions] = useState([]);
+ const [insightGroups, setInsightGroups] = useState([]);
+ const [insightError, setInsightError] = useState(null);
+ const [insightLoading, setInsightLoading] = useState(false);
+
+ const [salesInput, setSalesInput] = useState("");
+ const [salesItems, setSalesItems] = useState([]);
+ const [salesError, setSalesError] = useState(null);
+ const [salesLoading, setSalesLoading] = useState(false);
+ const [savedSnapshots, setSavedSnapshots] = useState([]);
+ const [activeSnapshotId, setActiveSnapshotId] = useState(null);
+ const [snapshotSearch, setSnapshotSearch] = useState("");
+ const [snapshotKindFilter, setSnapshotKindFilter] = useState("all");
+
+ async function handleKeywordAnalyze() {
+ const keywordFile = keywordFileRef.current?.files?.[0];
+ const manualFile = manualFileRef.current?.files?.[0];
+ setKeywordError(null);
+ setKeywordAnalyzing(true);
+
+ try {
+ if (manualSegmentationMode) {
+ if (!manualFile) {
+ throw new Error("请先上传手动分词文件");
+ }
+ const rows = await parseManualSegmentationFile(manualFile);
+ startTransition(() => setKeywordRows(rows));
+ toast.success(`已导入 ${rows.length} 条手动分词结果`);
+ return;
+ }
+
+ if (!keywordFile) {
+ throw new Error("请先上传关键词文件");
+ }
+
+ const inputs = await parseKeywordInputFile(keywordFile);
+ const rows = analyzeKeywordInputs(inputs, {
+ categoryWords: parseCategoryWords(categoryWords),
+ });
+ startTransition(() => setKeywordRows(rows));
+ toast.success(`已完成 ${inputs.length} 条关键词分析`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "关键词分析失败";
+ setKeywordError(message);
+ toast.error(message);
+ } finally {
+ setKeywordAnalyzing(false);
+ }
+ }
+
+ async function handleKeywordExport() {
+ if (keywordRows.length === 0) {
+ toast.error("当前没有可导出的关键词结果");
+ return;
+ }
+
+ try {
+ await downloadKeywordAnalysisWorkbook(keywordRows);
+ toast.success("关键词分析结果已导出");
+ } catch (error) {
+ console.error("[ProductPlanning] export failed:", error);
+ toast.error("导出关键词分析结果失败");
+ }
+ }
+
+ async function loadTemplates(showToast = false) {
+ setTemplatesLoading(true);
+
+ try {
+ const response = await fetch("/api/product-planning/templates", {
+ method: "GET",
+ cache: "no-store",
+ });
+ const payload = (await response.json()) as ProductPlanningTemplateListResponse;
+ if (!response.ok || !payload.success) {
+ throw new Error(payload.error || "模板库加载失败");
+ }
+
+ setTemplateError(null);
+ setTemplates(payload.templates);
+ if (showToast) {
+ toast.success("模板库已刷新");
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "模板库加载失败";
+ setTemplateError(message);
+ if (showToast) {
+ toast.error(message);
+ }
+ } finally {
+ setTemplatesLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ void loadTemplates();
+ }, []);
+
+ useEffect(() => {
+ try {
+ const raw = sessionStorage.getItem(PRODUCT_PLANNING_SESSION_KEY);
+ if (!raw) {
+ sessionHydratedRef.current = true;
+ return;
+ }
+
+ const parsed = JSON.parse(raw) as Partial | null;
+ if (!parsed || parsed.version !== 1) {
+ sessionHydratedRef.current = true;
+ return;
+ }
+
+ if (typeof parsed.mainTab === "string") setMainTab(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
+ );
+ }
+ if (
+ typeof parsed.activeCompetitorAnalysisKey === "string" ||
+ parsed.activeCompetitorAnalysisKey === null
+ ) {
+ setActiveCompetitorAnalysisKey(parsed.activeCompetitorAnalysisKey ?? null);
+ }
+ if (typeof parsed.insightKeyword === "string") setInsightKeyword(parsed.insightKeyword);
+ if (Array.isArray(parsed.insightSuggestions)) {
+ setInsightSuggestions(
+ parsed.insightSuggestions as ProductPlanningKeywordInsightSuggestion[]
+ );
+ }
+ if (Array.isArray(parsed.insightGroups)) {
+ setInsightGroups(parsed.insightGroups as ProductPlanningKeywordInsightGroup[]);
+ }
+ if (typeof parsed.salesInput === "string") setSalesInput(parsed.salesInput);
+ if (Array.isArray(parsed.salesItems)) {
+ setSalesItems(parsed.salesItems as ProductPlanningTaobaoItem[]);
+ }
+ if (typeof parsed.templateSearch === "string") setTemplateSearch(parsed.templateSearch);
+ if (Array.isArray(parsed.savedSnapshots)) {
+ setSavedSnapshots(parsed.savedSnapshots as ProductPlanningSavedSnapshot[]);
+ }
+ if (typeof parsed.activeSnapshotId === "string" || parsed.activeSnapshotId === null) {
+ setActiveSnapshotId(parsed.activeSnapshotId ?? null);
+ }
+ if (typeof parsed.snapshotSearch === "string") setSnapshotSearch(parsed.snapshotSearch);
+ if (
+ parsed.snapshotKindFilter === "all" ||
+ parsed.snapshotKindFilter === "review" ||
+ parsed.snapshotKindFilter === "qa" ||
+ parsed.snapshotKindFilter === "competitor"
+ ) {
+ setSnapshotKindFilter(parsed.snapshotKindFilter);
+ }
+ } catch (error) {
+ console.warn("[ProductPlanning] restore session failed:", error);
+ } finally {
+ sessionHydratedRef.current = true;
+ }
+ }, []);
+
+ async function fetchTemplateBlob(template: ProductPlanningTemplate) {
+ const response = await fetch(`/api/product-planning/templates/${encodeURIComponent(template.id)}`, {
+ method: "GET",
+ });
+
+ if (!response.ok) {
+ const payload = (await response.json().catch(() => null)) as
+ | ProductPlanningTemplateMutationResponse
+ | null;
+ throw new Error(payload?.error || "模板下载失败");
+ }
+
+ return await response.blob();
+ }
+
+ async function handleTemplateUse(template: ProductPlanningTemplate) {
+ setTemplateUsingId(template.id);
+ setKeywordError(null);
+
+ try {
+ const blob = await fetchTemplateBlob(template);
+ const file = new File([blob], template.fileName, {
+ type: template.contentType || blob.type || "application/octet-stream",
+ });
+ const inputs = await parseKeywordInputFile(file);
+ const nextCategoryWords = template.categoryName;
+ const rows = analyzeKeywordInputs(inputs, {
+ categoryWords: parseCategoryWords(nextCategoryWords),
+ });
+
+ startTransition(() => {
+ setManualSegmentationMode(false);
+ setCategoryWords(nextCategoryWords);
+ setKeywordRows(rows);
+ });
+ toast.success(`已加载模板“${template.categoryName}”并完成拆词`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "模板加载失败";
+ setKeywordError(message);
+ toast.error(message);
+ } finally {
+ setTemplateUsingId(null);
+ }
+ }
+
+ async function handleTemplateDownload(template: ProductPlanningTemplate) {
+ setTemplateDownloadingId(template.id);
+
+ try {
+ const blob = await fetchTemplateBlob(template);
+ downloadBlobFile(blob, template.fileName);
+ toast.success(`模板“${template.categoryName}”已开始下载`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "模板下载失败";
+ setTemplateError(message);
+ toast.error(message);
+ } finally {
+ setTemplateDownloadingId(null);
+ }
+ }
+
+ async function handleTemplateUpload() {
+ const file = templateUploadRef.current?.files?.[0];
+ if (!file) {
+ toast.error("请先选择要上传的模板文件");
+ return;
+ }
+
+ const categoryName = templateCategoryName.trim();
+ if (!categoryName) {
+ toast.error("请先填写模板名称");
+ return;
+ }
+
+ setTemplateUploading(true);
+
+ try {
+ const formData = new FormData();
+ formData.set("categoryName", categoryName);
+ formData.set("description", templateDescription.trim());
+ formData.set("file", file);
+
+ const response = await fetch("/api/product-planning/templates", {
+ method: "POST",
+ body: formData,
+ });
+ const payload = (await response.json()) as ProductPlanningTemplateMutationResponse;
+ if (!response.ok || !payload.success || !payload.template) {
+ throw new Error(payload.error || "模板上传失败");
+ }
+
+ setTemplateCategoryName("");
+ setTemplateDescription("");
+ if (templateUploadRef.current) {
+ templateUploadRef.current.value = "";
+ }
+ setTemplateError(null);
+ await loadTemplates();
+ toast.success(`模板“${payload.template.categoryName}”上传成功`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "模板上传失败";
+ setTemplateError(message);
+ toast.error(message);
+ } finally {
+ setTemplateUploading(false);
+ }
+ }
+
+ async function handleTemplateDelete(template: ProductPlanningTemplate) {
+ if (!window.confirm(`确认删除模板“${template.categoryName}”?`)) {
+ return;
+ }
+
+ setTemplateDeletingId(template.id);
+
+ try {
+ const response = await fetch(`/api/product-planning/templates/${encodeURIComponent(template.id)}`, {
+ method: "DELETE",
+ });
+ const payload = (await response.json()) as ProductPlanningTemplateMutationResponse;
+ if (!response.ok || !payload.success) {
+ throw new Error(payload.error || "模板删除失败");
+ }
+
+ if (templateUsingId === template.id) {
+ setTemplateUsingId(null);
+ }
+ setTemplateError(null);
+ await loadTemplates();
+ toast.success(`模板“${template.categoryName}”已删除`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "模板删除失败";
+ setTemplateError(message);
+ toast.error(message);
+ } finally {
+ setTemplateDeletingId(null);
+ }
+ }
+
+ function cancelBatchFetch() {
+ cancelFetchRef.current = true;
+ setFetchProgress((current) => ({
+ ...current,
+ running: false,
+ currentLabel: "已取消抓取",
+ }));
+ }
+
+ async function fetchRowsForItem(itemId: string, page: number) {
+ const [reviewResponse, qaResponse] = await Promise.all([
+ fetch(`/api/product-planning/taobao/reviews?id=${encodeURIComponent(itemId)}&page=${page}`, {
+ method: "GET",
+ }),
+ fetch("/api/product-planning/taobao/qa", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ itemId, page }),
+ }),
+ ]);
+
+ const reviewPayload = (await reviewResponse.json()) as ProductPlanningRowsResponse;
+ const qaPayload = (await qaResponse.json()) as ProductPlanningRowsResponse;
+
+ return {
+ reviewOk: reviewResponse.ok && reviewPayload.success,
+ qaOk: qaResponse.ok && qaPayload.success,
+ reviewPayload,
+ qaPayload,
+ };
+ }
+
+ async function handleBatchFetch() {
+ const productIds = productInput
+ .split(/\r?\n/)
+ .map((line) => extractTaobaoItemId(line))
+ .filter((item): item is string => Boolean(item));
+
+ if (productIds.length === 0) {
+ const message = "请输入淘宝链接或商品 ID";
+ setReviewError(message);
+ setQaError(message);
+ toast.error(message);
+ return;
+ }
+
+ if (productIds.length > 20) {
+ const message = "最多支持 20 个商品同时抓取";
+ setReviewError(message);
+ setQaError(message);
+ toast.error(message);
+ return;
+ }
+
+ const min = Math.max(Number.parseInt(minPage, 10) || 1, 1);
+ const max = Math.min(Number.parseInt(maxPage, 10) || 1, 20);
+ if (min > max) {
+ const message = "最小页数不能大于最大页数";
+ setReviewError(message);
+ setQaError(message);
+ toast.error(message);
+ return;
+ }
+
+ cancelFetchRef.current = false;
+ setReviewError(null);
+ setQaError(null);
+ setReviewMarkdown(null);
+ setQaMarkdown(null);
+
+ const totalSteps = productIds.length * (max - min + 1);
+ const nextReviewRows: TaobaoReviewRow[] = [];
+ const nextQaRows: TaobaoQaRow[] = [];
+ let failures = 0;
+ let completed = 0;
+
+ setFetchProgress({
+ running: true,
+ total: totalSteps,
+ completed: 0,
+ failed: 0,
+ currentLabel: "开始抓取数据...",
+ });
+
+ try {
+ for (const itemId of productIds) {
+ for (let page = min; page <= max; page += 1) {
+ if (cancelFetchRef.current) {
+ toast("已取消批量抓取");
+ break;
+ }
+
+ setFetchProgress((current) => ({
+ ...current,
+ currentLabel: `正在抓取商品 ${itemId} 第 ${page} 页`,
+ }));
+
+ try {
+ const result = await fetchRowsForItem(itemId, page);
+ if (result.reviewOk) {
+ nextReviewRows.push(...result.reviewPayload.rows);
+ } else {
+ failures += 1;
+ setReviewError(result.reviewPayload.error || "评价接口返回失败");
+ }
+
+ if (result.qaOk) {
+ nextQaRows.push(...result.qaPayload.rows);
+ } else {
+ failures += 1;
+ setQaError(result.qaPayload.error || "问大家接口返回失败");
+ }
+ } catch (error) {
+ failures += 2;
+ console.error("[ProductPlanning] batch fetch failed:", error);
+ setReviewError("部分评价数据抓取失败");
+ setQaError("部分问大家数据抓取失败");
+ } finally {
+ completed += 1;
+ setFetchProgress((current) => ({
+ ...current,
+ completed,
+ failed: failures,
+ }));
+ }
+ }
+
+ if (cancelFetchRef.current) break;
+ }
+
+ if (!cancelFetchRef.current) {
+ startTransition(() => {
+ setReviewRows(
+ uniqueBy(nextReviewRows, (row) =>
+ [row.itemId, row.date, row.content, row.appendComment || "", row.userId || ""].join(
+ "::"
+ )
+ )
+ );
+ setQaRows(
+ uniqueBy(nextQaRows, (row) =>
+ [
+ row.itemId,
+ row.question,
+ row.questionDetail || "",
+ row.answer,
+ row.answerUser || "",
+ row.answerDate || "",
+ row.isOfficial ? "1" : "0",
+ ].join("::")
+ )
+ );
+ });
+ toast.success("批量抓取完成");
+ }
+ } finally {
+ setFetchProgress((current) => ({
+ ...current,
+ running: false,
+ currentLabel: cancelFetchRef.current ? "已取消抓取" : "抓取完成",
+ }));
+ }
+ }
+
+ async function handleReviewImport(file: File) {
+ try {
+ const rows = await parseReviewImportFile(file);
+ setReviewError(null);
+ setReviewMarkdown(null);
+ startTransition(() => setReviewRows(rows));
+ setResultTab("review");
+ toast.success(`已导入 ${rows.length} 条评价数据`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "导入评价表失败";
+ setReviewError(message);
+ toast.error(message);
+ }
+ }
+
+ async function handleQaImport(file: File) {
+ try {
+ const rows = await parseQaImportFile(file);
+ setQaError(null);
+ setQaMarkdown(null);
+ startTransition(() => setQaRows(rows));
+ setResultTab("qa");
+ toast.success(`已导入 ${rows.length} 条问大家数据`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "导入问大家表失败";
+ setQaError(message);
+ toast.error(message);
+ }
+ }
+
+ async function handleAnalyze(kind: "review" | "qa") {
+ const rows = kind === "review" ? reviewRows : qaRows;
+ if (rows.length === 0) {
+ toast.error(kind === "review" ? "当前没有可分析的评价数据" : "当前没有可分析的问大家数据");
+ return;
+ }
+
+ if (kind === "review") {
+ setReviewAnalyzing(true);
+ setReviewMarkdown(null);
+ } else {
+ setQaAnalyzing(true);
+ setQaMarkdown(null);
+ }
+
+ try {
+ const response = await fetch("/api/product-planning/analyze", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ kind, rows }),
+ });
+ const payload = (await response.json()) as ProductPlanningAnalyzeResponse;
+ if (!response.ok || !payload.success || !payload.markdown) {
+ throw new Error(payload.error || "分析失败");
+ }
+
+ if (kind === "review") {
+ setReviewMarkdown(payload.markdown);
+ } else {
+ setQaMarkdown(payload.markdown);
+ }
+ toast.success(kind === "review" ? "评价分析完成" : "问大家分析完成");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "AI 分析失败";
+ if (kind === "review") {
+ setReviewError(message);
+ } else {
+ setQaError(message);
+ }
+ toast.error(message);
+ } finally {
+ if (kind === "review") {
+ setReviewAnalyzing(false);
+ } else {
+ setQaAnalyzing(false);
+ }
+ }
+ }
+
+ async function 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 (
+
+
+
+
+
+ 第一阶段
+ 商品策划工具
+
+
+
+
商品策划工具
+
+ 在当前项目内完成关键词拆词、评价/问大家抓取、本地表格导入和 AI 分析总结。
+
+
+
+
+
+
+
+ 分析快照
+
+ 将当前分析结果保存为会话内命名快照,便于回看、导出和对比。
+
+
+
已保存 {savedSnapshots.length} 条
+
+
+
+
+
setSnapshotSearch(event.target.value)}
+ placeholder="搜索快照名称、标题或内容"
+ value={snapshotSearch}
+ />
+
+ {([
+ ["all", "全部"],
+ ["review", "评价"],
+ ["qa", "问大家"],
+ ["competitor", "竞品"],
+ ] as Array<[SnapshotKindFilter, string]>).map(([value, label]) => (
+
+ ))}
+
+
+
+ {savedSnapshots.length === 0 ? (
+
+ 还没有保存任何分析快照。你可以在评价、问大家或竞品分析结果卡片里点击“保存快照”。
+
+ ) : filteredSnapshots.length === 0 ? (
+
+ 没有匹配当前筛选条件的快照。
+
+ ) : (
+
+
+ {filteredSnapshots.map((snapshot) => (
+
+
+
+
+
{snapshot.name}
+
+ {snapshot.kind === "review"
+ ? "评价分析"
+ : snapshot.kind === "qa"
+ ? "问大家分析"
+ : "竞品分析"}
+
+
+
{formatDateTime(snapshot.createdAt)}
+
+
{snapshot.title}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ )}
+
+
+
+
+
+ 关键词拆词
+ 评价 / 问大家分析
+ 拓展分析
+
+
+
+
+
+ 关键词拆词分析
+
+ 支持 txt、xls、xlsx 关键词文件,也支持直接导入手动分词结果。
+
+
+
+
+
+
手动分词模式
+
+ 开启后会跳过自动拆词,直接使用你上传表格中的“词语 / 标签分类”等结果。
+
+
+
+
+
+
+
+
+
+
+ {manualSegmentationMode
+ ? "手动分词模式支持 xls / xlsx / csv,至少包含“词语”和“标签分类”列。"
+ : "普通模式支持 txt / xls / xlsx / csv,并自动识别关键词、搜索人气、支付转化率等列。"}
+
+
+
+
+
+
+
+
+
+
+
+ {keywordRows.length > 0 ? (
+ 已生成 {keywordRows.length} 条结果
+ ) : null}
+
+
+
+
+
+
+ 分析结果
+ 按出现次数聚合展示拆词结果。
+
+
+ {keywordRows.length === 0 ? (
+
+ 还没有拆词结果。上传文件并点击“开始拆词”后会在这里展示。
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ 云端模板库
+
+ 共享常用品类关键词源数据,支持一键加载、下载,管理员可维护模板内容。
+
+
+
+ 共 {templates.length} 个模板
+
+
+
+
+
+
+
+ setTemplateSearch(event.target.value)}
+ placeholder="按模板名称、描述或文件名筛选"
+ value={templateSearch}
+ />
+
+
+ {canManageTemplates ? (
+
+
+
+
setTemplateCategoryName(event.target.value)}
+ placeholder="例如:换鞋凳、沙发床、书桌椅"
+ value={templateCategoryName}
+ />
+
+ 上传同名模板时会自动覆盖旧版本。
+
+
+
+
+
+ setTemplateDescription(event.target.value)}
+ placeholder="可选,用于标记来源或适用场景"
+ value={templateDescription}
+ />
+
+
+
+
+
+
+
+
+ 支持 txt / csv / xls / xlsx,作为关键词源数据模板共享给其他登录用户。
+
+
+
+
+ ) : null}
+
+
+
+ {filteredTemplates.length === 0 ? (
+
+ {templates.length === 0
+ ? "当前还没有可用模板。"
+ : "没有匹配当前搜索条件的模板。"}
+
+ ) : (
+
+ {filteredTemplates.map((template) => (
+
+
+
+
+
{template.categoryName}
+
+ 更新于 {formatDateTime(template.updatedAt)}
+
+
+
{formatFileSize(template.fileSize)}
+
+
+ {template.description || "未填写模板说明"}
+
+
+ 文件:{template.fileName}
+
+
+
+
+
+
+ {canManageTemplates ? (
+
+ ) : null}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ 抓取淘宝数据
+
+ 输入淘宝链接或商品 ID,系统将按页数范围顺序抓取评价与问大家数据。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {fetchProgress.total > 0 ? (
+
+
+ {fetchProgress.currentLabel || "准备中..."}
+
+ {fetchProgress.completed}/{fetchProgress.total}
+
+
+
+
+ 已失败请求数:{fetchProgress.failed}
+
+
+ ) : null}
+
+
+
+
+
+ 本地导入
+
+ 也可以直接导入本地评价表或问大家表,导入后可直接做 AI 分析。
+
+
+
+
+
+
导入评价表
+
+ 支持 csv / xls / xlsx,优先识别“评价内容”等核心列。
+
+
+
{
+ const file = event.target.files?.[0];
+ if (file) {
+ void handleReviewImport(file);
+ }
+ event.target.value = "";
+ }}
+ ref={reviewImportRef}
+ type="file"
+ />
+
+
+
+
+
导入问大家表
+
+ 支持 csv / xls / xlsx,优先识别“问题”和“回答”等核心列。
+
+
+
{
+ const file = event.target.files?.[0];
+ if (file) {
+ void handleQaImport(file);
+ }
+ event.target.value = "";
+ }}
+ ref={qaImportRef}
+ type="file"
+ />
+
+
+
+
+
+
+ 问大家 ({qaRows.length})
+ 评价 ({reviewRows.length})
+
+
+
+
+
+
+
+ 问大家数据
+ 支持抓取结果与本地导入结果统一分析。
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ markdown={qaMarkdown}
+ title="问大家分析结果"
+ />
+
+
+
+
+
+
+
+ 评价数据
+ 支持抓取结果与本地导入结果统一分析。
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ markdown={reviewMarkdown}
+ title="评价分析结果"
+ />
+
+
+
+
+
+
+
+ 竞品分析
+ 关键词洞察
+ ID 销量查询
+
+
+
+
+
+ 多平台竞品分析
+
+ 支持淘宝链接、京东链接,以及 `jd:商品ID` 形式的京东输入,系统会聚合商品详情、SKU 和销量做横向对比。
+
+
+
+
+
+
+
+
+
+
+ {competitorItems.length > 0 ? (
+ 已获取 {competitorItems.length} 个竞品
+ ) : null}
+
+
+
+
+
+
+ 竞品结果
+
+ 统一展示价格、销量、材质和 SKU 概览,也支持直接分析竞品主图。
+
+
+
+
+ void handleCompetitorImageAnalyze(params)}
+ onAnalyzeSku={(item) => void handleCompetitorSkuAnalyze(item)}
+ onViewAnalysis={handleViewCompetitorAnalysis}
+ rows={competitorItems}
+ />
+
+
+
+
+
+
+
+ 竞品图集
+
+ 展开后可查看主图与详情图,并逐张触发 AI 分析。
+
+
+
+
+
+
+ void handleCompetitorImageAnalyze(params)}
+ onViewAnalysis={handleViewCompetitorAnalysis}
+ />
+
+
+
+
+
+
+ >
+ }
+ markdown={activeCompetitorAnalysis?.markdown || null}
+ title={
+ activeCompetitorAnalysis
+ ? `${activeCompetitorAnalysis.kind === "sku" ? "SKU" : "竞品图片"}分析:${activeCompetitorAnalysis.title}`
+ : "竞品分析结果"
+ }
+ />
+
+
+
+
+
+
+
+ 关键词洞察
+
+ 调用淘宝搜索建议接口,输出推荐词和智能标签分组。
+
+
+
+
+
+
+ setInsightKeyword(event.target.value)}
+ placeholder="例如:换鞋凳"
+ value={insightKeyword}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ 搜索建议
+ 按接口返回的权重排序展示。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 标签分组
+ 保留热门标签与普通标签的分组信息。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ID 销量查询
+
+ 支持淘宝链接、京东链接,以及 `jd:商品ID` 形式的京东输入,系统会聚合销量、价格、店铺和材质信息。
+
+
+
+
+
+
+
+
+
+
+ {salesItems.length > 0 ? (
+ 已查询 {salesItems.length} 个商品
+ ) : null}
+
+
+
+
+
+
+ 销量结果
+ 展示近期销量、价格和材质摘要。
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/top-navbar.tsx b/components/top-navbar.tsx
index 52280d5ce..5bacf7009 100644
--- a/components/top-navbar.tsx
+++ b/components/top-navbar.tsx
@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X } from "lucide-react";
+import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X, BarChart3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes";
import { useState, useEffect, useRef } from "react";
@@ -57,6 +57,7 @@ type TopNavbarProps = {
tutorialVideos?: TutorialVideo[];
tutorialEnabled?: boolean;
rankingEnabled?: boolean;
+ productPlanningEnabled?: boolean;
agentEnabled?: boolean;
};
@@ -74,6 +75,7 @@ export function TopNavbar({
tutorialVideos = [],
tutorialEnabled = false,
rankingEnabled = true,
+ productPlanningEnabled = true,
agentEnabled = true,
}: TopNavbarProps) {
const router = useRouter();
@@ -141,6 +143,7 @@ export function TopNavbar({
if (trimmed === "智能体") return "智能";
if (trimmed === "无限画布") return "画布";
if (trimmed === "我的图册") return "图册";
+ if (trimmed === "商品策划") return "策划";
if (trimmed === "系统设置") return "设置";
if (trimmed === "工具箱") return "工具";
if (trimmed === "工具箱2") return "工具";
@@ -318,6 +321,21 @@ export function TopNavbar({
{getShortLabel("AI精选")}
+ {productPlanningEnabled && (
+ {
+ e.preventDefault();
+ window.location.href = "/product-planning";
+ }}
+ >
+
+ {getShortLabel("商品策划")}
+
+ )}
+
{/* 功能教程 */}
{tutorialEnabled && tutorialVideos.length > 0 && (
diff --git a/lib/db/migrations/0061_add_product_planning_enabled_site_setting.sql b/lib/db/migrations/0061_add_product_planning_enabled_site_setting.sql
new file mode 100644
index 000000000..cd9d9b0b2
--- /dev/null
+++ b/lib/db/migrations/0061_add_product_planning_enabled_site_setting.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "SiteSettings"
+ ADD COLUMN IF NOT EXISTS "productPlanningEnabled" boolean DEFAULT true NOT NULL;
diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json
index b58a8cee5..b07bd4c4a 100644
--- a/lib/db/migrations/meta/_journal.json
+++ b/lib/db/migrations/meta/_journal.json
@@ -330,6 +330,13 @@
"when": 1774074000000,
"tag": "0060_add_canvas_detail_agent_model",
"breakpoints": true
+ },
+ {
+ "idx": 47,
+ "version": "7",
+ "when": 1774483200000,
+ "tag": "0061_add_product_planning_enabled_site_setting",
+ "breakpoints": true
}
]
}
diff --git a/lib/db/queries.ts b/lib/db/queries.ts
index 3bbd667b9..794c83e30 100644
--- a/lib/db/queries.ts
+++ b/lib/db/queries.ts
@@ -4081,6 +4081,7 @@ export async function getSiteSettings() {
tutorialVideoUrl: null,
tutorialEnabled: false,
rankingEnabled: true,
+ productPlanningEnabled: true,
agentEnabled: true,
canvasDetailAgentModelId: null,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 27e14489b..92810b984 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -374,6 +374,7 @@ export const siteSettings = pgTable("SiteSettings", {
tutorialEnabled: boolean("tutorialEnabled").notNull().default(false), // 是否显示功能教程按钮
// 精选榜设置
rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮
+ productPlanningEnabled: boolean("productPlanningEnabled").notNull().default(true), // 是否显示并启用商品策划工具
// 无限画布设置
canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能
agentEnabled: boolean("agentEnabled").notNull().default(true), // 是否启用智能体功能
diff --git a/lib/product-planning/analyze.ts b/lib/product-planning/analyze.ts
new file mode 100644
index 000000000..1f3dd7296
--- /dev/null
+++ b/lib/product-planning/analyze.ts
@@ -0,0 +1,127 @@
+import type {
+ ProductPlanningAnalyzeRequest,
+ ProductPlanningSkuAnalyzeRequest,
+ TaobaoQaRow,
+ TaobaoReviewRow,
+} from "./types";
+import { truncateText } from "./utils";
+
+const MAX_ROWS = 120;
+
+function formatReviewRows(rows: TaobaoReviewRow[]) {
+ return rows
+ .slice(0, MAX_ROWS)
+ .map((row, index) =>
+ [
+ `### 评价 ${index + 1}`,
+ `- 商品ID:${row.itemId || "未知"}`,
+ `- 时间:${row.date || "未知"}`,
+ `- 用户:${row.userId || "未知"}`,
+ `- SKU:${row.sku || "未知"}`,
+ `- 内容:${truncateText(row.content, 220) || "无"}`,
+ row.appendComment ? `- 追评:${truncateText(row.appendComment, 180)}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n")
+ )
+ .join("\n\n");
+}
+
+function formatQaRows(rows: TaobaoQaRow[]) {
+ return rows
+ .slice(0, MAX_ROWS)
+ .map((row, index) =>
+ [
+ `### 问答 ${index + 1}`,
+ `- 商品ID:${row.itemId || "未知"}`,
+ `- 问题:${truncateText(row.question, 160) || "无"}`,
+ row.questionDetail ? `- 问题补充:${truncateText(row.questionDetail, 160)}` : "",
+ `- 回答:${truncateText(row.answer, 220) || "无"}`,
+ `- 回答用户:${row.answerUser || "未知"}`,
+ `- 回答时间:${row.answerDate || "未知"}`,
+ `- 官方回答:${row.isOfficial ? "是" : "否"}`,
+ ]
+ .filter(Boolean)
+ .join("\n")
+ )
+ .join("\n\n");
+}
+
+function formatSkuRows(request: ProductPlanningSkuAnalyzeRequest) {
+ return request.skus
+ .slice(0, MAX_ROWS)
+ .map((sku, index) =>
+ [
+ `### SKU ${index + 1}`,
+ `- 名称:${truncateText(sku.name, 180) || "无"}`,
+ typeof sku.price === "number" ? `- 价格:${sku.price}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n")
+ )
+ .join("\n\n");
+}
+
+export function buildProductPlanningAnalyzePrompt(
+ request: ProductPlanningAnalyzeRequest
+) {
+ if (request.kind === "review") {
+ return [
+ "# 淘宝评价数据分析",
+ "",
+ "请基于以下买家评价数据,输出一份适合商品策划与详情页优化使用的中文 Markdown 分析。",
+ "",
+ "输出要求:",
+ "1. 总结核心用户痛点",
+ "2. 提炼高频关注点和决策点",
+ "3. 按质量、做工、外观、功能、场景分别分析",
+ "4. 给出详情页文案与卖点强化建议",
+ "5. 如果数据中存在矛盾反馈,要单独指出",
+ "",
+ "评价数据:",
+ formatReviewRows(request.rows),
+ ].join("\n");
+ }
+
+ return [
+ "# 淘宝问大家数据分析",
+ "",
+ "请基于以下问大家数据,输出一份适合商品策划与详情页优化使用的中文 Markdown 分析。",
+ "",
+ "输出要求:",
+ "1. 总结用户最关心的问题",
+ "2. 提炼最影响转化的疑虑",
+ "3. 区分官方回答与普通回答的价值差异",
+ "4. 给出详情页必须补强的信息点",
+ "5. 给出客服 FAQ 建议",
+ "",
+ "问大家数据:",
+ formatQaRows(request.rows),
+ ].join("\n");
+}
+
+export function buildProductPlanningSkuAnalyzePrompt(
+ request: ProductPlanningSkuAnalyzeRequest
+) {
+ return [
+ "# 商品 SKU 卖点分析",
+ "",
+ "请基于以下 SKU 命名数据,输出一份适合商品策划与详情页优化使用的中文 Markdown 分析。",
+ "",
+ `平台:${request.platform === "jd" ? "京东" : "淘宝/天猫"}`,
+ `商品ID:${request.itemId}`,
+ request.title ? `商品标题:${request.title}` : "",
+ "",
+ "输出要求:",
+ "1. 提炼 SKU 命名中正在强调的核心卖点与差异点",
+ "2. 判断 SKU 维度是否清晰,是否存在信息堆砌或命名混乱",
+ "3. 总结当前 SKU 命名对转化的帮助与不足",
+ "4. 给出更利于用户理解和转化的 SKU 命名优化建议",
+ "5. 如果适合,补充可同步到主图/详情页/标题的卖点表达",
+ "",
+ "SKU 数据:",
+ formatSkuRows(request),
+ ]
+ .filter(Boolean)
+ .join("\n");
+}
diff --git a/lib/product-planning/config.ts b/lib/product-planning/config.ts
new file mode 100644
index 000000000..df32bd487
--- /dev/null
+++ b/lib/product-planning/config.ts
@@ -0,0 +1,59 @@
+export const PRODUCT_PLANNING_API_CONFIG_KEY = "product_planning_api_config";
+
+export type ProductPlanningApiConfig = {
+ oneboundBaseUrl: string;
+ taobaoApiKey: string;
+ taobaoApiSecret: string;
+ qaApiUrl: string;
+ qaToken: string;
+ saleApiUrl: string;
+ saleToken: string;
+};
+
+const DEFAULT_ONEBOUND_BASE_URL = "https://api-gw.onebound.cn";
+const DEFAULT_QA_API_URL = "http://119.91.237.2:5432/taobao_social_feed_aggregate";
+const DEFAULT_SALE_API_URL = "http://119.91.237.2:5432/taobao_item_month_sale";
+
+function normalizeUrl(value: unknown, fallback: string) {
+ const normalized = String(value || "").trim().replace(/\/+$/, "");
+ const normalizedFallback = String(fallback || "").trim().replace(/\/+$/, "");
+ return normalized || normalizedFallback;
+}
+
+function normalizeSecret(value: unknown, fallback = "") {
+ const normalized = String(value || "").trim();
+ const normalizedFallback = String(fallback || "").trim();
+ return normalized || normalizedFallback;
+}
+
+export function defaultProductPlanningApiConfig(): ProductPlanningApiConfig {
+ return {
+ oneboundBaseUrl: DEFAULT_ONEBOUND_BASE_URL,
+ taobaoApiKey: "",
+ taobaoApiSecret: "",
+ qaApiUrl: DEFAULT_QA_API_URL,
+ qaToken: "",
+ saleApiUrl: DEFAULT_SALE_API_URL,
+ saleToken: "",
+ };
+}
+
+export function normalizeProductPlanningApiConfig(
+ value: unknown,
+ fallback?: Partial
+): ProductPlanningApiConfig {
+ const raw =
+ value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+
+ return {
+ oneboundBaseUrl: normalizeUrl(raw.oneboundBaseUrl, fallback?.oneboundBaseUrl || DEFAULT_ONEBOUND_BASE_URL),
+ taobaoApiKey: normalizeSecret(raw.taobaoApiKey, fallback?.taobaoApiKey || ""),
+ taobaoApiSecret: normalizeSecret(raw.taobaoApiSecret, fallback?.taobaoApiSecret || ""),
+ qaApiUrl: normalizeUrl(raw.qaApiUrl, fallback?.qaApiUrl || DEFAULT_QA_API_URL),
+ qaToken: normalizeSecret(raw.qaToken, fallback?.qaToken || ""),
+ saleApiUrl: normalizeUrl(raw.saleApiUrl, fallback?.saleApiUrl || DEFAULT_SALE_API_URL),
+ saleToken: normalizeSecret(raw.saleToken, fallback?.saleToken || ""),
+ };
+}
diff --git a/lib/product-planning/export.ts b/lib/product-planning/export.ts
new file mode 100644
index 000000000..4dcd83933
--- /dev/null
+++ b/lib/product-planning/export.ts
@@ -0,0 +1,268 @@
+import type {
+ KeywordAnalysisRow,
+ ProductPlanningCompetitorAnalysisRecord,
+ ProductPlanningKeywordInsightGroup,
+ ProductPlanningKeywordInsightSuggestion,
+ ProductPlanningTaobaoItem,
+} from "./types";
+
+function formatPlatform(value: ProductPlanningTaobaoItem["platform"], isTmall: boolean) {
+ if (value === "jd") return "京东";
+ return isTmall ? "天猫" : "淘宝";
+}
+
+function formatPropertySummary(row: ProductPlanningTaobaoItem, limit = 10) {
+ return row.properties
+ .slice(0, limit)
+ .map((entry) => `${entry.name}:${entry.value}`)
+ .join(" | ");
+}
+
+function formatConversionRate(value?: number) {
+ if (typeof value !== "number") return "";
+ return `${(value * 100).toFixed(2)}%`;
+}
+
+function triggerBlobDownload(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);
+}
+
+export async function downloadKeywordAnalysisWorkbook(
+ rows: KeywordAnalysisRow[],
+ baseFileName = "商品策划工具_关键词分析"
+) {
+ const { Workbook } = await import("exceljs");
+
+ const workbook = new Workbook();
+ const sheet = workbook.addWorksheet("关键词拆词结果");
+ sheet.columns = [
+ { header: "词语", key: "keyword", width: 24 },
+ { header: "拆分片段", key: "segments", width: 28 },
+ { header: "标签分类", key: "category", width: 16 },
+ { header: "出现次数", key: "occurrenceCount", width: 12 },
+ { header: "搜索人气", key: "searchPopularity", width: 14 },
+ { header: "支付转化率", key: "conversionRate", width: 14 },
+ ];
+
+ sheet.getRow(1).font = { bold: true };
+ sheet.views = [{ state: "frozen", ySplit: 1 }];
+
+ for (const row of rows) {
+ sheet.addRow({
+ keyword: row.keyword,
+ segments: row.segments.join(" / "),
+ category: row.category,
+ occurrenceCount: row.occurrenceCount,
+ searchPopularity: row.searchPopularity ?? "",
+ conversionRate: formatConversionRate(row.conversionRate),
+ });
+ }
+
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ triggerBlobDownload(blob, `${baseFileName}_${new Date().toISOString().slice(0, 10)}.xlsx`);
+}
+
+async function downloadWorkbook(params: {
+ worksheetName: string;
+ columns: Array<{ header: string; key: string; width: number }>;
+ rows: Array>;
+ fileName: string;
+}) {
+ const { Workbook } = await import("exceljs");
+
+ const workbook = new Workbook();
+ const sheet = workbook.addWorksheet(params.worksheetName);
+ sheet.columns = params.columns;
+ sheet.getRow(1).font = { bold: true };
+ sheet.views = [{ state: "frozen", ySplit: 1 }];
+
+ for (const row of params.rows) {
+ sheet.addRow(row);
+ }
+
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ triggerBlobDownload(blob, `${params.fileName}_${new Date().toISOString().slice(0, 10)}.xlsx`);
+}
+
+export async function downloadCompetitorWorkbook(
+ rows: ProductPlanningTaobaoItem[],
+ baseFileName = "商品策划工具_竞品分析"
+) {
+ await downloadWorkbook({
+ worksheetName: "竞品分析",
+ fileName: baseFileName,
+ columns: [
+ { header: "平台", key: "platform", width: 12 },
+ { header: "商品ID", key: "itemId", width: 18 },
+ { header: "商品标题", key: "title", width: 40 },
+ { header: "品牌", key: "brand", width: 18 },
+ { header: "店铺", key: "shopName", width: 26 },
+ { header: "发货地", key: "location", width: 18 },
+ { header: "当前价格", key: "price", width: 14 },
+ { header: "划线价格", key: "originalPrice", width: 14 },
+ { header: "总销量", key: "totalSales", width: 14 },
+ { header: "近期销量", key: "recentSales", width: 14 },
+ { header: "材质", key: "material", width: 24 },
+ { header: "详细材质", key: "detailedMaterial", width: 36 },
+ { header: "主图数", key: "itemImageCount", width: 12 },
+ { header: "详情图数", key: "detailImageCount", width: 12 },
+ { header: "主图视频", key: "videoUrl", width: 36 },
+ { header: "参数预览", key: "propertySummary", width: 80 },
+ { header: "商品链接", key: "detailUrl", width: 48 },
+ ],
+ rows: rows.map((row) => ({
+ platform: formatPlatform(row.platform, row.isTmall),
+ itemId: row.itemId,
+ title: row.title,
+ brand: row.brand || "",
+ shopName: row.shopName || "",
+ location: row.location || "",
+ price: row.price ?? "",
+ originalPrice: row.originalPrice ?? "",
+ totalSales: row.totalSales ?? "",
+ recentSales: row.recentSales ?? "",
+ material: row.material || "",
+ detailedMaterial: row.detailedMaterial || "",
+ itemImageCount: row.itemImages.length,
+ detailImageCount: row.detailImages.length,
+ videoUrl: row.videoUrl || "",
+ propertySummary: formatPropertySummary(row),
+ detailUrl: row.detailUrl || "",
+ })),
+ });
+}
+
+export async function downloadSalesWorkbook(
+ rows: ProductPlanningTaobaoItem[],
+ baseFileName = "商品策划工具_ID销量查询"
+) {
+ await downloadWorkbook({
+ worksheetName: "ID销量查询",
+ fileName: baseFileName,
+ columns: [
+ { header: "平台", key: "platform", width: 12 },
+ { header: "商品ID", key: "itemId", width: 18 },
+ { header: "商品标题", key: "title", width: 40 },
+ { header: "近期销量", key: "recentSales", width: 14 },
+ { header: "当前价格", key: "price", width: 14 },
+ { header: "划线价格", key: "originalPrice", width: 14 },
+ { header: "品牌", key: "brand", width: 18 },
+ { header: "店铺", key: "shopName", width: 26 },
+ { header: "发货地", key: "location", width: 18 },
+ { header: "材质", key: "material", width: 22 },
+ { header: "详情图数", key: "detailImageCount", width: 12 },
+ { header: "参数预览", key: "propertySummary", width: 80 },
+ { header: "商品链接", key: "detailUrl", width: 48 },
+ ],
+ rows: rows.map((row) => ({
+ platform: formatPlatform(row.platform, row.isTmall),
+ itemId: row.itemId,
+ title: row.title,
+ recentSales: row.recentSales ?? "",
+ price: row.price ?? "",
+ originalPrice: row.originalPrice ?? "",
+ brand: row.brand || "",
+ shopName: row.shopName || "",
+ location: row.location || "",
+ material: row.material || "",
+ detailImageCount: row.detailImages.length,
+ propertySummary: formatPropertySummary(row),
+ detailUrl: row.detailUrl || "",
+ })),
+ });
+}
+
+export async function downloadKeywordInsightSuggestionsWorkbook(
+ rows: ProductPlanningKeywordInsightSuggestion[],
+ baseFileName = "商品策划工具_关键词洞察_搜索建议"
+) {
+ await downloadWorkbook({
+ worksheetName: "搜索建议",
+ fileName: baseFileName,
+ columns: [
+ { header: "排名", key: "rank", width: 10 },
+ { header: "关键词", key: "keyword", width: 28 },
+ { header: "权重", key: "weight", width: 16 },
+ ],
+ rows: rows.map((row) => ({
+ rank: row.rank,
+ keyword: row.keyword,
+ weight: row.weight,
+ })),
+ });
+}
+
+export async function downloadKeywordInsightGroupsWorkbook(
+ rows: ProductPlanningKeywordInsightGroup[],
+ baseFileName = "商品策划工具_关键词洞察_标签分组"
+) {
+ await downloadWorkbook({
+ worksheetName: "标签分组",
+ fileName: baseFileName,
+ columns: [
+ { header: "组序号", key: "index", width: 10 },
+ { header: "分组类型", key: "type", width: 20 },
+ { header: "标签名称", key: "title", width: 28 },
+ { header: "标签热度", key: "tagType", width: 12 },
+ ],
+ rows: rows.flatMap((group) =>
+ group.tags.map((tag) => ({
+ index: group.index,
+ type: group.type,
+ title: tag.title,
+ tagType: tag.type === "hot" ? "热门" : "普通",
+ }))
+ ),
+ });
+}
+
+export function downloadMarkdownFile(
+ markdown: string,
+ baseFileName = "商品策划工具_分析结果"
+) {
+ const blob = new Blob([markdown], {
+ type: "text/markdown;charset=utf-8",
+ });
+ triggerBlobDownload(blob, `${baseFileName}_${new Date().toISOString().slice(0, 10)}.md`);
+}
+
+export async function downloadCompetitorAnalysisWorkbook(
+ records: Record,
+ baseFileName = "商品策划工具_竞品分析结果"
+) {
+ const rows = Object.entries(records)
+ .map(([key, value]) => ({
+ key,
+ kind: value.kind === "sku" ? "SKU卖点分析" : "图片分析",
+ title: value.title,
+ updatedAt: value.updatedAt,
+ markdown: value.markdown,
+ }))
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
+
+ await downloadWorkbook({
+ worksheetName: "AI分析结果",
+ fileName: baseFileName,
+ columns: [
+ { header: "结果键", key: "key", width: 26 },
+ { header: "分析类型", key: "kind", width: 16 },
+ { header: "标题", key: "title", width: 42 },
+ { header: "更新时间", key: "updatedAt", width: 24 },
+ { header: "Markdown结果", key: "markdown", width: 120 },
+ ],
+ rows,
+ });
+}
diff --git a/lib/product-planning/file-parsers.ts b/lib/product-planning/file-parsers.ts
new file mode 100644
index 000000000..67b2233ae
--- /dev/null
+++ b/lib/product-planning/file-parsers.ts
@@ -0,0 +1,311 @@
+import * as XLSX from "xlsx";
+import Papa from "papaparse";
+import type {
+ KeywordAnalysisRow,
+ KeywordInputRow,
+ TaobaoQaRow,
+ TaobaoReviewRow,
+} from "./types";
+import {
+ normalizeHeader,
+ normalizeText,
+ parseInteger,
+ parseOfficialFlag,
+ parseRangeValue,
+ splitUrlCell,
+} from "./utils";
+import { aggregateManualKeywordRows } from "./keyword-segmentation";
+
+type SpreadsheetRecord = Record;
+
+const KEYWORD_ALIASES = {
+ keyword: ["关键词", "搜索词", "搜索关键词", "keyword", "keywords", "query"],
+ searchPopularity: ["搜索人气", "搜索量", "人气", "searchvolume", "popularity", "volume"],
+ conversionRate: ["支付转化率", "转化率", "conversionrate", "conversion", "cvr"],
+};
+
+const MANUAL_ALIASES = {
+ keyword: ["词语", "关键词", "搜索词", "word", "keyword"],
+ category: ["标签分类", "分类", "category", "tagcategory"],
+ searchPopularity: KEYWORD_ALIASES.searchPopularity,
+ conversionRate: KEYWORD_ALIASES.conversionRate,
+ occurrenceCount: ["出现次数", "次数", "count", "occurrencecount"],
+};
+
+const REVIEW_ALIASES = {
+ itemId: ["宝贝id", "商品id", "itemid", "numiid"],
+ date: ["评论时间", "评价时间", "日期", "时间", "date"],
+ content: ["评价内容", "评论内容", "内容", "content", "ratecontent"],
+ appendComment: ["追评", "追加评价", "appendcomment", "addfeedback"],
+ sku: ["评价所属sku", "sku", "规格", "skuinfo", "auctionsku"],
+ userId: ["买家id", "用户id", "userid", "displayusernick"],
+ images: ["评论图片", "图片", "pics", "images"],
+ videoUrls: ["评价视频", "视频", "videourls", "videolist"],
+};
+
+const QA_ALIASES = {
+ itemId: ["商品id", "宝贝id", "itemid"],
+ question: ["问题", "提问", "question"],
+ questionDetail: ["问题补充", "问题详情", "questiondetail", "content"],
+ answer: ["回答", "答案", "answer"],
+ answerUser: ["回答用户", "用户", "answeruser"],
+ answerDate: ["回答时间", "时间", "answerdate", "date"],
+ isOfficial: ["是否官方", "官方回答", "isofficial", "official"],
+};
+
+function matchColumn(record: SpreadsheetRecord, aliases: string[]) {
+ const aliasSet = new Set(aliases.map((item) => normalizeHeader(item)));
+ return Object.keys(record).find((key) => aliasSet.has(normalizeHeader(key))) || null;
+}
+
+async function readSpreadsheetRecords(file: File) {
+ const extension = file.name.toLowerCase().split(".").pop() || "";
+ if (extension === "csv") {
+ const text = await file.text();
+ const parsed = Papa.parse(text, {
+ header: true,
+ skipEmptyLines: true,
+ transformHeader: (header) => normalizeText(header),
+ });
+
+ if (parsed.errors.length > 0) {
+ throw new Error(`CSV 解析失败:${parsed.errors[0]?.message || "未知错误"}`);
+ }
+
+ return parsed.data;
+ }
+
+ const buffer = await file.arrayBuffer();
+ const workbook = XLSX.read(buffer, { type: "array" });
+ const sheetName = workbook.SheetNames[0];
+ if (!sheetName) {
+ throw new Error("表格中没有可读取的工作表");
+ }
+
+ const worksheet = workbook.Sheets[sheetName];
+ return XLSX.utils.sheet_to_json(worksheet, {
+ defval: "",
+ });
+}
+
+export async function parseKeywordInputFile(file: File) {
+ const extension = file.name.toLowerCase().split(".").pop() || "";
+ if (extension === "txt") {
+ const text = await file.text();
+ const rows = text
+ .split(/\r?\n/)
+ .map((line) => normalizeText(line))
+ .filter(Boolean)
+ .map((keyword) => ({ keyword }));
+
+ if (rows.length === 0) {
+ throw new Error("关键词文件中没有有效内容");
+ }
+
+ return rows;
+ }
+
+ if (!["xls", "xlsx", "csv"].includes(extension)) {
+ throw new Error("仅支持 txt、xls、xlsx、csv 文件");
+ }
+
+ const records = await readSpreadsheetRecords(file);
+ if (records.length === 0) {
+ throw new Error("关键词表中没有有效数据");
+ }
+
+ const firstRecord = records[0];
+ const keywordColumn =
+ matchColumn(firstRecord, KEYWORD_ALIASES.keyword) || Object.keys(firstRecord)[0] || null;
+ if (!keywordColumn) {
+ throw new Error("未找到关键词列");
+ }
+
+ const searchPopularityColumn = matchColumn(firstRecord, KEYWORD_ALIASES.searchPopularity);
+ const conversionRateColumn = matchColumn(firstRecord, KEYWORD_ALIASES.conversionRate);
+
+ const rows = records
+ .map((record) => {
+ const keyword = normalizeText(record[keywordColumn]);
+ if (!keyword) return null;
+
+ const popularity = searchPopularityColumn
+ ? parseRangeValue(record[searchPopularityColumn])
+ : null;
+ const conversion = conversionRateColumn
+ ? parseRangeValue(record[conversionRateColumn])
+ : null;
+
+ return {
+ keyword,
+ searchPopularity: typeof popularity === "number" ? popularity : undefined,
+ conversionRate:
+ typeof conversion === "number"
+ ? conversion > 1
+ ? Number((conversion / 100).toFixed(4))
+ : Number(conversion.toFixed(4))
+ : undefined,
+ };
+ })
+ .filter((item): item is KeywordInputRow => Boolean(item));
+
+ if (rows.length === 0) {
+ throw new Error("未从关键词文件中提取到有效关键词");
+ }
+
+ return rows;
+}
+
+export async function parseManualSegmentationFile(file: File) {
+ const extension = file.name.toLowerCase().split(".").pop() || "";
+ if (!["xls", "xlsx", "csv"].includes(extension)) {
+ throw new Error("手动分词文件仅支持 xls、xlsx、csv");
+ }
+
+ const records = await readSpreadsheetRecords(file);
+ if (records.length === 0) {
+ throw new Error("手动分词表中没有有效数据");
+ }
+
+ const firstRecord = records[0];
+ const keywordColumn = matchColumn(firstRecord, MANUAL_ALIASES.keyword);
+ const categoryColumn = matchColumn(firstRecord, MANUAL_ALIASES.category);
+ if (!keywordColumn || !categoryColumn) {
+ throw new Error("手动分词表缺少核心列:词语、标签分类");
+ }
+
+ const searchPopularityColumn = matchColumn(firstRecord, MANUAL_ALIASES.searchPopularity);
+ const conversionRateColumn = matchColumn(firstRecord, MANUAL_ALIASES.conversionRate);
+ const occurrenceCountColumn = matchColumn(firstRecord, MANUAL_ALIASES.occurrenceCount);
+
+ const rows = records
+ .map((record) => {
+ const keyword = normalizeText(record[keywordColumn]);
+ const category = normalizeText(record[categoryColumn]);
+ if (!keyword || !category) return null;
+
+ const searchPopularity = searchPopularityColumn
+ ? parseRangeValue(record[searchPopularityColumn])
+ : null;
+ const conversionRate = conversionRateColumn
+ ? parseRangeValue(record[conversionRateColumn])
+ : null;
+ const occurrenceCount = occurrenceCountColumn
+ ? Math.max(parseInteger(record[occurrenceCountColumn], 1), 1)
+ : 1;
+
+ return {
+ keyword,
+ segments: [keyword],
+ category,
+ occurrenceCount,
+ searchPopularity: typeof searchPopularity === "number" ? searchPopularity : undefined,
+ conversionRate:
+ typeof conversionRate === "number"
+ ? conversionRate > 1
+ ? Number((conversionRate / 100).toFixed(4))
+ : Number(conversionRate.toFixed(4))
+ : undefined,
+ };
+ })
+ .filter((item): item is KeywordAnalysisRow => Boolean(item));
+
+ if (rows.length === 0) {
+ throw new Error("未从手动分词表中提取到有效数据");
+ }
+
+ return aggregateManualKeywordRows(rows);
+}
+
+export async function parseReviewImportFile(file: File) {
+ const records = await readSpreadsheetRecords(file);
+ if (records.length === 0) {
+ throw new Error("评价表中没有有效数据");
+ }
+
+ const firstRecord = records[0];
+ const contentColumn = matchColumn(firstRecord, REVIEW_ALIASES.content);
+ if (!contentColumn) {
+ throw new Error("评价表缺少核心列:评价内容");
+ }
+
+ const itemIdColumn = matchColumn(firstRecord, REVIEW_ALIASES.itemId);
+ const dateColumn = matchColumn(firstRecord, REVIEW_ALIASES.date);
+ const appendColumn = matchColumn(firstRecord, REVIEW_ALIASES.appendComment);
+ const skuColumn = matchColumn(firstRecord, REVIEW_ALIASES.sku);
+ const userIdColumn = matchColumn(firstRecord, REVIEW_ALIASES.userId);
+ const imagesColumn = matchColumn(firstRecord, REVIEW_ALIASES.images);
+ const videoUrlsColumn = matchColumn(firstRecord, REVIEW_ALIASES.videoUrls);
+
+ const rows = records
+ .map((record) => {
+ const content = normalizeText(record[contentColumn]);
+ if (!content) return null;
+
+ return {
+ itemId: itemIdColumn ? normalizeText(record[itemIdColumn]) : "",
+ date: dateColumn ? normalizeText(record[dateColumn]) : "",
+ content,
+ appendComment: appendColumn ? normalizeText(record[appendColumn]) || undefined : undefined,
+ sku: skuColumn ? normalizeText(record[skuColumn]) || undefined : undefined,
+ userId: userIdColumn ? normalizeText(record[userIdColumn]) || undefined : undefined,
+ images: imagesColumn ? splitUrlCell(record[imagesColumn]) : [],
+ videoUrls: videoUrlsColumn ? splitUrlCell(record[videoUrlsColumn]) : [],
+ };
+ })
+ .filter((item): item is TaobaoReviewRow => Boolean(item));
+
+ if (rows.length === 0) {
+ throw new Error("未从评价表中提取到有效评价数据");
+ }
+
+ return rows;
+}
+
+export async function parseQaImportFile(file: File) {
+ const records = await readSpreadsheetRecords(file);
+ if (records.length === 0) {
+ throw new Error("问大家表中没有有效数据");
+ }
+
+ const firstRecord = records[0];
+ const questionColumn = matchColumn(firstRecord, QA_ALIASES.question);
+ const answerColumn = matchColumn(firstRecord, QA_ALIASES.answer);
+ if (!questionColumn || !answerColumn) {
+ throw new Error("问大家表缺少核心列:问题、回答");
+ }
+
+ const itemIdColumn = matchColumn(firstRecord, QA_ALIASES.itemId);
+ const detailColumn = matchColumn(firstRecord, QA_ALIASES.questionDetail);
+ const answerUserColumn = matchColumn(firstRecord, QA_ALIASES.answerUser);
+ const answerDateColumn = matchColumn(firstRecord, QA_ALIASES.answerDate);
+ const officialColumn = matchColumn(firstRecord, QA_ALIASES.isOfficial);
+
+ const rows = records
+ .map((record) => {
+ const question = normalizeText(record[questionColumn]);
+ const answer = normalizeText(record[answerColumn]);
+ if (!question || !answer) return null;
+
+ return {
+ itemId: itemIdColumn ? normalizeText(record[itemIdColumn]) : "",
+ question,
+ questionDetail: detailColumn ? normalizeText(record[detailColumn]) || undefined : undefined,
+ answer,
+ answerUser: answerUserColumn
+ ? normalizeText(record[answerUserColumn]) || undefined
+ : undefined,
+ answerDate: answerDateColumn
+ ? normalizeText(record[answerDateColumn]) || undefined
+ : undefined,
+ isOfficial: officialColumn ? parseOfficialFlag(record[officialColumn]) : false,
+ };
+ })
+ .filter((item): item is TaobaoQaRow => Boolean(item));
+
+ if (rows.length === 0) {
+ throw new Error("未从问大家表中提取到有效数据");
+ }
+
+ return rows;
+}
diff --git a/lib/product-planning/keyword-segmentation.ts b/lib/product-planning/keyword-segmentation.ts
new file mode 100644
index 000000000..a6983e879
--- /dev/null
+++ b/lib/product-planning/keyword-segmentation.ts
@@ -0,0 +1,406 @@
+import type { KeywordAnalysisRow, KeywordInputRow } from "./types";
+import { normalizeText } from "./utils";
+
+const PRODUCT_TERMS = [
+ "换鞋凳",
+ "换鞋椅",
+ "穿鞋凳",
+ "穿鞋椅",
+ "储物凳",
+ "收纳凳",
+ "鞋柜",
+ "鞋架",
+ "鞋凳",
+ "沙发",
+ "茶几",
+ "电视柜",
+ "书桌",
+ "衣柜",
+ "床头柜",
+ "餐桌",
+ "椅子",
+ "书架",
+ "置物架",
+ "电脑桌",
+ "梳妆台",
+ "床垫",
+ "床架",
+ "凳子",
+ "桌子",
+ "衣架",
+ "挂钩",
+ "热敷包",
+ "盐袋",
+ "拖鞋",
+ "凉拖",
+ "棉拖",
+ "人字拖",
+ "耳环",
+ "项链",
+ "戒指",
+ "手链",
+ "白酒",
+ "红酒",
+];
+
+const MATERIAL_TERMS = [
+ "实木",
+ "木质",
+ "板式",
+ "布艺",
+ "皮质",
+ "金属",
+ "不锈钢",
+ "铁艺",
+ "藤编",
+ "塑料",
+ "大理石",
+ "玻璃",
+ "硅胶",
+ "棉麻",
+ "羊毛",
+ "陶瓷",
+ "亚克力",
+ "牛皮",
+ "皮革",
+ "绒布",
+ "铝合金",
+ "abs",
+ "pvc",
+];
+
+const AUDIENCE_TERMS = [
+ "儿童",
+ "成人",
+ "男士",
+ "女士",
+ "学生",
+ "老人",
+ "孕妇",
+ "婴儿",
+ "宝宝",
+ "青年",
+ "中年",
+ "老年",
+ "情侣",
+ "新生儿",
+];
+
+const SCENARIO_TERMS = [
+ "家用",
+ "办公",
+ "客厅",
+ "卧室",
+ "厨房",
+ "浴室",
+ "阳台",
+ "书房",
+ "门口",
+ "玄关",
+ "入户",
+ "户外",
+ "旅行",
+ "露营",
+ "车载",
+ "宿舍",
+];
+
+const ACTION_TERMS = [
+ "换鞋",
+ "收纳",
+ "储物",
+ "折叠",
+ "防滑",
+ "防水",
+ "保暖",
+ "透气",
+ "静音",
+ "可折叠",
+ "可拆洗",
+ "可移动",
+ "防臭",
+ "耐磨",
+ "加厚",
+ "加宽",
+ "加深",
+ "大容量",
+];
+
+const ATTRIBUTE_TERMS = [
+ "简约",
+ "现代",
+ "北欧",
+ "中式",
+ "复古",
+ "轻奢",
+ "ins",
+ "新款",
+ "小户型",
+ "多功能",
+ "可坐",
+ "组合",
+ "一体",
+ "年份",
+ "2024",
+ "2025",
+ "2026",
+ "2027",
+ "春夏",
+ "秋冬",
+ "加长",
+ "加大",
+ "双层",
+ "三层",
+];
+
+const BASE_DICTIONARY = Array.from(
+ new Set([
+ ...PRODUCT_TERMS,
+ ...MATERIAL_TERMS,
+ ...AUDIENCE_TERMS,
+ ...SCENARIO_TERMS,
+ ...ACTION_TERMS,
+ ...ATTRIBUTE_TERMS,
+ ])
+).sort((left, right) => right.length - left.length);
+
+const YEAR_PATTERN = /^(19\d{2}|20\d{2}|21\d{2})(?:[年款版新])?/;
+const ALPHANUMERIC_PATTERN = /^[a-z0-9&-]+/i;
+
+function isSeparator(char: string) {
+ return /[\s,,.。/\\|+_\-()[\]{}]/.test(char);
+}
+
+function splitUnknownChunk(chunk: string) {
+ const text = normalizeText(chunk);
+ if (!text) return [] as string[];
+ if (text.length <= 4) return [text];
+
+ const result: string[] = [];
+ for (let index = 0; index < text.length; index += 2) {
+ const next = text.slice(index, index + 2);
+ if (!next) continue;
+ if (next.length === 1 && result.length > 0) {
+ result[result.length - 1] += next;
+ continue;
+ }
+ result.push(next);
+ }
+ return result;
+}
+
+function getDictionary(categoryWords: string[]) {
+ const normalizedCategoryWords = Array.from(
+ new Set(categoryWords.map((item) => normalizeText(item)).filter(Boolean))
+ ).sort((left, right) => right.length - left.length);
+
+ const dictionary = Array.from(
+ new Set([...normalizedCategoryWords, ...BASE_DICTIONARY])
+ ).sort((left, right) => right.length - left.length);
+
+ return {
+ normalizedCategoryWords,
+ dictionary,
+ };
+}
+
+function hasKnownMatchAt(text: string, start: number, dictionary: string[]) {
+ const remaining = text.slice(start);
+ if (YEAR_PATTERN.test(remaining) || ALPHANUMERIC_PATTERN.test(remaining)) {
+ return true;
+ }
+
+ return dictionary.some((token) => remaining.startsWith(token));
+}
+
+function matchLongestToken(text: string, start: number, dictionary: string[]) {
+ const remaining = text.slice(start);
+ const yearMatch = remaining.match(YEAR_PATTERN);
+ if (yearMatch?.[0]) return yearMatch[0];
+
+ const alphanumericMatch = remaining.match(ALPHANUMERIC_PATTERN);
+ if (alphanumericMatch?.[0]) return alphanumericMatch[0];
+
+ return dictionary.find((token) => remaining.startsWith(token)) || "";
+}
+
+export function segmentKeyword(keyword: string, categoryWords: string[] = []) {
+ const source = normalizeText(keyword).replace(/\s+/g, "");
+ if (!source) return [] as string[];
+
+ const { dictionary } = getDictionary(categoryWords);
+ const tokens: string[] = [];
+ let index = 0;
+
+ while (index < source.length) {
+ const char = source[index];
+ if (isSeparator(char)) {
+ index += 1;
+ continue;
+ }
+
+ const matchedToken = matchLongestToken(source, index, dictionary);
+ if (matchedToken) {
+ tokens.push(matchedToken);
+ index += matchedToken.length;
+ continue;
+ }
+
+ let end = index + 1;
+ while (end < source.length) {
+ if (isSeparator(source[end]) || hasKnownMatchAt(source, end, dictionary)) {
+ break;
+ }
+ end += 1;
+ }
+
+ const chunk = source.slice(index, end);
+ tokens.push(...splitUnknownChunk(chunk));
+ index = end;
+ }
+
+ return tokens.filter(Boolean);
+}
+
+export function inferKeywordCategory(keyword: string, categoryWords: string[] = []) {
+ const value = normalizeText(keyword).toLowerCase();
+ const categorySet = new Set(categoryWords.map((item) => normalizeText(item).toLowerCase()));
+
+ if (categorySet.has(value)) return "品类大词";
+ if (MATERIAL_TERMS.some((item) => item.toLowerCase() === value)) return "材质词";
+ if (AUDIENCE_TERMS.some((item) => item.toLowerCase() === value)) return "人群词";
+ if (SCENARIO_TERMS.some((item) => item.toLowerCase() === value)) return "场景词";
+ if (ACTION_TERMS.some((item) => item.toLowerCase() === value)) return "需求词";
+ if (ATTRIBUTE_TERMS.some((item) => item.toLowerCase() === value)) return "属性词";
+ if (YEAR_PATTERN.test(value) || ALPHANUMERIC_PATTERN.test(value)) return "属性词";
+ return "未分类";
+}
+
+export function analyzeKeywordInputs(
+ inputs: KeywordInputRow[],
+ options?: { categoryWords?: string[] }
+) {
+ const categoryWords = options?.categoryWords || [];
+ const aggregates = new Map<
+ string,
+ {
+ occurrenceCount: number;
+ popularityTotal: number;
+ popularityCount: number;
+ conversionTotal: number;
+ conversionCount: number;
+ }
+ >();
+
+ for (const input of inputs) {
+ const keyword = normalizeText(input.keyword);
+ if (!keyword) continue;
+
+ const uniqueTokens = new Set(segmentKeyword(keyword, categoryWords));
+ for (const token of uniqueTokens) {
+ const entry = aggregates.get(token) || {
+ occurrenceCount: 0,
+ popularityTotal: 0,
+ popularityCount: 0,
+ conversionTotal: 0,
+ conversionCount: 0,
+ };
+
+ entry.occurrenceCount += 1;
+ if (typeof input.searchPopularity === "number") {
+ entry.popularityTotal += input.searchPopularity;
+ entry.popularityCount += 1;
+ }
+ if (typeof input.conversionRate === "number") {
+ entry.conversionTotal += input.conversionRate;
+ entry.conversionCount += 1;
+ }
+
+ aggregates.set(token, entry);
+ }
+ }
+
+ const rows: KeywordAnalysisRow[] = Array.from(aggregates.entries()).map(([keyword, stats]) => ({
+ keyword,
+ segments: [keyword],
+ category: inferKeywordCategory(keyword, categoryWords),
+ occurrenceCount: stats.occurrenceCount,
+ searchPopularity:
+ stats.popularityCount > 0
+ ? Number((stats.popularityTotal / stats.popularityCount).toFixed(2))
+ : undefined,
+ conversionRate:
+ stats.conversionCount > 0
+ ? Number((stats.conversionTotal / stats.conversionCount).toFixed(4))
+ : undefined,
+ }));
+
+ return rows.sort((left, right) => {
+ if (right.occurrenceCount !== left.occurrenceCount) {
+ return right.occurrenceCount - left.occurrenceCount;
+ }
+ return left.keyword.localeCompare(right.keyword, "zh-CN");
+ });
+}
+
+export function aggregateManualKeywordRows(rows: KeywordAnalysisRow[]) {
+ const aggregates = new Map();
+
+ for (const row of rows) {
+ const keyword = normalizeText(row.keyword);
+ if (!keyword) continue;
+
+ const existing = aggregates.get(keyword);
+ if (!existing) {
+ aggregates.set(keyword, {
+ keyword,
+ segments: row.segments.length > 0 ? row.segments : [keyword],
+ category: row.category || "未分类",
+ occurrenceCount: Math.max(row.occurrenceCount, 1),
+ searchPopularity: row.searchPopularity,
+ conversionRate: row.conversionRate,
+ });
+ continue;
+ }
+
+ const totalCount = existing.occurrenceCount + Math.max(row.occurrenceCount, 1);
+ const popularityValues = [existing.searchPopularity, row.searchPopularity].filter(
+ (item): item is number => typeof item === "number"
+ );
+ const conversionValues = [existing.conversionRate, row.conversionRate].filter(
+ (item): item is number => typeof item === "number"
+ );
+
+ aggregates.set(keyword, {
+ keyword,
+ segments: existing.segments.length > 0 ? existing.segments : [keyword],
+ category: existing.category !== "未分类" ? existing.category : row.category || "未分类",
+ occurrenceCount: totalCount,
+ searchPopularity:
+ popularityValues.length > 0
+ ? Number(
+ (
+ popularityValues.reduce((sum, value) => sum + value, 0) /
+ popularityValues.length
+ ).toFixed(2)
+ )
+ : undefined,
+ conversionRate:
+ conversionValues.length > 0
+ ? Number(
+ (
+ conversionValues.reduce((sum, value) => sum + value, 0) /
+ conversionValues.length
+ ).toFixed(4)
+ )
+ : undefined,
+ });
+ }
+
+ return Array.from(aggregates.values()).sort((left, right) => {
+ if (right.occurrenceCount !== left.occurrenceCount) {
+ return right.occurrenceCount - left.occurrenceCount;
+ }
+ return left.keyword.localeCompare(right.keyword, "zh-CN");
+ });
+}
diff --git a/lib/product-planning/page-auth.ts b/lib/product-planning/page-auth.ts
new file mode 100644
index 000000000..f4d83dde1
--- /dev/null
+++ b/lib/product-planning/page-auth.ts
@@ -0,0 +1,31 @@
+import { cookies } from "next/headers";
+import { decode } from "next-auth/jwt";
+
+export async function getProductPlanningPageToken() {
+ const cookieStore = await cookies();
+ const secureCookieName = "__Secure-authjs.session-token";
+ const regularCookieName = "authjs.session-token";
+
+ const rawToken =
+ cookieStore.get(secureCookieName)?.value ||
+ cookieStore.get(regularCookieName)?.value ||
+ null;
+
+ if (!rawToken || !process.env.AUTH_SECRET) {
+ return null;
+ }
+
+ const salt = rawToken === cookieStore.get(secureCookieName)?.value
+ ? secureCookieName
+ : regularCookieName;
+
+ try {
+ return await decode({
+ token: rawToken,
+ secret: process.env.AUTH_SECRET,
+ salt,
+ });
+ } catch {
+ return null;
+ }
+}
diff --git a/lib/product-planning/server-auth.ts b/lib/product-planning/server-auth.ts
new file mode 100644
index 000000000..ef0ec26c6
--- /dev/null
+++ b/lib/product-planning/server-auth.ts
@@ -0,0 +1,13 @@
+import type { NextRequest } from "next/server";
+import { getToken } from "next-auth/jwt";
+
+export async function getProductPlanningToken(request: NextRequest) {
+ const isSecure =
+ process.env.AUTH_URL?.startsWith("https://") || request.nextUrl.protocol === "https:";
+
+ return getToken({
+ req: request,
+ secret: process.env.AUTH_SECRET,
+ secureCookie: isSecure,
+ });
+}
diff --git a/lib/product-planning/server-config.ts b/lib/product-planning/server-config.ts
new file mode 100644
index 000000000..687e7ef78
--- /dev/null
+++ b/lib/product-planning/server-config.ts
@@ -0,0 +1,18 @@
+import "server-only";
+
+import { getSystemSetting } from "@/lib/db/queries";
+import {
+ normalizeProductPlanningApiConfig,
+ PRODUCT_PLANNING_API_CONFIG_KEY,
+} from "./config";
+
+export async function getResolvedProductPlanningApiConfig() {
+ const setting = await getSystemSetting({ key: PRODUCT_PLANNING_API_CONFIG_KEY }).catch(() => null);
+
+ return normalizeProductPlanningApiConfig(setting?.value, {
+ taobaoApiKey: process.env.TAOBAO_API_KEY,
+ taobaoApiSecret: process.env.TAOBAO_API_SECRET,
+ qaToken: process.env.PRODUCT_PLANNING_QA_TOKEN,
+ saleToken: process.env.TAOBAO_SALE_API_TOKEN,
+ });
+}
diff --git a/lib/product-planning/server-feature.ts b/lib/product-planning/server-feature.ts
new file mode 100644
index 000000000..1c9d52242
--- /dev/null
+++ b/lib/product-planning/server-feature.ts
@@ -0,0 +1,10 @@
+import "server-only";
+
+import { getSiteSettings } from "@/lib/db/queries";
+
+export const PRODUCT_PLANNING_DISABLED_MESSAGE = "商品策划功能已关闭,请联系管理员";
+
+export async function isProductPlanningEnabled() {
+ const settings = await getSiteSettings().catch(() => null);
+ return (settings as { productPlanningEnabled?: boolean } | null)?.productPlanningEnabled !== false;
+}
diff --git a/lib/product-planning/taobao.ts b/lib/product-planning/taobao.ts
new file mode 100644
index 000000000..a9d0420c6
--- /dev/null
+++ b/lib/product-planning/taobao.ts
@@ -0,0 +1,305 @@
+import type {
+ ProductPlanningPropertyRow,
+ ProductPlanningSkuRow,
+ ProductPlanningTaobaoItem,
+} from "./types";
+import { ensureHttpsUrl, normalizeText, parseInteger, parseRangeValue } from "./utils";
+
+function normalizePrice(value: unknown) {
+ const parsed = parseRangeValue(value);
+ return typeof parsed === "number" ? parsed : null;
+}
+
+function normalizeImageList(item: any) {
+ if (!Array.isArray(item?.item_imgs)) return [] as string[];
+
+ return item.item_imgs
+ .map((entry: unknown) => {
+ if (typeof entry === "string") return ensureHttpsUrl(entry);
+ if (entry && typeof entry === "object") {
+ const url = (entry as Record).url;
+ return typeof url === "string" ? ensureHttpsUrl(url) : "";
+ }
+ return "";
+ })
+ .filter(Boolean);
+}
+
+function normalizeMixedImageList(value: unknown) {
+ if (!Array.isArray(value)) return [] as string[];
+
+ return value
+ .map((entry: unknown) => {
+ if (typeof entry === "string") return ensureHttpsUrl(entry);
+ if (entry && typeof entry === "object") {
+ const record = entry as Record;
+ return ensureHttpsUrl(record.url || record.img || record.image || "");
+ }
+ return "";
+ })
+ .filter(Boolean);
+}
+
+function extractDetailImagesFromHtml(value: unknown) {
+ const html = String(value || "");
+ if (!html) return [] as string[];
+
+ const matched = html.match(/(?:data-lazyload|src)=["']([^"']+\.(?:jpg|jpeg|png|gif|webp)(?:[^"']*)?)["']/gi);
+ if (!matched) return [] as string[];
+
+ const urls = matched
+ .map((entry) => {
+ const urlMatch = entry.match(/["']([^"']+)["']/);
+ return ensureHttpsUrl(urlMatch?.[1] || "");
+ })
+ .filter(Boolean);
+
+ return Array.from(new Set(urls));
+}
+
+function normalizeDetailImages(item: any) {
+ const descImages = normalizeMixedImageList(item?.desc_img);
+ if (descImages.length > 0) return Array.from(new Set(descImages));
+ return extractDetailImagesFromHtml(item?.desc);
+}
+
+function normalizeSkuList(item: any) {
+ const skuRows = Array.isArray(item?.skus?.sku) ? item.skus.sku : [];
+ const result: ProductPlanningSkuRow[] = [];
+
+ for (const sku of skuRows) {
+ const name =
+ normalizeText(sku?.properties_name) ||
+ normalizeText(sku?.properties) ||
+ normalizeText(sku?.sku_id) ||
+ "默认规格";
+ result.push({
+ name,
+ price: normalizePrice(sku?.price),
+ });
+ }
+
+ return result;
+}
+
+function normalizeJdSkuList(item: any) {
+ const skuRows = Array.isArray(item?.skus) ? item.skus : [];
+ const result: ProductPlanningSkuRow[] = [];
+
+ for (const sku of skuRows) {
+ const specs = Array.isArray(sku?.specs) ? sku.specs : [];
+ const specNames = specs.flatMap((spec: any) =>
+ Array.isArray(spec?.values)
+ ? spec.values
+ .map((entry: any) => normalizeText(entry?.vname))
+ .filter(Boolean)
+ : []
+ );
+ const name =
+ specNames.join(" / ") ||
+ normalizeText(sku?.name) ||
+ normalizeText(sku?.sku_id) ||
+ "默认规格";
+ result.push({
+ name,
+ price: normalizePrice(sku?.price ?? item?.price),
+ });
+ }
+
+ return result;
+}
+
+function normalizePropertyList(item: any) {
+ const result: ProductPlanningPropertyRow[] = [];
+
+ if (Array.isArray(item?.props)) {
+ for (const prop of item.props) {
+ const name = normalizeText(prop?.name);
+ const value = normalizeText(prop?.value);
+ if (!name || !value) continue;
+ result.push({ name, value });
+ }
+ }
+
+ if (item?.props_list && typeof item.props_list === "object") {
+ for (const [name, value] of Object.entries(item.props_list)) {
+ const normalizedName = normalizeText(name);
+ const normalizedValue = normalizeText(value);
+ if (!normalizedName || !normalizedValue) continue;
+ if (result.some((entry) => entry.name === normalizedName && entry.value === normalizedValue)) {
+ continue;
+ }
+ result.push({ name: normalizedName, value: normalizedValue });
+ }
+ }
+
+ return result;
+}
+
+function normalizeJdPropertyList(item: any) {
+ const result = normalizePropertyList(item);
+ const attrs = Array.isArray(item?.attrs) ? item.attrs : [];
+
+ for (const attr of attrs) {
+ const name = normalizeText(attr?.name || attr?.attr_name);
+ const value =
+ normalizeText(attr?.value || attr?.attr_value) ||
+ (Array.isArray(attr?.values)
+ ? attr.values.map((entry: any) => normalizeText(entry?.vname || entry)).filter(Boolean).join(" / ")
+ : "");
+
+ if (!name || !value) continue;
+ if (result.some((entry) => entry.name === name && entry.value === value)) {
+ continue;
+ }
+ result.push({ name, value });
+ }
+
+ return result;
+}
+
+export function extractMaterialSummary(properties: ProductPlanningPropertyRow[]) {
+ const directMaterial = properties.find((property) => property.name === "材质");
+ const woodMaterial = properties.find((property) => property.name === "木质材质");
+
+ if (directMaterial && woodMaterial) {
+ return `${directMaterial.value}(${woodMaterial.value})`;
+ }
+
+ if (woodMaterial) return woodMaterial.value;
+ if (directMaterial) return directMaterial.value;
+
+ const partialMaterial = properties.find(
+ (property) =>
+ property.name.includes("材质") ||
+ property.name.includes("面料") ||
+ property.name.toLowerCase().includes("material")
+ );
+
+ return partialMaterial?.value || undefined;
+}
+
+export function extractDetailedMaterialSummary(properties: ProductPlanningPropertyRow[]) {
+ const materialProperties = properties.filter((property) =>
+ [
+ "材质",
+ "木质材质",
+ "饰面材质",
+ "实木占比",
+ "面料",
+ "成分",
+ "工艺",
+ "表面处理",
+ "风格",
+ "流行元素",
+ ].some((keyword) => property.name.includes(keyword))
+ );
+
+ if (materialProperties.length === 0) {
+ return undefined;
+ }
+
+ return materialProperties.map((property) => `${property.name}:${property.value}`).join(" | ");
+}
+
+export function normalizeTaobaoItem(item: any, itemId: string): ProductPlanningTaobaoItem {
+ const itemImages = normalizeImageList(item);
+ const detailImages = normalizeDetailImages(item);
+ const skus = normalizeSkuList(item);
+ const properties = normalizePropertyList(item);
+ const material = extractMaterialSummary(properties);
+ const detailedMaterial = extractDetailedMaterialSummary(properties);
+
+ return {
+ platform: "taobao",
+ itemId,
+ title: normalizeText(item?.title) || "无标题",
+ detailUrl: ensureHttpsUrl(item?.detail_url || ""),
+ shopName:
+ normalizeText(item?.seller_info?.shop_name) ||
+ normalizeText(item?.shop_name) ||
+ normalizeText(item?.nick) ||
+ undefined,
+ location: normalizeText(item?.location) || undefined,
+ price: normalizePrice(item?.price),
+ originalPrice: normalizePrice(item?.orginal_price || item?.original_price || item?.price),
+ salesText: normalizeText(item?.sales) || undefined,
+ totalSales: parseInteger(item?.sales || item?.volume || item?.total_sold, 0),
+ recentSales: null,
+ brand: normalizeText(item?.brand) || undefined,
+ isTmall: Boolean(item?.tmall) || String(item?.detail_url || "").includes("tmall.com"),
+ mainImage: ensureHttpsUrl(item?.pic_url || itemImages[0] || ""),
+ itemImages,
+ detailImages,
+ descriptionHtml: normalizeText(item?.desc) ? String(item.desc) : undefined,
+ videoUrl: ensureHttpsUrl(item?.video?.url || ""),
+ videoThumb: ensureHttpsUrl(item?.video?.thumb || ""),
+ skus,
+ properties,
+ material,
+ detailedMaterial,
+ };
+}
+
+export function normalizeJdItem(item: any, itemId: string): ProductPlanningTaobaoItem {
+ const itemImages = normalizeMixedImageList(item?.item_imgs);
+ const detailImages = normalizeDetailImages(item);
+ const skus = normalizeJdSkuList(item);
+ const properties = normalizeJdPropertyList(item);
+ const material = extractMaterialSummary(properties);
+ const detailedMaterial = extractDetailedMaterialSummary(properties);
+ const totalSales = parseInteger(item?.sales || item?.volume || item?.total_sold, 0);
+
+ return {
+ platform: "jd",
+ itemId,
+ title: normalizeText(item?.title) || "无标题",
+ detailUrl: ensureHttpsUrl(item?.detail_url || `https://item.jd.com/${itemId}.html`),
+ shopName:
+ normalizeText(item?.shop_name) ||
+ normalizeText(item?.seller_info?.shop_name) ||
+ normalizeText(item?.nick) ||
+ undefined,
+ location: normalizeText(item?.location) || undefined,
+ price: normalizePrice(item?.price),
+ originalPrice: normalizePrice(item?.orginal_price || item?.original_price || item?.price),
+ salesText: normalizeText(item?.sales || item?.volume) || undefined,
+ totalSales,
+ recentSales: totalSales,
+ brand: normalizeText(item?.brand) || undefined,
+ isTmall: false,
+ mainImage: ensureHttpsUrl(item?.pic_url || itemImages[0] || ""),
+ itemImages,
+ detailImages,
+ descriptionHtml: normalizeText(item?.desc) ? String(item.desc) : undefined,
+ videoUrl: ensureHttpsUrl(item?.video?.url || ""),
+ videoThumb: ensureHttpsUrl(item?.video?.thumb || ""),
+ skus,
+ properties,
+ material,
+ detailedMaterial,
+ };
+}
+
+export function extractMonthSale(raw: Record | null | undefined) {
+ if (!raw || typeof raw !== "object") return null;
+
+ const candidates = [
+ raw.biz30day,
+ raw.sales,
+ raw.month_sales,
+ (raw.data as Record | undefined)?.sales,
+ (raw.data as Record | undefined)?.month_sales,
+ (raw.data as Record | undefined)?.recent_sales,
+ raw.recent_sales,
+ ];
+
+ for (const candidate of candidates) {
+ const parsed = parseRangeValue(candidate);
+ if (typeof parsed === "number") {
+ return parsed;
+ }
+ }
+
+ return null;
+}
diff --git a/lib/product-planning/templates.ts b/lib/product-planning/templates.ts
new file mode 100644
index 000000000..1ce1bb633
--- /dev/null
+++ b/lib/product-planning/templates.ts
@@ -0,0 +1,79 @@
+import type { ProductPlanningTemplate } from "./types";
+import { normalizeText } from "./utils";
+
+export const PRODUCT_PLANNING_TEMPLATES_KEY = "product_planning_keyword_templates";
+
+function toValidDateString(value: unknown) {
+ const text = String(value || "").trim();
+ if (!text) return null;
+
+ const date = new Date(text);
+ if (Number.isNaN(date.getTime())) return null;
+ return date.toISOString();
+}
+
+export function normalizeTemplateCategoryName(value: unknown) {
+ return normalizeText(value).replace(/\s+/g, " ");
+}
+
+export function parseProductPlanningTemplates(value: unknown) {
+ if (!Array.isArray(value)) return [] as ProductPlanningTemplate[];
+
+ const templates: ProductPlanningTemplate[] = [];
+
+ for (const entry of value) {
+ if (!entry || typeof entry !== "object") continue;
+
+ const record = entry as Record;
+ const id = normalizeText(record.id);
+ const categoryName = normalizeTemplateCategoryName(record.categoryName);
+ const fileName = normalizeText(record.fileName);
+ const fileUrl = normalizeText(record.fileUrl);
+ const createdAt = toValidDateString(record.createdAt);
+ const updatedAt = toValidDateString(record.updatedAt) || createdAt;
+ const fileSize = Number.parseInt(String(record.fileSize || "0"), 10);
+
+ if (
+ !id ||
+ !categoryName ||
+ !fileName ||
+ !fileUrl ||
+ !createdAt ||
+ !updatedAt ||
+ !Number.isFinite(fileSize)
+ ) {
+ continue;
+ }
+
+ templates.push({
+ id,
+ categoryName,
+ description: normalizeText(record.description) || undefined,
+ fileName,
+ fileUrl,
+ filePath: normalizeText(record.filePath) || undefined,
+ fileSize,
+ contentType: normalizeText(record.contentType) || undefined,
+ createdAt,
+ updatedAt,
+ uploadedById: normalizeText(record.uploadedById) || undefined,
+ });
+ }
+
+ return templates.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
+}
+
+export function upsertProductPlanningTemplate(
+ templates: ProductPlanningTemplate[],
+ template: ProductPlanningTemplate
+) {
+ return [
+ template,
+ ...templates.filter(
+ (entry) =>
+ entry.id !== template.id &&
+ normalizeTemplateCategoryName(entry.categoryName) !==
+ normalizeTemplateCategoryName(template.categoryName)
+ ),
+ ].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
+}
diff --git a/lib/product-planning/types.ts b/lib/product-planning/types.ts
new file mode 100644
index 000000000..2cb6bacce
--- /dev/null
+++ b/lib/product-planning/types.ts
@@ -0,0 +1,177 @@
+export type KeywordInputRow = {
+ keyword: string;
+ searchPopularity?: number;
+ conversionRate?: number;
+};
+
+export type KeywordAnalysisRow = {
+ keyword: string;
+ segments: string[];
+ category: string;
+ occurrenceCount: number;
+ searchPopularity?: number;
+ conversionRate?: number;
+};
+
+export type TaobaoReviewRow = {
+ itemId: string;
+ date: string;
+ content: string;
+ appendComment?: string;
+ sku?: string;
+ userId?: string;
+ images: string[];
+ videoUrls: string[];
+};
+
+export type TaobaoQaRow = {
+ itemId: string;
+ question: string;
+ questionDetail?: string;
+ answer: string;
+ answerUser?: string;
+ answerDate?: string;
+ isOfficial: boolean;
+};
+
+export type ProductPlanningAnalyzeRequest =
+ | { kind: "review"; rows: TaobaoReviewRow[] }
+ | { kind: "qa"; rows: TaobaoQaRow[] };
+
+export type ProductPlanningAnalyzeResponse = {
+ success: boolean;
+ markdown?: string;
+ error?: string;
+};
+
+export type ProductPlanningImageAnalyzeRequest = {
+ itemId: string;
+ imageUrl: string;
+ title?: string;
+};
+
+export type ProductPlanningSkuAnalyzeRequest = {
+ itemId: string;
+ title?: string;
+ platform: ProductPlanningPlatform;
+ skus: ProductPlanningSkuRow[];
+};
+
+export type ProductPlanningCompetitorAnalysisRecord = {
+ title: string;
+ markdown: string;
+ kind: "image" | "sku";
+ updatedAt: string;
+};
+
+export type ProductPlanningRowsResponse = {
+ success: boolean;
+ itemId: string;
+ page: number;
+ totalPages: number;
+ rows: T[];
+ error?: string;
+};
+
+export type ProductPlanningSkuRow = {
+ name: string;
+ price: number | null;
+};
+
+export type ProductPlanningPlatform = "taobao" | "jd";
+
+export type ProductPlanningPropertyRow = {
+ name: string;
+ value: string;
+};
+
+export type ProductPlanningTaobaoItem = {
+ platform: ProductPlanningPlatform;
+ itemId: string;
+ title: string;
+ detailUrl?: string;
+ shopName?: string;
+ location?: string;
+ price: number | null;
+ originalPrice: number | null;
+ salesText?: string;
+ totalSales: number | null;
+ recentSales: number | null;
+ brand?: string;
+ isTmall: boolean;
+ mainImage?: string;
+ itemImages: string[];
+ detailImages: string[];
+ descriptionHtml?: string;
+ videoUrl?: string;
+ videoThumb?: string;
+ skus: ProductPlanningSkuRow[];
+ properties: ProductPlanningPropertyRow[];
+ material?: string;
+ detailedMaterial?: string;
+};
+
+export type ProductPlanningTaobaoItemResponse = {
+ success: boolean;
+ item?: ProductPlanningTaobaoItem;
+ error?: string;
+};
+
+export type ProductPlanningMonthSaleResponse = {
+ success: boolean;
+ itemId: string;
+ recentSales: number | null;
+ raw?: Record | null;
+ error?: string;
+};
+
+export type ProductPlanningKeywordInsightSuggestion = {
+ keyword: string;
+ weight: number;
+ rank: number;
+};
+
+export type ProductPlanningKeywordInsightTag = {
+ title: string;
+ type: "hot" | "normal";
+};
+
+export type ProductPlanningKeywordInsightGroup = {
+ index: number;
+ type: string;
+ tags: ProductPlanningKeywordInsightTag[];
+};
+
+export type ProductPlanningKeywordInsightResponse = {
+ success: boolean;
+ keyword: string;
+ suggestions: ProductPlanningKeywordInsightSuggestion[];
+ groups: ProductPlanningKeywordInsightGroup[];
+ error?: string;
+};
+
+export type ProductPlanningTemplate = {
+ id: string;
+ categoryName: string;
+ description?: string;
+ fileName: string;
+ fileUrl: string;
+ filePath?: string;
+ fileSize: number;
+ contentType?: string;
+ createdAt: string;
+ updatedAt: string;
+ uploadedById?: string;
+};
+
+export type ProductPlanningTemplateListResponse = {
+ success: boolean;
+ templates: ProductPlanningTemplate[];
+ error?: string;
+};
+
+export type ProductPlanningTemplateMutationResponse = {
+ success: boolean;
+ template?: ProductPlanningTemplate;
+ error?: string;
+};
diff --git a/lib/product-planning/utils.ts b/lib/product-planning/utils.ts
new file mode 100644
index 000000000..50b0d38c8
--- /dev/null
+++ b/lib/product-planning/utils.ts
@@ -0,0 +1,159 @@
+import type { ProductPlanningPlatform } from "./types";
+
+const HEADER_SANITIZE_PATTERN = /[\s_\-./\\()[\]{}::,,;;'"`~!@#$%^&*+|?<>]/g;
+
+export function normalizeHeader(value: unknown) {
+ return String(value || "")
+ .replace(/^\ufeff/, "")
+ .trim()
+ .toLowerCase()
+ .replace(HEADER_SANITIZE_PATTERN, "");
+}
+
+export function normalizeText(value: unknown) {
+ return String(value || "").replace(/^\ufeff/, "").trim();
+}
+
+export function parseRangeValue(value: unknown): number | null {
+ if (value === null || value === undefined || value === "") {
+ return null;
+ }
+
+ const text = String(value).trim();
+ if (!text) return null;
+
+ const rangeMatch = text.match(
+ /^(\d+(?:\.\d+)?)\s*[-~~至到]\s*(\d+(?:\.\d+)?)$/
+ );
+ if (rangeMatch) {
+ return Math.min(Number.parseFloat(rangeMatch[1]), Number.parseFloat(rangeMatch[2]));
+ }
+
+ const cleaned = text.replace(/[,,%%\s]/g, "");
+ const parsed = Number.parseFloat(cleaned);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+export function parseInteger(value: unknown, fallback = 0) {
+ const parsed = Number.parseInt(String(value || "").trim(), 10);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+export function ensureHttpsUrl(value: unknown) {
+ const text = String(value || "").trim();
+ if (!text) return "";
+ if (text.startsWith("//")) return `https:${text}`;
+ if (/^https?:\/\//i.test(text)) return text;
+ return `https://${text}`;
+}
+
+export function splitUrlCell(value: unknown) {
+ const text = String(value || "").trim();
+ if (!text) return [] as string[];
+
+ return text
+ .split(/[\n,,;;\s]+/)
+ .map((item) => ensureHttpsUrl(item))
+ .filter((item) => /^https?:\/\//i.test(item));
+}
+
+export function extractTaobaoItemId(value: string) {
+ const input = String(value || "").trim();
+ if (!input) return null;
+
+ if (/^\d+$/.test(input)) return input;
+
+ const patterns = [
+ /item\.taobao\.com\/item\.htm.*?[?&]id=(\d+)/i,
+ /detail\.tmall\.com\/item\.htm.*?[?&]id=(\d+)/i,
+ /a\.m\.taobao\.com\/i(\d+)/i,
+ /[?&]id=(\d+)/i,
+ /(\d{10,})/,
+ ];
+
+ for (const pattern of patterns) {
+ const matched = input.match(pattern);
+ if (matched?.[1]) {
+ return matched[1];
+ }
+ }
+
+ return null;
+}
+
+export function extractJdItemId(value: string) {
+ const input = String(value || "").trim();
+ if (!input) return null;
+
+ const explicitPrefix = input.match(/^jd[\s::]+(\d{6,})$/i);
+ if (explicitPrefix?.[1]) {
+ return explicitPrefix[1];
+ }
+
+ const patterns = [
+ /item\.jd\.com\/(\d+)\.html/i,
+ /sku=(\d+)/i,
+ /\/product\/(\d+)\.html/i,
+ /[?&]sku=(\d+)/i,
+ ];
+
+ for (const pattern of patterns) {
+ const matched = input.match(pattern);
+ if (matched?.[1]) {
+ return matched[1];
+ }
+ }
+
+ return null;
+}
+
+export function parseMarketplaceItemReference(value: string): {
+ platform: ProductPlanningPlatform;
+ itemId: string;
+} | null {
+ const input = String(value || "").trim();
+ if (!input) return null;
+
+ const jdItemId = extractJdItemId(input);
+ if (jdItemId) {
+ return {
+ platform: "jd",
+ itemId: jdItemId,
+ };
+ }
+
+ const taobaoItemId = extractTaobaoItemId(input);
+ if (taobaoItemId) {
+ return {
+ platform: "taobao",
+ itemId: taobaoItemId,
+ };
+ }
+
+ return null;
+}
+
+export function uniqueBy(items: T[], getKey: (item: T) => string) {
+ const seen = new Set();
+ const result: T[] = [];
+
+ for (const item of items) {
+ const key = getKey(item);
+ if (!key || seen.has(key)) continue;
+ seen.add(key);
+ result.push(item);
+ }
+
+ return result;
+}
+
+export function truncateText(value: unknown, maxLength: number) {
+ const text = normalizeText(value);
+ if (text.length <= maxLength) return text;
+ return `${text.slice(0, maxLength)}...`;
+}
+
+export function parseOfficialFlag(value: unknown) {
+ const text = normalizeText(value).toLowerCase();
+ return ["true", "1", "yes", "y", "是", "官方"].includes(text);
+}
diff --git a/package.json b/package.json
index a51786511..43c81786d 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
"dotenv": "^16.4.5",
"drizzle-orm": "^0.34.0",
"embla-carousel-react": "^8.6.0",
+ "exceljs": "^4.4.0",
"fast-deep-equal": "^3.1.3",
"form-data": "^4.0.5",
"framer-motion": "^11.18.2",
@@ -113,6 +114,7 @@
"tokenlens": "1.3.0",
"use-stick-to-bottom": "^1.1.1",
"usehooks-ts": "^3.1.0",
+ "xlsx": "^0.18.5",
"zod": "^3.25.76",
"zustand": "5.0.4"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c73dab8e..74899c5cc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -146,6 +146,9 @@ importers:
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.0.0-rc-45804af1-20241021)
+ exceljs:
+ specifier: ^4.4.0
+ version: 4.4.0
fast-deep-equal:
specifier: ^3.1.3
version: 3.1.3
@@ -287,6 +290,9 @@ importers:
usehooks-ts:
specifier: ^3.1.0
version: 3.1.1(react@19.0.0-rc-45804af1-20241021)
+ xlsx:
+ specifier: ^0.18.5
+ version: 0.18.5
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -1253,6 +1259,12 @@ packages:
engines: {node: '>=18.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
+ '@fast-csv/format@4.3.5':
+ resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==}
+
+ '@fast-csv/parse@4.3.6':
+ resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==}
+
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
@@ -3124,6 +3136,9 @@ packages:
'@types/mssql@9.1.9':
resolution: {integrity: sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A==}
+ '@types/node@14.18.63':
+ resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
+
'@types/node@22.13.10':
resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==}
@@ -3323,6 +3338,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ adler-32@1.3.1:
+ resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+ engines: {node: '>=0.8'}
+
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -3347,6 +3366,18 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
+ archiver-utils@2.1.0:
+ resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
+ engines: {node: '>= 6'}
+
+ archiver-utils@3.0.4:
+ resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==}
+ engines: {node: '>= 10'}
+
+ archiver@5.3.2:
+ resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==}
+ engines: {node: '>= 10'}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -3361,12 +3392,18 @@ packages:
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
+ async@3.2.6:
+ resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
+
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -3381,21 +3418,54 @@ packages:
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+ big-integer@1.6.52:
+ resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
+ engines: {node: '>=0.6'}
+
+ binary@0.3.0:
+ resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==}
+
+ bl@4.1.0:
+ resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+
bl@6.1.6:
resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==}
+ bluebird@3.4.7:
+ resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
+
bowser@2.13.1:
resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==}
+ brace-expansion@1.1.12:
+ resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+
+ brace-expansion@2.0.2:
+ resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+ buffer-crc32@0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ buffer-indexof-polyfill@1.0.2:
+ resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==}
+ engines: {node: '>=0.10'}
+
+ buffer@5.7.1:
+ resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+ buffers@0.1.1:
+ resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
+ engines: {node: '>=0.2.0'}
+
bufferutil@4.0.9:
resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==}
engines: {node: '>=6.14.2'}
@@ -3432,10 +3502,17 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ cfb@1.2.2:
+ resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
+ engines: {node: '>=0.8'}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
+ chainsaw@0.1.0:
+ resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -3503,6 +3580,10 @@ packages:
codemirror@6.0.1:
resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==}
+ codepage@1.15.0:
+ resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+ engines: {node: '>=0.8'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -3536,6 +3617,13 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
+ compress-commons@4.1.2:
+ resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==}
+ engines: {node: '>= 10'}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -3558,6 +3646,15 @@ packages:
cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
+ crc-32@1.2.2:
+ resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
+ crc32-stream@4.0.3:
+ resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
+ engines: {node: '>= 10'}
+
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@@ -3923,6 +4020,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ duplexer2@0.1.4:
+ resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
+
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
@@ -3939,6 +4039,9 @@ packages:
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
@@ -4022,6 +4125,10 @@ packages:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
+ exceljs@4.4.0:
+ resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
+ engines: {node: '>=8.3.0'}
+
expect-type@1.2.2:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
@@ -4032,6 +4139,10 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+ fast-csv@4.3.6:
+ resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==}
+ engines: {node: '>=10.0.0'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -4068,6 +4179,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
+ frac@1.1.2:
+ resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+ engines: {node: '>=0.8'}
+
framer-motion@11.18.2:
resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==}
peerDependencies:
@@ -4082,6 +4197,12 @@ packages:
react-dom:
optional: true
+ fs-constants@1.0.0:
+ resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+
+ fs.realpath@1.0.0:
+ resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4092,6 +4213,11 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ fstream@1.0.12:
+ resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==}
+ engines: {node: '>=0.6'}
+ deprecated: This package is no longer supported.
+
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -4115,6 +4241,10 @@ packages:
get-tsconfig@4.10.0:
resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==}
+ glob@7.2.3:
+ resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+ deprecated: Glob versions prior to v9 are no longer supported
+
globals@15.15.0:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
engines: {node: '>=18'}
@@ -4239,6 +4369,10 @@ packages:
import-in-the-middle@1.15.0:
resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==}
+ inflight@1.0.6:
+ resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+ deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
+
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -4395,6 +4529,10 @@ packages:
layout-base@2.0.1:
resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
+ lazystream@1.0.1:
+ resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
+ engines: {node: '>= 0.6.3'}
+
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -4475,6 +4613,9 @@ packages:
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
+ listenercount@1.0.1:
+ resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==}
+
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
@@ -4488,15 +4629,40 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
+ lodash.defaults@4.2.0:
+ resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
+
+ lodash.difference@4.5.0:
+ resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
+
+ lodash.escaperegexp@4.1.2:
+ resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
+
+ lodash.flatten@4.4.0:
+ resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
+
+ lodash.groupby@4.6.0:
+ resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==}
+
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+ lodash.isequal@4.5.0:
+ resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
+ deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
+
+ lodash.isfunction@3.0.9:
+ resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
+
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+ lodash.isnil@4.0.0:
+ resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==}
+
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
@@ -4506,12 +4672,21 @@ packages:
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+ lodash.isundefined@3.0.1:
+ resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==}
+
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+ lodash.union@4.6.0:
+ resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
+
+ lodash.uniq@4.5.0:
+ resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
+
long@4.0.0:
resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==}
@@ -4723,6 +4898,20 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+ minimatch@5.1.9:
+ resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
+ engines: {node: '>=10'}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ mkdirp@0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+ hasBin: true
+
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@@ -4812,6 +5001,10 @@ packages:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
nypm@0.6.2:
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
engines: {node: ^14.16.0 || >=16.10.0}
@@ -4827,6 +5020,9 @@ packages:
obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
@@ -4898,6 +5094,10 @@ packages:
path-data-parser@0.1.0:
resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
+ path-is-absolute@1.0.1:
+ resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+ engines: {node: '>=0.10.0'}
+
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -5207,10 +5407,17 @@ packages:
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ readdir-glob@1.1.3:
+ resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
+
redis@5.9.0:
resolution: {integrity: sha512-E8dQVLSyH6UE/C9darFuwq4usOPrqfZ1864kI4RFbr5Oj9ioB9qPF0oJMwX7s8mf6sPYrz84x/Dx1PGF3/0EaQ==}
engines: {node: '>= 18'}
@@ -5274,6 +5481,11 @@ packages:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
+ rimraf@2.7.1:
+ resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
+ deprecated: Rimraf versions prior to v4 are no longer supported
+ hasBin: true
+
robot3@0.4.1:
resolution: {integrity: sha512-hzjy826lrxzx8eRgv80idkf8ua1JAepRc9Efdtj03N3KNJuznQCPlyCJ7gnUmDFwZCLQjxy567mQVKmdv2BsXQ==}
@@ -5307,6 +5519,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ saxes@5.0.1:
+ resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
+ engines: {node: '>=10'}
+
scheduler@0.25.0-rc-45804af1-20241021:
resolution: {integrity: sha512-8jyu/iy3tGFNakMMCWnKw/vsiTcapDyl0LKlZ3fUKBcBicZAkrrCC1bdqVFx0Ioxgry1SzOrCGcZLM7vtWK00A==}
@@ -5377,6 +5593,10 @@ packages:
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
+ ssf@0.11.2:
+ resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
+ engines: {node: '>=0.8'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -5473,6 +5693,10 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
+ tar-stream@2.2.0:
+ resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
+ engines: {node: '>=6'}
+
tarn@3.0.2:
resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==}
engines: {node: '>=8.0.0'}
@@ -5523,9 +5747,16 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
+ tmp@0.2.5:
+ resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
+ engines: {node: '>=14.14'}
+
tokenlens@1.3.0:
resolution: {integrity: sha512-qrwHFO7CI8HEd+UvKjlL+veTzCVr3N4AYp3cquGL6z62Q/OtoEHbTv5uNqiiejP54y7eR+h8VVRRpZbm3t/99Q==}
+ traverse@0.3.9:
+ resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
+
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -5622,6 +5853,9 @@ packages:
unist-util-visit@5.0.0:
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
+ unzipper@0.10.14:
+ resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==}
+
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -5810,6 +6044,17 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ wmf@1.0.2:
+ resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+ engines: {node: '>=0.8'}
+
+ word@0.3.0:
+ resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
+ engines: {node: '>=0.8'}
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
ws@8.18.1:
resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==}
engines: {node: '>=10.0.0'}
@@ -5826,6 +6071,14 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
+ xlsx@0.18.5:
+ resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -5835,6 +6088,10 @@ packages:
engines: {node: '>= 14'}
hasBin: true
+ zip-stream@4.1.1:
+ resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
+ engines: {node: '>= 10'}
+
zod-to-json-schema@3.24.5:
resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==}
peerDependencies:
@@ -6990,6 +7247,25 @@ snapshots:
eventsource-parser: 1.1.2
robot3: 0.4.1
+ '@fast-csv/format@4.3.5':
+ dependencies:
+ '@types/node': 14.18.63
+ lodash.escaperegexp: 4.1.2
+ lodash.isboolean: 3.0.3
+ lodash.isequal: 4.5.0
+ lodash.isfunction: 3.0.9
+ lodash.isnil: 4.0.0
+
+ '@fast-csv/parse@4.3.6':
+ dependencies:
+ '@types/node': 14.18.63
+ lodash.escaperegexp: 4.1.2
+ lodash.groupby: 4.6.0
+ lodash.isfunction: 3.0.9
+ lodash.isnil: 4.0.0
+ lodash.isundefined: 3.0.1
+ lodash.uniq: 4.5.0
+
'@fastify/busboy@2.1.1': {}
'@floating-ui/core@1.6.9':
@@ -8931,6 +9207,8 @@ snapshots:
- '@azure/core-client'
- supports-color
+ '@types/node@14.18.63': {}
+
'@types/node@22.13.10':
dependencies:
undici-types: 6.20.0
@@ -9129,6 +9407,8 @@ snapshots:
acorn@8.15.0: {}
+ adler-32@1.3.1: {}
+
agent-base@7.1.4: {}
ai@5.0.117(zod@3.25.76):
@@ -9153,6 +9433,42 @@ snapshots:
ansi-styles@5.2.0: {}
+ archiver-utils@2.1.0:
+ dependencies:
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ lazystream: 1.0.1
+ lodash.defaults: 4.2.0
+ lodash.difference: 4.5.0
+ lodash.flatten: 4.4.0
+ lodash.isplainobject: 4.0.6
+ lodash.union: 4.6.0
+ normalize-path: 3.0.0
+ readable-stream: 2.3.8
+
+ archiver-utils@3.0.4:
+ dependencies:
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ lazystream: 1.0.1
+ lodash.defaults: 4.2.0
+ lodash.difference: 4.5.0
+ lodash.flatten: 4.4.0
+ lodash.isplainobject: 4.0.6
+ lodash.union: 4.6.0
+ normalize-path: 3.0.0
+ readable-stream: 3.6.2
+
+ archiver@5.3.2:
+ dependencies:
+ archiver-utils: 2.1.0
+ async: 3.2.6
+ buffer-crc32: 0.2.13
+ readable-stream: 3.6.2
+ readdir-glob: 1.1.3
+ tar-stream: 2.2.0
+ zip-stream: 4.1.1
+
argparse@2.0.1: {}
aria-hidden@1.2.4:
@@ -9165,10 +9481,14 @@ snapshots:
dependencies:
retry: 0.13.1
+ async@3.2.6: {}
+
asynckit@0.4.0: {}
bail@2.0.2: {}
+ balanced-match@1.0.2: {}
+
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.11: {}
@@ -9179,6 +9499,19 @@ snapshots:
dependencies:
require-from-string: 2.0.2
+ big-integer@1.6.52: {}
+
+ binary@0.3.0:
+ dependencies:
+ buffers: 0.1.1
+ chainsaw: 0.1.0
+
+ bl@4.1.0:
+ dependencies:
+ buffer: 5.7.1
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+
bl@6.1.6:
dependencies:
'@types/readable-stream': 4.0.23
@@ -9186,17 +9519,39 @@ snapshots:
inherits: 2.0.4
readable-stream: 4.7.0
+ bluebird@3.4.7: {}
+
bowser@2.13.1: {}
+ brace-expansion@1.1.12:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@2.0.2:
+ dependencies:
+ balanced-match: 1.0.2
+
+ buffer-crc32@0.2.13: {}
+
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
+ buffer-indexof-polyfill@1.0.2: {}
+
+ buffer@5.7.1:
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
+ buffers@0.1.1: {}
+
bufferutil@4.0.9:
dependencies:
node-gyp-build: 4.8.4
@@ -9225,6 +9580,11 @@ snapshots:
ccount@2.0.1: {}
+ cfb@1.2.2:
+ dependencies:
+ adler-32: 1.3.1
+ crc-32: 1.2.2
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -9233,6 +9593,10 @@ snapshots:
loupe: 3.2.1
pathval: 2.0.1
+ chainsaw@0.1.0:
+ dependencies:
+ traverse: 0.3.9
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -9298,6 +9662,8 @@ snapshots:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.4
+ codepage@1.15.0: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -9320,6 +9686,15 @@ snapshots:
commander@8.3.0: {}
+ compress-commons@4.1.2:
+ dependencies:
+ buffer-crc32: 0.2.13
+ crc32-stream: 4.0.3
+ normalize-path: 3.0.0
+ readable-stream: 3.6.2
+
+ concat-map@0.0.1: {}
+
confbox@0.1.8: {}
confbox@0.2.2: {}
@@ -9340,6 +9715,13 @@ snapshots:
dependencies:
layout-base: 2.0.1
+ crc-32@1.2.2: {}
+
+ crc32-stream@4.0.3:
+ dependencies:
+ crc-32: 1.2.2
+ readable-stream: 3.6.2
+
crelt@1.0.6: {}
cross-env@7.0.3:
@@ -9628,9 +10010,13 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ duplexer2@0.1.4:
+ dependencies:
+ readable-stream: 2.3.8
+
ecdsa-sig-formatter@1.0.11:
dependencies:
- safe-buffer: 5.1.2
+ safe-buffer: 5.2.1
embla-carousel-react@8.6.0(react@19.0.0-rc-45804af1-20241021):
dependencies:
@@ -9644,6 +10030,10 @@ snapshots:
embla-carousel@8.6.0: {}
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
@@ -9776,12 +10166,29 @@ snapshots:
eventsource-parser@3.0.6: {}
+ exceljs@4.4.0:
+ dependencies:
+ archiver: 5.3.2
+ dayjs: 1.11.19
+ fast-csv: 4.3.6
+ jszip: 3.10.1
+ readable-stream: 3.6.2
+ saxes: 5.0.1
+ tmp: 0.2.5
+ unzipper: 0.10.14
+ uuid: 8.3.2
+
expect-type@1.2.2: {}
exsolve@1.0.7: {}
extend@3.0.2: {}
+ fast-csv@4.3.6:
+ dependencies:
+ '@fast-csv/format': 4.3.5
+ '@fast-csv/parse': 4.3.6
+
fast-deep-equal@3.1.3: {}
fast-xml-parser@5.2.5:
@@ -9812,6 +10219,8 @@ snapshots:
format@0.2.2: {}
+ frac@1.1.2: {}
+
framer-motion@11.18.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
dependencies:
motion-dom: 11.18.1
@@ -9821,12 +10230,23 @@ snapshots:
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
+ fs-constants@1.0.0: {}
+
+ fs.realpath@1.0.0: {}
+
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
+ fstream@1.0.12:
+ dependencies:
+ graceful-fs: 4.2.11
+ inherits: 2.0.4
+ mkdirp: 0.5.6
+ rimraf: 2.7.1
+
function-bind@1.1.2: {}
geist@1.3.1(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)):
@@ -9857,6 +10277,15 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
+ glob@7.2.3:
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.5
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+
globals@15.15.0: {}
glsl-noise@0.0.0: {}
@@ -10056,6 +10485,11 @@ snapshots:
cjs-module-lexer: 1.4.3
module-details-from-path: 1.0.4
+ inflight@1.0.6:
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+
inherits@2.0.4: {}
inline-style-parser@0.2.4: {}
@@ -10161,12 +10595,12 @@ snapshots:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
- safe-buffer: 5.1.2
+ safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
- safe-buffer: 5.1.2
+ safe-buffer: 5.2.1
katex@0.16.25:
dependencies:
@@ -10200,6 +10634,10 @@ snapshots:
layout-base@2.0.1: {}
+ lazystream@1.0.1:
+ dependencies:
+ readable-stream: 2.3.8
+
lie@3.3.0:
dependencies:
immediate: 3.0.6
@@ -10257,6 +10695,8 @@ snapshots:
dependencies:
uc.micro: 2.1.0
+ listenercount@1.0.1: {}
+
local-pkg@1.1.2:
dependencies:
mlly: 1.8.0
@@ -10269,22 +10709,44 @@ snapshots:
lodash.debounce@4.0.8: {}
+ lodash.defaults@4.2.0: {}
+
+ lodash.difference@4.5.0: {}
+
+ lodash.escaperegexp@4.1.2: {}
+
+ lodash.flatten@4.4.0: {}
+
+ lodash.groupby@4.6.0: {}
+
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
+ lodash.isequal@4.5.0: {}
+
+ lodash.isfunction@3.0.9: {}
+
lodash.isinteger@4.0.4: {}
+ lodash.isnil@4.0.0: {}
+
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
+ lodash.isundefined@3.0.1: {}
+
lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
+ lodash.union@4.6.0: {}
+
+ lodash.uniq@4.5.0: {}
+
long@4.0.0: {}
longest-streak@3.1.0: {}
@@ -10739,6 +11201,20 @@ snapshots:
dependencies:
mime-db: 1.52.0
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.12
+
+ minimatch@5.1.9:
+ dependencies:
+ brace-expansion: 2.0.2
+
+ minimist@1.2.8: {}
+
+ mkdirp@0.5.6:
+ dependencies:
+ minimist: 1.2.8
+
mlly@1.8.0:
dependencies:
acorn: 8.15.0
@@ -10817,6 +11293,8 @@ snapshots:
node-gyp-build@4.8.4:
optional: true
+ normalize-path@3.0.0: {}
+
nypm@0.6.2:
dependencies:
citty: 0.1.6
@@ -10832,6 +11310,10 @@ snapshots:
obuf@1.1.2:
optional: true
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.3:
@@ -10922,6 +11404,8 @@ snapshots:
path-data-parser@0.1.0: {}
+ path-is-absolute@1.0.1: {}
+
path-key@3.1.1: {}
path-parse@1.0.7: {}
@@ -11347,6 +11831,12 @@ snapshots:
string_decoder: 1.1.1
util-deprecate: 1.0.2
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
@@ -11355,6 +11845,10 @@ snapshots:
process: 0.11.10
string_decoder: 1.3.0
+ readdir-glob@1.1.3:
+ dependencies:
+ minimatch: 5.1.9
+
redis@5.9.0:
dependencies:
'@redis/bloom': 5.9.0(@redis/client@5.9.0)
@@ -11462,6 +11956,10 @@ snapshots:
retry@0.13.1: {}
+ rimraf@2.7.1:
+ dependencies:
+ glob: 7.2.3
+
robot3@0.4.1: {}
robust-predicates@3.0.2: {}
@@ -11513,6 +12011,10 @@ snapshots:
safer-buffer@2.1.2: {}
+ saxes@5.0.1:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.25.0-rc-45804af1-20241021: {}
scheduler@0.27.0: {}
@@ -11599,6 +12101,10 @@ snapshots:
sprintf-js@1.1.3: {}
+ ssf@0.11.2:
+ dependencies:
+ frac: 1.1.2
+
stackback@0.0.2: {}
stats-gl@2.4.2(@types/three@0.182.0)(three@0.182.0):
@@ -11694,6 +12200,14 @@ snapshots:
tapable@2.2.1: {}
+ tar-stream@2.2.0:
+ dependencies:
+ bl: 4.1.0
+ end-of-stream: 1.4.5
+ fs-constants: 1.0.0
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+
tarn@3.0.2: {}
tedious@19.2.1(@azure/core-client@1.10.1):
@@ -11747,6 +12261,8 @@ snapshots:
tinyspy@4.0.4: {}
+ tmp@0.2.5: {}
+
tokenlens@1.3.0:
dependencies:
'@tokenlens/core': 1.3.0
@@ -11754,6 +12270,8 @@ snapshots:
'@tokenlens/helpers': 1.3.0
'@tokenlens/models': 1.3.0
+ traverse@0.3.9: {}
+
trim-lines@3.0.1: {}
troika-three-text@0.52.4(three@0.182.0):
@@ -11892,6 +12410,19 @@ snapshots:
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
+ unzipper@0.10.14:
+ dependencies:
+ big-integer: 1.6.52
+ binary: 0.3.0
+ bluebird: 3.4.7
+ buffer-indexof-polyfill: 1.0.2
+ duplexer2: 0.1.4
+ fstream: 1.0.12
+ graceful-fs: 4.2.11
+ listenercount: 1.0.1
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
use-callback-ref@1.3.3(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
dependencies:
react: 19.0.0-rc-45804af1-20241021
@@ -12064,6 +12595,12 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ wmf@1.0.2: {}
+
+ word@0.3.0: {}
+
+ wrappy@1.0.2: {}
+
ws@8.18.1(bufferutil@4.0.9):
optionalDependencies:
bufferutil: 4.0.9
@@ -12073,11 +12610,29 @@ snapshots:
dependencies:
is-wsl: 3.1.1
+ xlsx@0.18.5:
+ dependencies:
+ adler-32: 1.3.1
+ cfb: 1.2.2
+ codepage: 1.15.0
+ crc-32: 1.2.2
+ ssf: 0.11.2
+ wmf: 1.0.2
+ word: 0.3.0
+
+ xmlchars@2.2.0: {}
+
xtend@4.0.2: {}
yaml@2.7.0:
optional: true
+ zip-stream@4.1.1:
+ dependencies:
+ archiver-utils: 3.0.4
+ compress-commons: 4.1.2
+ readable-stream: 3.6.2
+
zod-to-json-schema@3.24.5(zod@3.25.76):
dependencies:
zod: 3.25.76
diff --git a/proxy.ts b/proxy.ts
index 2ce34ea38..661cddb1e 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -266,8 +266,8 @@ export async function proxy(request: NextRequest) {
// 未登录用户允许访问公开页面,其余需要登录
if (!token && !isPublicPage) {
- // 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
- if (pathname.startsWith("/api/") && request.method !== "GET") {
+ // 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页
+ if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "未授权,请先登录" },
{ status: 401 }
diff --git a/tests/data/product-planning-keywords.txt b/tests/data/product-planning-keywords.txt
new file mode 100644
index 000000000..752af4717
--- /dev/null
+++ b/tests/data/product-planning-keywords.txt
@@ -0,0 +1,3 @@
+换鞋凳 门口 入户
+实木换鞋凳玄关
+收纳换鞋凳 小户型
diff --git a/tests/data/product-planning-manual.csv b/tests/data/product-planning-manual.csv
new file mode 100644
index 000000000..5f33ff2c3
--- /dev/null
+++ b/tests/data/product-planning-manual.csv
@@ -0,0 +1,4 @@
+词语,标签分类,搜索人气,支付转化率,出现次数
+换鞋凳,品类大词,120,12,3
+实木,材质词,90,8,2
+玄关,场景词,80,6,2
diff --git a/tests/e2e/product-planning.test.ts b/tests/e2e/product-planning.test.ts
new file mode 100644
index 000000000..d428b5425
--- /dev/null
+++ b/tests/e2e/product-planning.test.ts
@@ -0,0 +1,105 @@
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+import { encode } from "next-auth/jwt";
+
+const SESSION_COOKIE_NAME = "authjs.session-token";
+
+async function createSessionCookie() {
+ const secret = process.env.AUTH_SECRET;
+ if (!secret) {
+ throw new Error("AUTH_SECRET 未配置,无法生成测试会话");
+ }
+
+ return encode({
+ secret,
+ salt: SESSION_COOKIE_NAME,
+ token: {
+ sub: "product-planning-page-user",
+ id: "product-planning-page-user",
+ email: "product-planning-page@example.com",
+ name: "Product Planning Page User",
+ type: "regular",
+ },
+ });
+}
+
+test.describe.serial("Product planning page", () => {
+ const keywordFixture = path.join(process.cwd(), "tests/data/product-planning-keywords.txt");
+ const manualFixture = path.join(process.cwd(), "tests/data/product-planning-manual.csv");
+
+ test("anonymous user is redirected to login", async ({ page }) => {
+ await page.goto("/product-planning", { waitUntil: "commit" }).catch((error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ if (!/ERR_ABORTED|frame was detached/i.test(message)) {
+ throw error;
+ }
+ });
+ await page.waitForURL(/\/login/);
+ await expect(page).toHaveURL(/\/login/);
+ });
+
+ test("authenticated user can open product planning page", async ({ page }) => {
+ const token = await createSessionCookie();
+ await page.context().addCookies([
+ {
+ name: SESSION_COOKIE_NAME,
+ value: token,
+ url: "http://localhost:3000",
+ httpOnly: true,
+ sameSite: "Lax",
+ },
+ ]);
+
+ await page.goto("/product-planning");
+ await expect(page).toHaveURL(/\/product-planning/);
+ await expect(page.getByRole("heading", { name: "商品策划工具" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "关键词拆词" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "评价 / 问大家分析" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "拓展分析" })).toBeVisible();
+ await expect(page.getByText("云端模板库")).toBeVisible();
+ });
+
+ test("authenticated user can upload keyword file and get results", async ({ page }) => {
+ const token = await createSessionCookie();
+ await page.context().addCookies([
+ {
+ name: SESSION_COOKIE_NAME,
+ value: token,
+ url: "http://localhost:3000",
+ httpOnly: true,
+ sameSite: "Lax",
+ },
+ ]);
+
+ await page.goto("/product-planning");
+ await page.locator("input[type='file']").first().setInputFiles(keywordFixture);
+ await page.getByRole("button", { name: "开始拆词" }).click();
+
+ await expect(page.getByText("已生成")).toBeVisible();
+ await expect(page.getByRole("cell", { name: "换鞋凳" }).first()).toBeVisible();
+ await expect(page.getByRole("cell", { name: "场景词" }).first()).toBeVisible();
+ await expect(page.getByRole("button", { name: "导出 xlsx" })).toBeEnabled();
+ });
+
+ test("authenticated user can import manual segmentation file", async ({ page }) => {
+ const token = await createSessionCookie();
+ await page.context().addCookies([
+ {
+ name: SESSION_COOKIE_NAME,
+ value: token,
+ url: "http://localhost:3000",
+ httpOnly: true,
+ sameSite: "Lax",
+ },
+ ]);
+
+ await page.goto("/product-planning");
+ await page.getByRole("switch").click();
+ await page.locator("input[type='file']").first().setInputFiles(manualFixture);
+ await page.getByRole("button", { name: "开始拆词" }).click();
+
+ await expect(page.getByRole("cell", { name: "换鞋凳" }).first()).toBeVisible();
+ await expect(page.getByRole("cell", { name: "品类大词" })).toBeVisible();
+ await expect(page.getByRole("cell", { name: "实木" }).first()).toBeVisible();
+ });
+});
diff --git a/tests/routes/admin-settings.test.ts b/tests/routes/admin-settings.test.ts
new file mode 100644
index 000000000..2f7681d90
--- /dev/null
+++ b/tests/routes/admin-settings.test.ts
@@ -0,0 +1,79 @@
+import { expect, test } from "@playwright/test";
+import {
+ defaultProductPlanningApiConfig,
+ normalizeProductPlanningApiConfig,
+ PRODUCT_PLANNING_API_CONFIG_KEY,
+ type ProductPlanningApiConfig,
+} from "@/lib/product-planning/config";
+test.describe.serial("/api/admin/settings", () => {
+ test("guest cannot read product planning system config", async ({ request }) => {
+ const response = await request.get(
+ `/api/admin/settings?key=${PRODUCT_PLANNING_API_CONFIG_KEY}`
+ );
+
+ expect(response.status()).toBe(401);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ error: "未授权,请先登录",
+ });
+ });
+
+ test("product planning config normalization trims values and falls back to defaults", () => {
+ const defaults = defaultProductPlanningApiConfig();
+ const expectedValue: ProductPlanningApiConfig = {
+ oneboundBaseUrl: "https://api.example.com/onebound",
+ taobaoApiKey: "demo-key",
+ taobaoApiSecret: "demo-secret",
+ qaApiUrl: defaults.qaApiUrl,
+ qaToken: "qa-token",
+ saleApiUrl: "http://sales.example.com/api",
+ saleToken: "sale-token",
+ };
+
+ const normalized = normalizeProductPlanningApiConfig({
+ oneboundBaseUrl: " https://api.example.com/onebound/// ",
+ taobaoApiKey: " demo-key ",
+ taobaoApiSecret: " demo-secret ",
+ qaApiUrl: " ",
+ qaToken: " qa-token ",
+ saleApiUrl: " http://sales.example.com/api/// ",
+ saleToken: " sale-token ",
+ });
+
+ expect(normalized).toEqual(expectedValue);
+ });
+
+ test("product planning config normalization can reuse fallback secrets and urls", () => {
+ const normalized = normalizeProductPlanningApiConfig(
+ {
+ oneboundBaseUrl: " ",
+ taobaoApiKey: "",
+ taobaoApiSecret: " custom-secret ",
+ qaApiUrl: "",
+ qaToken: " ",
+ saleApiUrl: " ",
+ saleToken: "",
+ },
+ {
+ oneboundBaseUrl: "https://fallback.example.com/root/",
+ taobaoApiKey: "fallback-key",
+ taobaoApiSecret: "fallback-secret",
+ qaApiUrl: "https://fallback.example.com/qa/",
+ qaToken: "fallback-qa-token",
+ saleApiUrl: "https://fallback.example.com/sale/",
+ saleToken: "fallback-sale-token",
+ }
+ );
+
+ expect(normalized).toEqual({
+ oneboundBaseUrl: "https://fallback.example.com/root",
+ taobaoApiKey: "fallback-key",
+ taobaoApiSecret: "custom-secret",
+ qaApiUrl: "https://fallback.example.com/qa",
+ qaToken: "fallback-qa-token",
+ saleApiUrl: "https://fallback.example.com/sale",
+ saleToken: "fallback-sale-token",
+ } satisfies ProductPlanningApiConfig);
+ });
+});
diff --git a/tests/routes/product-planning.test.ts b/tests/routes/product-planning.test.ts
new file mode 100644
index 000000000..c4da2de41
--- /dev/null
+++ b/tests/routes/product-planning.test.ts
@@ -0,0 +1,298 @@
+import { expect, test } from "@playwright/test";
+import { encode } from "next-auth/jwt";
+
+const SESSION_COOKIE_NAME = "authjs.session-token";
+
+async function createSessionCookie(type: "regular" | "admin" | "agent_admin" = "regular") {
+ const secret = process.env.AUTH_SECRET;
+ if (!secret) {
+ throw new Error("AUTH_SECRET 未配置,无法生成测试会话");
+ }
+
+ const token = await encode({
+ secret,
+ salt: SESSION_COOKIE_NAME,
+ token: {
+ sub: "product-planning-test-user",
+ id: "product-planning-test-user",
+ email: "product-planning-test@example.com",
+ name: "Product Planning Test User",
+ type,
+ },
+ });
+
+ return `${SESSION_COOKIE_NAME}=${token}`;
+}
+
+test.describe.serial("/api/product-planning unauthorized", () => {
+ test("guest cannot access reviews route", async ({ request }) => {
+ const response = await request.get("/api/product-planning/taobao/reviews?id=1234567890&page=1");
+ expect(response.status()).toBe(401);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ error: "未授权,请先登录",
+ });
+ });
+
+ test("guest cannot access template list route", async ({ request }) => {
+ const response = await request.get("/api/product-planning/templates");
+ expect(response.status()).toBe(401);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ error: "未授权,请先登录",
+ });
+ });
+
+ test("guest cannot access image analyze route", async ({ request }) => {
+ const response = await request.post("/api/product-planning/analyze-image", {
+ data: {
+ itemId: "123",
+ imageUrl: "https://example.com/demo.jpg",
+ },
+ });
+ expect(response.status()).toBe(401);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ error: "未授权,请先登录",
+ });
+ });
+
+ test("guest cannot access SKU analyze route", async ({ request }) => {
+ const response = await request.post("/api/product-planning/analyze-sku", {
+ data: {
+ itemId: "123",
+ platform: "taobao",
+ skus: [{ name: "原木款", price: 199 }],
+ },
+ });
+ expect(response.status()).toBe(401);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ error: "未授权,请先登录",
+ });
+ });
+});
+
+test.describe.serial("/api/product-planning", () => {
+ test("authenticated user gets 400 when reviews id is missing", async ({ request }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.get("/api/product-planning/taobao/reviews?page=1", {
+ headers: {
+ Cookie: cookie,
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "缺少商品ID参数",
+ });
+ });
+
+ test("authenticated user gets 400 when QA itemId is missing", async ({ request }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.post("/api/product-planning/taobao/qa", {
+ headers: {
+ Cookie: cookie,
+ },
+ data: {
+ itemId: "",
+ page: 1,
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "请求参数不合法",
+ });
+ });
+
+ test("authenticated user gets 400 when analyze rows are empty", async ({ request }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.post("/api/product-planning/analyze", {
+ headers: {
+ Cookie: cookie,
+ },
+ data: {
+ kind: "review",
+ rows: [],
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "请求参数不合法或 rows 为空",
+ });
+ });
+
+ test("authenticated user gets 400 when image analyze payload is invalid", async ({ request }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.post("/api/product-planning/analyze-image", {
+ headers: {
+ Cookie: cookie,
+ },
+ data: {
+ itemId: "",
+ imageUrl: "not-a-url",
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "请求参数不合法",
+ });
+ });
+
+ test("authenticated user gets 400 when SKU analyze payload is invalid", async ({ request }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.post("/api/product-planning/analyze-sku", {
+ headers: {
+ Cookie: cookie,
+ },
+ data: {
+ itemId: "",
+ platform: "taobao",
+ skus: [],
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "请求参数不合法或 skus 为空",
+ });
+ });
+
+ test("authenticated user gets 400 when item id is missing for competitor item route", async ({
+ request,
+ }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.get("/api/product-planning/taobao/item", {
+ headers: {
+ Cookie: cookie,
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "缺少商品ID参数",
+ });
+ });
+
+ test("authenticated user gets 400 when item id is missing for JD item route", async ({
+ request,
+ }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.get("/api/product-planning/jd/item", {
+ headers: {
+ Cookie: cookie,
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "缺少商品ID参数",
+ });
+ });
+
+ test("authenticated user gets 400 when item id is missing for month-sale route", async ({
+ request,
+ }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.get("/api/product-planning/taobao/month-sale", {
+ headers: {
+ Cookie: cookie,
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "缺少商品ID参数",
+ });
+ });
+
+ test("authenticated user gets 400 when keyword insight q is missing", async ({ request }) => {
+ const cookie = await createSessionCookie();
+ const response = await request.get("/api/product-planning/keyword-insight", {
+ headers: {
+ Cookie: cookie,
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "缺少关键词参数",
+ });
+ });
+
+ test("regular user cannot upload shared templates", async ({ request }) => {
+ const cookie = await createSessionCookie("regular");
+ const response = await request.post("/api/product-planning/templates", {
+ headers: {
+ Cookie: cookie,
+ },
+ });
+ expect(response.status()).toBe(403);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "需要管理员权限",
+ });
+ });
+
+ test("admin upload rejects missing template file", async ({ request }) => {
+ const cookie = await createSessionCookie("admin");
+ const response = await request.post("/api/product-planning/templates", {
+ headers: {
+ Cookie: cookie,
+ },
+ multipart: {
+ categoryName: "换鞋凳",
+ },
+ });
+ expect(response.status()).toBe(400);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "缺少模板文件",
+ });
+ });
+
+ test("regular user cannot delete shared templates", async ({ request }) => {
+ const cookie = await createSessionCookie("regular");
+ const response = await request.delete("/api/product-planning/templates/mock-template-id", {
+ headers: {
+ Cookie: cookie,
+ },
+ });
+ expect(response.status()).toBe(403);
+
+ const payload = await response.json();
+ expect(payload).toMatchObject({
+ success: false,
+ error: "需要管理员权限",
+ });
+ });
+});