diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index ab2b2babb..ee5dfe96f 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -49,6 +49,7 @@ export default async function AdminLayout({ }))} tutorialEnabled={siteSettings.tutorialEnabled ?? false} rankingEnabled={siteSettings.rankingEnabled ?? true} + productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true} />
{children}
diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 900be2cb3..633cd0c26 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -33,6 +33,12 @@ import { defaultThirdPartySqlServerConfig, THIRD_PARTY_SQLSERVER_CONFIG_KEY, } from "@/lib/sqlserver/config"; +import { + defaultProductPlanningApiConfig, + normalizeProductPlanningApiConfig, + PRODUCT_PLANNING_API_CONFIG_KEY, + type ProductPlanningApiConfig, +} from "@/lib/product-planning/config"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; @@ -255,6 +261,7 @@ type SiteSettings = { tutorialVideoUrl: string | null; tutorialEnabled: boolean; rankingEnabled: boolean; + productPlanningEnabled: boolean; canvasEnabled: boolean; agentEnabled: boolean; canvasAgentModeEnabled: boolean; @@ -696,6 +703,7 @@ export function AdminSettingsClient() { tutorialVideoUrl: null, tutorialEnabled: false, rankingEnabled: true, + productPlanningEnabled: true, canvasEnabled: true, agentEnabled: true, canvasAgentModeEnabled: true, @@ -788,6 +796,7 @@ export function AdminSettingsClient() { tutorialVideoUrl: siteSettingsData.tutorialVideoUrl || null, tutorialEnabled: siteSettingsData.tutorialEnabled ?? false, rankingEnabled: siteSettingsData.rankingEnabled ?? true, + productPlanningEnabled: (siteSettingsData as any).productPlanningEnabled ?? true, canvasEnabled: siteSettingsData.canvasEnabled ?? true, agentEnabled: (siteSettingsData as any).agentEnabled ?? true, canvasAgentModeEnabled: (siteSettingsData as any).canvasAgentModeEnabled ?? true, @@ -1080,6 +1089,8 @@ export function AdminSettingsClient() { ...defaultThirdPartySqlServerConfig, }); const [isTestingThirdPartySqlServer, setIsTestingThirdPartySqlServer] = useState(false); + const [productPlanningApiConfig, setProductPlanningApiConfig] = + useState(defaultProductPlanningApiConfig()); // 加载抠图API配置 const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher); @@ -1174,6 +1185,16 @@ export function AdminSettingsClient() { } }, [watermarkRemoveConfigData]); + const { data: productPlanningApiConfigData } = useSWR( + `/api/admin/settings?key=${PRODUCT_PLANNING_API_CONFIG_KEY}`, + fetcher + ); + useEffect(() => { + setProductPlanningApiConfig( + normalizeProductPlanningApiConfig(productPlanningApiConfigData?.setting?.value) + ); + }, [productPlanningApiConfigData]); + // 加载角度编辑API配置 const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher); useEffect(() => { @@ -2496,6 +2517,36 @@ export function AdminSettingsClient() { } }; + const handleSaveProductPlanningApiConfig = async () => { + try { + const response = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: PRODUCT_PLANNING_API_CONFIG_KEY, + value: { + oneboundBaseUrl: productPlanningApiConfig.oneboundBaseUrl.trim(), + taobaoApiKey: productPlanningApiConfig.taobaoApiKey.trim(), + taobaoApiSecret: productPlanningApiConfig.taobaoApiSecret.trim(), + qaApiUrl: productPlanningApiConfig.qaApiUrl.trim(), + qaToken: productPlanningApiConfig.qaToken.trim(), + saleApiUrl: productPlanningApiConfig.saleApiUrl.trim(), + saleToken: productPlanningApiConfig.saleToken.trim(), + }, + }), + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(payload?.cause || payload?.message || payload?.error || "保存失败"); + } + + toast.success("商品策划工具 API 配置保存成功"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存商品策划工具配置失败"); + } + }; + const handleSaveThirdPartySqlServerConfig = async () => { try { if (thirdPartySqlServerConfig.enabled) { @@ -6316,6 +6367,21 @@ export function AdminSettingsClient() { /> +
+
+ +
+ 控制是否在左侧导航显示商品策划入口;关闭后商品策划页面和相关接口也会一并禁用 +
+
+ + setSiteSettings({ ...siteSettings, productPlanningEnabled: checked }) + } + /> +
+ {/* 功能教程设置 */}
@@ -8664,6 +8730,148 @@ export function AdminSettingsClient() { {/* 本地AI设置 */} + + + 商品策划工具 API 配置 + + 配置商品策划工具页面使用的淘宝 / 京东 Onebound、问大家和销量接口。保存后商品策划相关接口会优先读取这里的配置,未填写时才回退到环境变量。 + + + +
+
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + oneboundBaseUrl: e.target.value, + })) + } + placeholder="https://api-gw.onebound.cn" + /> +
+ 用于淘宝评价、商品详情、关键词洞察以及京东商品详情。 +
+
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + qaApiUrl: e.target.value, + })) + } + placeholder="http://119.91.237.2:5432/taobao_social_feed_aggregate" + /> +
+ 用于商品策划工具中的问大家抓取。 +
+
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + taobaoApiKey: e.target.value, + })) + } + placeholder="请输入 Onebound API Key" + /> +
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + taobaoApiSecret: e.target.value, + })) + } + placeholder="请输入 Onebound API Secret" + /> +
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + qaToken: e.target.value, + })) + } + placeholder="请输入问大家抓取 Token" + /> +
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + saleApiUrl: e.target.value, + })) + } + placeholder="http://119.91.237.2:5432/taobao_item_month_sale" + /> +
+ 用于商品策划工具中的销量查询。 +
+
+ +
+ + + setProductPlanningApiConfig((prev) => ({ + ...prev, + saleToken: e.target.value, + })) + } + placeholder="请输入销量查询 Token" + /> +
+
+ +
+

• 商品策划工具的评价、关键词洞察、淘宝/京东详情、问大家和销量接口都会优先使用这里的配置。

+

• 若某个字段留空,系统会继续尝试读取旧环境变量,避免你切换配置期间功能直接中断。

+
+ +
+ +
+
+
+ Moonshot 官方 Kimi K2.5 配置 diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index 02eb91ce3..a9e627238 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -78,6 +78,7 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) { }))} tutorialEnabled={siteSettings.tutorialEnabled ?? false} rankingEnabled={siteSettings.rankingEnabled ?? true} + productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true} agentEnabled={(siteSettings as any).agentEnabled ?? true} > {children} diff --git a/app/(chat)/product-planning/page.tsx b/app/(chat)/product-planning/page.tsx new file mode 100644 index 000000000..dc38279af --- /dev/null +++ b/app/(chat)/product-planning/page.tsx @@ -0,0 +1,21 @@ +import { notFound, redirect } from "next/navigation"; +import { ProductPlanningPage } from "@/components/product-planning/product-planning-page"; +import { isProductPlanningEnabled } from "@/lib/product-planning/server-feature"; +import { getProductPlanningPageToken } from "@/lib/product-planning/page-auth"; + +export default async function ProductPlanningRoute() { + const enabled = await isProductPlanningEnabled(); + if (!enabled) { + notFound(); + } + + const token = await getProductPlanningPageToken(); + if (!token?.sub) { + redirect("/login"); + } + + const type = String(token.type || "").trim(); + const canManageTemplates = type === "admin" || type === "agent_admin"; + + return ; +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 3a7cbd0e1..90db2241b 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -13,6 +13,10 @@ import { resolveRealtimeProviders, } from "@/lib/realtime/config"; import { DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY } from "@/lib/ai/detail-agent-model-config"; +import { + normalizeProductPlanningApiConfig, + PRODUCT_PLANNING_API_CONFIG_KEY, +} from "@/lib/product-planning/config"; import { normalizeThirdPartySqlServerConfig, THIRD_PARTY_SQLSERVER_CONFIG_KEY, @@ -201,6 +205,10 @@ export async function POST(request: Request) { nextValue = normalizedValue; } + if (key === PRODUCT_PLANNING_API_CONFIG_KEY) { + nextValue = normalizeProductPlanningApiConfig(value); + } + const setting = await upsertSystemSetting({ key, value: nextValue, diff --git a/app/api/product-planning/analyze-image/route.ts b/app/api/product-planning/analyze-image/route.ts new file mode 100644 index 000000000..f5491e4be --- /dev/null +++ b/app/api/product-planning/analyze-image/route.ts @@ -0,0 +1,178 @@ +import { generateText } from "ai"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; +import { + isImageGenerationModelDescriptor, + isVideoModelDescriptor, +} from "@/lib/ai/model-classification"; +import { inlineVisionImages } from "@/lib/ai/vision-inline"; +import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; +import { getDefaultAiModel, getEnabledAiModelsWithProvider } from "@/lib/db/queries"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import type { ProductPlanningAnalyzeResponse } from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const requestSchema = z.object({ + itemId: z.string().trim().min(1), + imageUrl: z.string().trim().url(), + title: z.string().trim().optional(), +}); + +function jsonError(status: number, error: string) { + const payload: ProductPlanningAnalyzeResponse = { + success: false, + error, + }; + return NextResponse.json(payload, { status }); +} + +function supportsVisionAnalysis(model: { + supportsVision?: boolean | null; + capabilities?: unknown; + defaultParams?: unknown; + supportsImageGen?: boolean | null; +}) { + return ( + (Boolean(model.supportsVision) || + isAiModelMultimodal({ + capabilities: model.capabilities, + defaultParams: model.defaultParams, + })) && + !model.supportsImageGen + ); +} + +async function resolvePlanningVisionModel() { + const [defaultModel, enabledModels] = await Promise.all([ + getDefaultAiModel().catch(() => undefined), + getEnabledAiModelsWithProvider(false), + ]); + + const candidates = enabledModels.filter(({ model, provider }) => { + const isVideoModel = isVideoModelDescriptor({ + providerType: provider.type, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }); + const isImageModel = isImageGenerationModelDescriptor({ + providerType: provider.type, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }); + + return !isVideoModel && !isImageModel && supportsVisionAnalysis(model); + }); + + const selected = + (defaultModel ? candidates.find(({ model }) => model.id === defaultModel.id) : undefined) || + candidates[0]; + + if (!selected) { + return null; + } + + const model = await getLanguageModelById(selected.model.id); + if (!model) { + return null; + } + + return model; +} + +function buildImageAnalyzePrompt(params: { itemId: string; title?: string }) { + return [ + "你是一名资深电商视觉策划顾问,请分析这张竞品主图,并输出结构化 markdown。", + `商品ID:${params.itemId}`, + params.title ? `商品标题:${params.title}` : "", + "", + "请至少包含以下部分:", + "1. 画面场景与拍摄方式", + "2. 产品外观与结构要点", + "3. 当前主图正在强调的核心卖点", + "4. 用户感知风险或信息缺口", + "5. 给我们做同类商品主图时可复用的优化建议", + "", + "要求:", + "- 用中文输出", + "- 结论明确,避免空泛形容词", + "- 结合电商转化场景给出建议", + ] + .filter(Boolean) + .join("\n"); +} + +export async function POST(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError(400, "请求体不是合法 JSON"); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return jsonError(400, "请求参数不合法"); + } + + const model = await resolvePlanningVisionModel(); + if (!model) { + return jsonError(500, "当前站点没有可用的图片分析模型"); + } + + try { + const { inlineDataUrls } = await inlineVisionImages([parsed.data.imageUrl], { + maxImages: 1, + maxSide: 900, + timeoutMs: 12000, + retries: 1, + loggerPrefix: "[ProductPlanning][AnalyzeImage]", + }); + const effectiveImage = inlineDataUrls[0] || parsed.data.imageUrl; + + const { text } = await generateText({ + model, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: buildImageAnalyzePrompt(parsed.data), + }, + { + type: "image", + image: effectiveImage, + }, + ], + }, + ], + temperature: 0.2, + maxTokens: 1200, + } as any); + + return NextResponse.json({ + success: true, + markdown: text, + } satisfies ProductPlanningAnalyzeResponse); + } catch (error) { + console.error("[ProductPlanning][AnalyzeImage] request failed:", error); + return jsonError(502, "图片分析失败"); + } +} diff --git a/app/api/product-planning/analyze-sku/route.ts b/app/api/product-planning/analyze-sku/route.ts new file mode 100644 index 000000000..af616bc84 --- /dev/null +++ b/app/api/product-planning/analyze-sku/route.ts @@ -0,0 +1,112 @@ +import { generateText } from "ai"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; +import { isVideoModelDescriptor } from "@/lib/ai/model-classification"; +import { getDefaultAiModel, getEnabledAiModelsWithProvider } from "@/lib/db/queries"; +import { buildProductPlanningSkuAnalyzePrompt } from "@/lib/product-planning/analyze"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import type { + ProductPlanningAnalyzeResponse, + ProductPlanningSkuAnalyzeRequest, +} from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const requestSchema = z.object({ + itemId: z.string().trim().min(1), + title: z.string().trim().optional(), + platform: z.enum(["taobao", "jd"]), + skus: z + .array( + z.object({ + name: z.string().trim().min(1), + price: z.number().nullable(), + }) + ) + .min(1), +}); + +function jsonError(status: number, error: string) { + const payload: ProductPlanningAnalyzeResponse = { + success: false, + error, + }; + return NextResponse.json(payload, { status }); +} + +async function resolvePlanningModel() { + const [defaultModel, enabledModels] = await Promise.all([ + getDefaultAiModel().catch(() => undefined), + getEnabledAiModelsWithProvider(false), + ]); + + const candidates = enabledModels.filter(({ model, provider }) => { + const isVideoModel = isVideoModelDescriptor({ + providerType: provider.type, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }); + return !isVideoModel && !model.supportsImageGen; + }); + + const selected = + (defaultModel ? candidates.find(({ model }) => model.id === defaultModel.id) : undefined) || + candidates[0]; + + if (!selected) return null; + + const model = await getLanguageModelById(selected.model.id); + return model || null; +} + +export async function POST(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError(400, "请求体不是合法 JSON"); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return jsonError(400, "请求参数不合法或 skus 为空"); + } + + const model = await resolvePlanningModel(); + if (!model) { + return jsonError(500, "当前站点没有可用的文本分析模型"); + } + + try { + const analyzeRequest = parsed.data as ProductPlanningSkuAnalyzeRequest; + const prompt = buildProductPlanningSkuAnalyzePrompt(analyzeRequest); + const { text } = await generateText({ + model, + prompt, + temperature: 0.3, + }); + + return NextResponse.json({ + success: true, + markdown: text, + } satisfies ProductPlanningAnalyzeResponse); + } catch (error) { + console.error("[ProductPlanning][AnalyzeSku] request failed:", error); + return jsonError(502, "SKU 卖点分析失败"); + } +} diff --git a/app/api/product-planning/analyze/route.ts b/app/api/product-planning/analyze/route.ts new file mode 100644 index 000000000..22afddd4d --- /dev/null +++ b/app/api/product-planning/analyze/route.ts @@ -0,0 +1,141 @@ +import { generateText } from "ai"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; +import { isVideoModelDescriptor } from "@/lib/ai/model-classification"; +import { getDefaultAiModel, getEnabledAiModelsWithProvider } from "@/lib/db/queries"; +import { buildProductPlanningAnalyzePrompt } from "@/lib/product-planning/analyze"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import type { + ProductPlanningAnalyzeRequest, + ProductPlanningAnalyzeResponse, +} from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const requestSchema = z.union([ + z.object({ + kind: z.literal("review"), + rows: z + .array( + z.object({ + itemId: z.string(), + date: z.string(), + content: z.string(), + appendComment: z.string().optional(), + sku: z.string().optional(), + userId: z.string().optional(), + images: z.array(z.string()), + videoUrls: z.array(z.string()), + }) + ) + .min(1), + }), + z.object({ + kind: z.literal("qa"), + rows: z + .array( + z.object({ + itemId: z.string(), + question: z.string(), + questionDetail: z.string().optional(), + answer: z.string(), + answerUser: z.string().optional(), + answerDate: z.string().optional(), + isOfficial: z.boolean(), + }) + ) + .min(1), + }), +]); + +function jsonError(status: number, error: string) { + const payload: ProductPlanningAnalyzeResponse = { + success: false, + error, + }; + return NextResponse.json(payload, { status }); +} + +async function resolvePlanningModel() { + const [defaultModel, enabledModels] = await Promise.all([ + getDefaultAiModel().catch(() => undefined), + getEnabledAiModelsWithProvider(false), + ]); + + const candidates = enabledModels.filter(({ model, provider }) => { + const isVideoModel = isVideoModelDescriptor({ + providerType: provider.type, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }); + return !isVideoModel && !model.supportsImageGen; + }); + + const selected = + (defaultModel ? candidates.find(({ model }) => model.id === defaultModel.id) : undefined) || + candidates[0]; + + if (!selected) { + return null; + } + + const model = await getLanguageModelById(selected.model.id); + if (!model) { + return null; + } + + return model; +} + +export async function POST(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError(400, "请求体不是合法 JSON"); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return jsonError(400, "请求参数不合法或 rows 为空"); + } + + const model = await resolvePlanningModel(); + if (!model) { + return jsonError(500, "当前站点没有可用的文本分析模型"); + } + + try { + const analyzeRequest = parsed.data as ProductPlanningAnalyzeRequest; + const prompt = buildProductPlanningAnalyzePrompt(analyzeRequest); + const { text } = await generateText({ + model, + prompt, + temperature: 0.3, + }); + + const payload: ProductPlanningAnalyzeResponse = { + success: true, + markdown: text, + }; + return NextResponse.json(payload); + } catch (error) { + console.error("[ProductPlanning][Analyze] request failed:", error); + return jsonError(502, "AI 分析失败"); + } +} diff --git a/app/api/product-planning/jd/item/route.ts b/app/api/product-planning/jd/item/route.ts new file mode 100644 index 000000000..2d5a5d7de --- /dev/null +++ b/app/api/product-planning/jd/item/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getResolvedProductPlanningApiConfig } from "@/lib/product-planning/server-config"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import { normalizeJdItem } from "@/lib/product-planning/taobao"; +import type { ProductPlanningTaobaoItemResponse } from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; + +function jsonError(status: number, error: string) { + const payload: ProductPlanningTaobaoItemResponse = { + success: false, + error, + }; + return NextResponse.json(payload, { status }); +} + +export async function GET(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + const itemId = String(request.nextUrl.searchParams.get("id") || "").trim(); + if (!itemId) { + return jsonError(400, "缺少商品ID参数"); + } + + const config = await getResolvedProductPlanningApiConfig(); + const apiKey = config.taobaoApiKey; + const apiSecret = config.taobaoApiSecret; + if (!apiKey || !apiSecret) { + return jsonError(500, "缺少商品策划工具淘宝 API Key / Secret,请前往系统设置配置"); + } + + const apiUrl = + `${config.oneboundBaseUrl}/jd/item_get_app/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}&domain_type=jd`; + const descApiUrl = + `${config.oneboundBaseUrl}/jd/item_get_desc/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}&domain_type=jd`; + + try { + const [response, descResponse] = await Promise.all([ + fetch(apiUrl, { + signal: AbortSignal.timeout(15000), + headers: { Accept: "application/json" }, + cache: "no-store", + }), + fetch(descApiUrl, { + signal: AbortSignal.timeout(15000), + headers: { Accept: "application/json" }, + cache: "no-store", + }).catch(() => null), + ]); + + if (!response.ok) { + return jsonError(502, `京东商品详情接口请求失败:${response.status}`); + } + + const data = await response.json(); + if (data?.error_code && data.error_code !== "0000") { + return jsonError(502, data.error || data.reason || "京东商品详情接口返回错误"); + } + + if (!data?.item) { + return jsonError(404, "未找到商品信息"); + } + + let descHtml = ""; + if (descResponse?.ok) { + try { + const descData = await descResponse.json(); + descHtml = String(descData?.item?.desc || "").trim(); + } catch (error) { + console.warn("[ProductPlanning][JdItem] parse desc failed:", error); + } + } + + return NextResponse.json({ + success: true, + item: normalizeJdItem( + { + ...data.item, + desc: descHtml || data.item.desc, + }, + itemId + ), + } satisfies ProductPlanningTaobaoItemResponse); + } catch (error) { + console.error("[ProductPlanning][JdItem] request failed:", error); + return jsonError(502, "京东商品详情接口调用失败"); + } +} diff --git a/app/api/product-planning/keyword-insight/route.ts b/app/api/product-planning/keyword-insight/route.ts new file mode 100644 index 000000000..2195bde2b --- /dev/null +++ b/app/api/product-planning/keyword-insight/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getResolvedProductPlanningApiConfig } from "@/lib/product-planning/server-config"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import type { + ProductPlanningKeywordInsightTag, + ProductPlanningKeywordInsightGroup, + ProductPlanningKeywordInsightResponse, + ProductPlanningKeywordInsightSuggestion, +} from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; + +function jsonError(status: number, keyword: string, error: string) { + const payload: ProductPlanningKeywordInsightResponse = { + success: false, + keyword, + suggestions: [], + groups: [], + error, + }; + return NextResponse.json(payload, { status }); +} + +function normalizeSuggestions(items: unknown) { + if (!Array.isArray(items)) return [] as ProductPlanningKeywordInsightSuggestion[]; + + return items + .map((entry, index) => { + if (!Array.isArray(entry) || entry.length < 2) return null; + const keyword = String(entry[0] || "").trim(); + const weight = Number.parseFloat(String(entry[1] || "0")); + if (!keyword) return null; + return { + keyword, + weight: Number.isFinite(weight) ? weight : 0, + rank: index + 1, + } satisfies ProductPlanningKeywordInsightSuggestion; + }) + .filter((item): item is ProductPlanningKeywordInsightSuggestion => Boolean(item)); +} + +function normalizeGroups(items: unknown) { + if (!Array.isArray(items)) return [] as ProductPlanningKeywordInsightGroup[]; + + return items + .map((group) => { + if (!group || typeof group !== "object") return null; + const record = group as Record; + const tagBuckets = Array.isArray(record.data) ? record.data : []; + const tags = tagBuckets.flatMap((bucket) => + Array.isArray(bucket) + ? bucket + .map((tag) => { + if (!tag || typeof tag !== "object") return null; + const tagRecord = tag as Record; + const title = String(tagRecord.title || "").trim(); + if (!title) return null; + return { + title, + type: String(tagRecord.type || "").toLowerCase() === "hot" ? "hot" : "normal", + } as const; + }) + .filter( + (tag): tag is ProductPlanningKeywordInsightTag => Boolean(tag) + ) + : [] + ); + + return { + index: Number.parseInt(String(record.index || "0"), 10) || 0, + type: String(record.type || "tag_group").trim() || "tag_group", + tags, + } satisfies ProductPlanningKeywordInsightGroup; + }) + .filter((item): item is ProductPlanningKeywordInsightGroup => Boolean(item)); +} + +export async function GET(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "", "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, "", PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + const keyword = String(request.nextUrl.searchParams.get("q") || "").trim(); + if (!keyword) { + return jsonError(400, "", "缺少关键词参数"); + } + + const config = await getResolvedProductPlanningApiConfig(); + const apiKey = config.taobaoApiKey; + const apiSecret = config.taobaoApiSecret; + if (!apiKey || !apiSecret) { + return jsonError(500, keyword, "缺少商品策划工具淘宝 API Key / Secret,请前往系统设置配置"); + } + + const apiUrl = + `${config.oneboundBaseUrl}/taobao/item_search_suggest/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&q=${encodeURIComponent(keyword)}&cache=yes`; + + try { + const response = await fetch(apiUrl, { + signal: AbortSignal.timeout(15000), + headers: { Accept: "application/json" }, + cache: "no-store", + }); + + if (!response.ok) { + return jsonError(502, keyword, `关键词洞察接口请求失败:${response.status}`); + } + + const data = await response.json(); + if (data?.error_code && data.error_code !== "0000") { + return jsonError(502, keyword, data.error || data.reason || "关键词洞察接口返回错误"); + } + + return NextResponse.json({ + success: true, + keyword, + suggestions: normalizeSuggestions(data?.items?.result), + groups: normalizeGroups(data?.items?.magic), + } satisfies ProductPlanningKeywordInsightResponse); + } catch (error) { + console.error("[ProductPlanning][KeywordInsight] request failed:", error); + return jsonError(502, keyword, "关键词洞察接口调用失败"); + } +} diff --git a/app/api/product-planning/taobao/item/route.ts b/app/api/product-planning/taobao/item/route.ts new file mode 100644 index 000000000..e1c8b939a --- /dev/null +++ b/app/api/product-planning/taobao/item/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getResolvedProductPlanningApiConfig } from "@/lib/product-planning/server-config"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import { normalizeTaobaoItem } from "@/lib/product-planning/taobao"; +import type { ProductPlanningTaobaoItemResponse } from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; + +function jsonError(status: number, error: string) { + const payload: ProductPlanningTaobaoItemResponse = { + success: false, + error, + }; + return NextResponse.json(payload, { status }); +} + +export async function GET(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + const itemId = String(request.nextUrl.searchParams.get("id") || "").trim(); + if (!itemId) { + return jsonError(400, "缺少商品ID参数"); + } + + const config = await getResolvedProductPlanningApiConfig(); + const apiKey = config.taobaoApiKey; + const apiSecret = config.taobaoApiSecret; + if (!apiKey || !apiSecret) { + return jsonError(500, "缺少商品策划工具淘宝 API Key / Secret,请前往系统设置配置"); + } + + const apiUrl = + `${config.oneboundBaseUrl}/taobao/item_get_pro/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}`; + + try { + const response = await fetch(apiUrl, { + signal: AbortSignal.timeout(15000), + headers: { Accept: "application/json" }, + cache: "no-store", + }); + + if (!response.ok) { + return jsonError(502, `淘宝商品详情接口请求失败:${response.status}`); + } + + const data = await response.json(); + if (data?.error_code && data.error_code !== "0000") { + return jsonError(502, data.error || data.reason || "淘宝商品详情接口返回错误"); + } + + if (!data?.item) { + const fallbackUrl = + `${config.oneboundBaseUrl}/taobao/item_get/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}`; + const fallbackResponse = await fetch(fallbackUrl, { + signal: AbortSignal.timeout(15000), + headers: { Accept: "application/json" }, + cache: "no-store", + }); + + if (!fallbackResponse.ok) { + return jsonError(502, `淘宝商品详情接口请求失败:${fallbackResponse.status}`); + } + + const fallbackData = await fallbackResponse.json(); + if (fallbackData?.error_code && fallbackData.error_code !== "0000") { + return jsonError( + 502, + fallbackData.error || fallbackData.reason || "淘宝商品详情接口返回错误" + ); + } + + if (!fallbackData?.item) { + return jsonError(404, "未找到商品信息"); + } + + return NextResponse.json({ + success: true, + item: normalizeTaobaoItem(fallbackData.item, itemId), + } satisfies ProductPlanningTaobaoItemResponse); + } + + return NextResponse.json({ + success: true, + item: normalizeTaobaoItem(data.item, itemId), + } satisfies ProductPlanningTaobaoItemResponse); + } catch (error) { + console.error("[ProductPlanning][TaobaoItem] request failed:", error); + return jsonError(502, "淘宝商品详情接口调用失败"); + } +} diff --git a/app/api/product-planning/taobao/month-sale/route.ts b/app/api/product-planning/taobao/month-sale/route.ts new file mode 100644 index 000000000..22b7e4b4d --- /dev/null +++ b/app/api/product-planning/taobao/month-sale/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getResolvedProductPlanningApiConfig } from "@/lib/product-planning/server-config"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import { extractMonthSale } from "@/lib/product-planning/taobao"; +import type { ProductPlanningMonthSaleResponse } from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; + +function jsonError(status: number, itemId: string, error: string) { + const payload: ProductPlanningMonthSaleResponse = { + success: false, + itemId, + recentSales: null, + error, + }; + return NextResponse.json(payload, { status }); +} + +export async function GET(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "", "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, "", PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + const itemId = String(request.nextUrl.searchParams.get("id") || "").trim(); + if (!itemId) { + return jsonError(400, "", "缺少商品ID参数"); + } + + const config = await getResolvedProductPlanningApiConfig(); + const saleToken = config.saleToken; + if (!saleToken) { + return jsonError(500, itemId, "缺少商品策划工具销量 Token,请前往系统设置配置"); + } + + const apiUrl = + `${config.saleApiUrl}?itemId=${encodeURIComponent(itemId)}` + + `&token=${encodeURIComponent(saleToken)}&v=3.0`; + + try { + const response = await fetch(apiUrl, { + signal: AbortSignal.timeout(15000), + headers: { Accept: "application/json" }, + cache: "no-store", + }); + + if (!response.ok) { + return jsonError(502, itemId, `月销量接口请求失败:${response.status}`); + } + + const data = await response.json(); + const recentSales = extractMonthSale(data); + + return NextResponse.json({ + success: true, + itemId, + recentSales, + raw: data && typeof data === "object" ? data : null, + } satisfies ProductPlanningMonthSaleResponse); + } catch (error) { + console.error("[ProductPlanning][MonthSale] request failed:", error); + return jsonError(502, itemId, "月销量接口调用失败"); + } +} diff --git a/app/api/product-planning/taobao/qa/route.ts b/app/api/product-planning/taobao/qa/route.ts new file mode 100644 index 000000000..3b352981c --- /dev/null +++ b/app/api/product-planning/taobao/qa/route.ts @@ -0,0 +1,170 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getResolvedProductPlanningApiConfig } from "@/lib/product-planning/server-config"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import type { ProductPlanningRowsResponse, TaobaoQaRow } from "@/lib/product-planning/types"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import { normalizeText, truncateText, uniqueBy } from "@/lib/product-planning/utils"; + +export const runtime = "nodejs"; + +const requestSchema = z.object({ + itemId: z.string().trim().min(1), + page: z.number().int().min(1).max(20).default(1), +}); + +function jsonError(status: number, error: string) { + return NextResponse.json({ success: false, error }, { status }); +} + +function cleanAnswerContent(value: unknown) { + return normalizeText(value).replace( + /\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}(?:\s*[::]\s*[^::\n]+)?[::]?/g, + "" + ); +} + +function formatQaRows(itemId: string, payload: any): TaobaoQaRow[] { + const groups = Array.isArray(payload?.data?.list) + ? payload.data.list + : Array.isArray(payload?.list) + ? payload.list + : []; + + const rows: TaobaoQaRow[] = []; + for (const group of groups) { + const questions = Array.isArray(group) ? group : [group]; + for (const question of questions) { + const questionText = normalizeText(question?.title || question?.question); + const questionDetail = normalizeText(question?.content); + const answers = Array.isArray(question?.answerList) + ? question.answerList + : Array.isArray(question?.subFeeds) + ? question.subFeeds.map((answer: any) => ({ + content: answer?.title || "", + official: answer?.official || false, + user: answer?.user, + date: answer?.date || answer?.gmtCreate || "", + })) + : []; + + if (answers.length === 0) { + rows.push({ + itemId, + question: questionText || questionDetail || "未命名问题", + questionDetail: + questionDetail && questionDetail !== questionText ? questionDetail : undefined, + answer: "", + answerUser: undefined, + answerDate: undefined, + isOfficial: false, + }); + continue; + } + + for (const answer of answers) { + rows.push({ + itemId, + question: questionText || questionDetail || "未命名问题", + questionDetail: + questionDetail && questionDetail !== questionText ? questionDetail : undefined, + answer: truncateText(cleanAnswerContent(answer?.content), 240), + answerUser: + normalizeText(answer?.userNick || answer?.display_user_nick || answer?.user?.nick) || + undefined, + answerDate: normalizeText(answer?.date || answer?.gmtCreate) || undefined, + isOfficial: Boolean(answer?.official), + }); + } + } + } + + return uniqueBy(rows, (row) => + [ + row.itemId, + row.question, + row.questionDetail || "", + row.answer, + row.answerUser || "", + row.answerDate || "", + row.isOfficial ? "1" : "0", + ].join("::") + ); +} + +export async function POST(request: NextRequest) { + const authToken = await getProductPlanningToken(request); + if (!authToken?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError(400, "请求体不是合法 JSON"); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return jsonError(400, "请求参数不合法"); + } + + const config = await getResolvedProductPlanningApiConfig(); + const qaApiToken = config.qaToken; + if (!qaApiToken) { + return jsonError(500, "缺少商品策划工具问大家 Token,请前往系统设置配置"); + } + + const { itemId, page } = parsed.data; + const apiUrl = + `${config.qaApiUrl}?itemId=${encodeURIComponent(itemId)}` + + `&page=${page}&token=${encodeURIComponent(qaApiToken)}`; + + try { + const response = await fetch(apiUrl, { + signal: AbortSignal.timeout(15000), + headers: { + Accept: "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + return jsonError(502, `问大家接口请求失败:${response.status}`); + } + + const data = await response.json(); + const rows = formatQaRows(itemId, data); + const totalPages = + Number.parseInt( + String( + data?.data?.totalPages || + data?.data?.pageCount || + data?.pageCount || + data?.pagination?.pageCount || + 1 + ), + 10 + ) || 1; + + const payload: ProductPlanningRowsResponse = { + success: true, + itemId, + page, + totalPages, + rows, + }; + + return NextResponse.json(payload); + } catch (error) { + console.error("[ProductPlanning][QA] request failed:", error); + return jsonError(502, "问大家接口调用失败"); + } +} diff --git a/app/api/product-planning/taobao/reviews/route.ts b/app/api/product-planning/taobao/reviews/route.ts new file mode 100644 index 000000000..83eb949cb --- /dev/null +++ b/app/api/product-planning/taobao/reviews/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { ProductPlanningRowsResponse, TaobaoReviewRow } from "@/lib/product-planning/types"; +import { getResolvedProductPlanningApiConfig } from "@/lib/product-planning/server-config"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import { ensureHttpsUrl } from "@/lib/product-planning/utils"; + +export const runtime = "nodejs"; + +function normalizeMediaUrl(value: unknown) { + if (typeof value === "string") { + return ensureHttpsUrl(value); + } + + if (value && typeof value === "object") { + const candidate = (value as Record).url; + if (typeof candidate === "string") { + return ensureHttpsUrl(candidate); + } + } + + return ""; +} + +function jsonError(status: number, error: string) { + return NextResponse.json({ success: false, error }, { status }); +} + +export async function GET(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + const itemId = String(request.nextUrl.searchParams.get("id") || "").trim(); + const page = Math.max( + Number.parseInt(String(request.nextUrl.searchParams.get("page") || "1"), 10) || 1, + 1 + ); + + if (!itemId) { + return jsonError(400, "缺少商品ID参数"); + } + + const config = await getResolvedProductPlanningApiConfig(); + const apiKey = config.taobaoApiKey; + const apiSecret = config.taobaoApiSecret; + if (!apiKey || !apiSecret) { + return jsonError(500, "缺少商品策划工具淘宝 API Key / Secret,请前往系统设置配置"); + } + + const apiUrl = + `${config.oneboundBaseUrl}/taobao/item_review/?key=${encodeURIComponent(apiKey)}` + + `&secret=${encodeURIComponent(apiSecret)}&num_iid=${encodeURIComponent(itemId)}` + + `&page=${page}&version=1&cache=no&lang=zh-CN`; + + try { + const response = await fetch(apiUrl, { + signal: AbortSignal.timeout(15000), + headers: { + Accept: "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + return jsonError(502, `淘宝评价接口请求失败:${response.status}`); + } + + const data = await response.json(); + if (data?.error_code && data.error_code !== "0000") { + return jsonError(502, data.error || data.reason || "淘宝评价接口返回错误"); + } + + const rows: TaobaoReviewRow[] = Array.isArray(data?.items?.item) + ? data.items.item.map((item: any) => ({ + itemId, + date: String(item?.rate_date || "").trim(), + content: String(item?.rate_content || "").trim(), + appendComment: String(item?.add_feedback || "").trim() || undefined, + sku: String(item?.auction_sku || "").trim() || undefined, + userId: String(item?.display_user_nick || "").trim() || undefined, + images: Array.isArray(item?.pics) + ? item.pics.map((image: unknown) => normalizeMediaUrl(image)).filter(Boolean) + : [], + videoUrls: item?.video ? [normalizeMediaUrl(item.video)].filter(Boolean) : [], + })) + : []; + + const payload: ProductPlanningRowsResponse = { + success: true, + itemId, + page, + totalPages: Number.parseInt(String(data?.items?.totalpage || "1"), 10) || 1, + rows, + }; + + return NextResponse.json(payload); + } catch (error) { + console.error("[ProductPlanning][Reviews] request failed:", error); + return jsonError(502, "淘宝评价接口调用失败"); + } +} diff --git a/app/api/product-planning/templates/[id]/route.ts b/app/api/product-planning/templates/[id]/route.ts new file mode 100644 index 000000000..8e83857ec --- /dev/null +++ b/app/api/product-planning/templates/[id]/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDefaultStorageConfig, getSystemSetting, upsertSystemSetting } from "@/lib/db/queries"; +import { + PRODUCT_PLANNING_TEMPLATES_KEY, + parseProductPlanningTemplates, +} from "@/lib/product-planning/templates"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import type { ProductPlanningTemplateMutationResponse } from "@/lib/product-planning/types"; + +export const runtime = "nodejs"; + +function isAdminToken(token: Awaited>) { + const type = String(token?.type || "").trim(); + return type === "admin" || type === "agent_admin"; +} + +function jsonError(status: number, error: string) { + const payload: ProductPlanningTemplateMutationResponse = { + success: false, + error, + }; + return NextResponse.json(payload, { status }); +} + +function encodeAttachmentFileName(fileName: string) { + const fallback = fileName.replace(/[^\x20-\x7E]+/g, "_") || "template.xlsx"; + return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`; +} + +async function readTemplates() { + const setting = await getSystemSetting({ key: PRODUCT_PLANNING_TEMPLATES_KEY }); + return parseProductPlanningTemplates(setting?.value); +} + +async function tryDeleteTemplateFile(filePath?: string) { + if (!filePath) return; + + try { + const storageConfig = await getDefaultStorageConfig().catch(() => undefined); + if (!storageConfig || storageConfig.type !== "s3") return; + + const { deleteS3ObjectByPath, toDbS3UploadConfig } = await import("@/lib/storage/service"); + const uploadConfig = toDbS3UploadConfig(storageConfig); + if (!uploadConfig) return; + + await deleteS3ObjectByPath(filePath, uploadConfig); + } catch (error) { + console.warn("[ProductPlanning][Templates] delete file failed:", error); + } +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + try { + const { id } = await context.params; + const templates = await readTemplates(); + const template = templates.find((entry) => entry.id === id); + if (!template) { + return jsonError(404, "未找到模板"); + } + + const response = await fetch(template.fileUrl, { + headers: { + Accept: template.contentType || "*/*", + }, + cache: "no-store", + signal: AbortSignal.timeout(20000), + }); + + if (!response.ok) { + return jsonError(502, "模板文件下载失败"); + } + + const buffer = await response.arrayBuffer(); + return new NextResponse(buffer, { + status: 200, + headers: { + "Content-Type": template.contentType || response.headers.get("content-type") || "application/octet-stream", + "Content-Length": String(buffer.byteLength), + "Content-Disposition": encodeAttachmentFileName(template.fileName), + "Cache-Control": "no-store", + }, + }); + } catch (error) { + console.error("[ProductPlanning][Templates] download failed:", error); + return jsonError(502, "模板文件下载失败"); + } +} + +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + if (!isAdminToken(token)) { + return jsonError(403, "需要管理员权限"); + } + + try { + const { id } = await context.params; + const templates = await readTemplates(); + const template = templates.find((entry) => entry.id === id); + if (!template) { + return jsonError(404, "未找到模板"); + } + + await upsertSystemSetting({ + key: PRODUCT_PLANNING_TEMPLATES_KEY, + value: templates.filter((entry) => entry.id !== id), + updatedBy: String(token.sub), + }); + + await tryDeleteTemplateFile(template.filePath); + + return NextResponse.json({ + success: true, + } satisfies ProductPlanningTemplateMutationResponse); + } catch (error) { + console.error("[ProductPlanning][Templates] delete failed:", error); + return jsonError(502, "模板删除失败"); + } +} diff --git a/app/api/product-planning/templates/route.ts b/app/api/product-planning/templates/route.ts new file mode 100644 index 000000000..77822f63b --- /dev/null +++ b/app/api/product-planning/templates/route.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { getDefaultStorageConfig, getSystemSetting, upsertSystemSetting } from "@/lib/db/queries"; +import { + PRODUCT_PLANNING_TEMPLATES_KEY, + normalizeTemplateCategoryName, + parseProductPlanningTemplates, + upsertProductPlanningTemplate, +} from "@/lib/product-planning/templates"; +import { + isProductPlanningEnabled, + PRODUCT_PLANNING_DISABLED_MESSAGE, +} from "@/lib/product-planning/server-feature"; +import type { + ProductPlanningTemplate, + ProductPlanningTemplateListResponse, + ProductPlanningTemplateMutationResponse, +} from "@/lib/product-planning/types"; +import { getProductPlanningToken } from "@/lib/product-planning/server-auth"; +import { uploadFile } from "@/lib/storage/service"; + +export const runtime = "nodejs"; + +const MAX_TEMPLATE_FILE_SIZE = 10 * 1024 * 1024; +const ALLOWED_TEMPLATE_EXTENSIONS = new Set(["txt", "csv", "xls", "xlsx"]); + +function isAdminToken(token: Awaited>) { + const type = String(token?.type || "").trim(); + return type === "admin" || type === "agent_admin"; +} + +function jsonListError(status: number, error: string) { + const payload: ProductPlanningTemplateListResponse = { + success: false, + templates: [], + error, + }; + return NextResponse.json(payload, { status }); +} + +function jsonMutationError(status: number, error: string) { + const payload: ProductPlanningTemplateMutationResponse = { + success: false, + error, + }; + return NextResponse.json(payload, { status }); +} + +function getTemplateExtension(fileName: string) { + return fileName.toLowerCase().split(".").pop() || ""; +} + +async function readTemplates() { + const setting = await getSystemSetting({ key: PRODUCT_PLANNING_TEMPLATES_KEY }); + return parseProductPlanningTemplates(setting?.value); +} + +async function tryDeleteLegacyTemplateFile(template: ProductPlanningTemplate) { + if (!template.filePath) return; + + try { + const storageConfig = await getDefaultStorageConfig().catch(() => undefined); + if (!storageConfig || storageConfig.type !== "s3") return; + + const { deleteS3ObjectByPath, toDbS3UploadConfig } = await import("@/lib/storage/service"); + const uploadConfig = toDbS3UploadConfig(storageConfig); + if (!uploadConfig) return; + + await deleteS3ObjectByPath(template.filePath, uploadConfig); + } catch (error) { + console.warn("[ProductPlanning][Templates] failed to delete legacy template file:", error); + } +} + +export async function GET(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonListError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonListError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + try { + const templates = await readTemplates(); + return NextResponse.json({ + success: true, + templates, + } satisfies ProductPlanningTemplateListResponse); + } catch (error) { + console.error("[ProductPlanning][Templates] list failed:", error); + return jsonListError(503, "模板库读取失败"); + } +} + +export async function POST(request: NextRequest) { + const token = await getProductPlanningToken(request); + if (!token?.sub) { + return jsonMutationError(401, "未登录"); + } + if (!(await isProductPlanningEnabled())) { + return jsonMutationError(403, PRODUCT_PLANNING_DISABLED_MESSAGE); + } + + if (!isAdminToken(token)) { + return jsonMutationError(403, "需要管理员权限"); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return jsonMutationError(400, "上传表单解析失败"); + } + + const categoryName = normalizeTemplateCategoryName(formData.get("categoryName")); + const description = String(formData.get("description") || "").trim(); + const file = formData.get("file"); + + if (!categoryName) { + return jsonMutationError(400, "缺少模板名称"); + } + + if (categoryName.length > 50) { + return jsonMutationError(400, "模板名称不能超过 50 个字符"); + } + + if (!(file instanceof File)) { + return jsonMutationError(400, "缺少模板文件"); + } + + const extension = getTemplateExtension(file.name); + if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) { + return jsonMutationError(400, "模板文件仅支持 txt、csv、xls、xlsx"); + } + + if (file.size <= 0) { + return jsonMutationError(400, "模板文件不能为空"); + } + + if (file.size > MAX_TEMPLATE_FILE_SIZE) { + return jsonMutationError(400, "模板文件不能超过 10MB"); + } + + try { + const buffer = await file.arrayBuffer(); + const uploaded = await uploadFile(file.name, buffer); + const now = new Date().toISOString(); + const nextTemplate: ProductPlanningTemplate = { + id: randomUUID(), + categoryName, + description: description || undefined, + fileName: file.name, + fileUrl: uploaded.url, + filePath: uploaded.pathname, + fileSize: file.size, + contentType: uploaded.contentType || file.type || undefined, + createdAt: now, + updatedAt: now, + uploadedById: String(token.sub), + }; + + const currentTemplates = await readTemplates(); + const replacedTemplates = currentTemplates.filter( + (template) => + normalizeTemplateCategoryName(template.categoryName) === + normalizeTemplateCategoryName(categoryName) + ); + const templates = upsertProductPlanningTemplate(currentTemplates, nextTemplate); + + await upsertSystemSetting({ + key: PRODUCT_PLANNING_TEMPLATES_KEY, + value: templates, + updatedBy: String(token.sub), + }); + + await Promise.all(replacedTemplates.map((template) => tryDeleteLegacyTemplateFile(template))); + + return NextResponse.json({ + success: true, + template: nextTemplate, + } satisfies ProductPlanningTemplateMutationResponse); + } catch (error) { + console.error("[ProductPlanning][Templates] upload failed:", error); + return jsonMutationError(502, "模板上传失败"); + } +} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index a4eb27fe6..76f2aaa45 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -507,6 +507,7 @@ export async function GET() { tutorialVideoUrl: null, tutorialEnabled: false, rankingEnabled: true, + productPlanningEnabled: true, canvasEnabled: true, agentEnabled: true, canvasAgentModeEnabled: true, @@ -615,6 +616,7 @@ const updateSchema = z.object({ tutorialVideoUrl: z.string().nullable().optional(), tutorialEnabled: z.boolean().optional(), rankingEnabled: z.boolean().optional(), + productPlanningEnabled: z.boolean().optional(), canvasEnabled: z.boolean().optional(), agentEnabled: z.boolean().optional(), canvasAgentModeEnabled: z.boolean().optional(), @@ -1546,6 +1548,7 @@ export async function PUT(request: NextRequest) { tutorialVideoUrl: siteSettingsPayload.tutorialVideoUrl, tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false, rankingEnabled: siteSettingsPayload.rankingEnabled ?? true, + productPlanningEnabled: siteSettingsPayload.productPlanningEnabled ?? true, canvasEnabled: siteSettingsPayload.canvasEnabled ?? true, agentEnabled: siteSettingsPayload.agentEnabled ?? true, realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true, diff --git a/app/ping/route.ts b/app/ping/route.ts new file mode 100644 index 000000000..11f290147 --- /dev/null +++ b/app/ping/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/components/chat-shell.tsx b/components/chat-shell.tsx index f2b90e05f..93c564db5 100644 --- a/components/chat-shell.tsx +++ b/components/chat-shell.tsx @@ -41,6 +41,7 @@ type ChatShellProps = { tutorialVideos: TutorialVideo[]; tutorialEnabled: boolean; rankingEnabled: boolean; + productPlanningEnabled: boolean; agentEnabled: boolean; }; @@ -62,6 +63,7 @@ export function ChatShell({ tutorialVideos, tutorialEnabled, rankingEnabled, + productPlanningEnabled, agentEnabled, }: ChatShellProps) { const pathname = usePathname(); @@ -87,6 +89,7 @@ export function ChatShell({ tutorialVideos={tutorialVideos} tutorialEnabled={tutorialEnabled} rankingEnabled={rankingEnabled} + productPlanningEnabled={productPlanningEnabled} agentEnabled={agentEnabled} />
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 ( +
+ + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
商品ID评论时间评价内容追评SKU买家ID
{row.itemId || "-"}{row.date || "-"}{row.content || "-"}{row.appendComment || "-"}{row.sku || "-"}{row.userId || "-"}
+
+ ); +} + +function QaTable({ rows }: { rows: TaobaoQaRow[] }) { + if (rows.length === 0) return ( +
+ 还没有问大家数据。 +
+ ); + + return ( +
+ + + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + + ))} + +
商品ID问题问题补充回答回答用户回答时间官方
{row.itemId || "-"}{row.question || "-"}{row.questionDetail || "-"}{row.answer || "-"}{row.answerUser || "-"}{row.answerDate || "-"}{row.isOfficial ? "是" : "否"}
+
+ ); +} + +function formatInteger(value: number | null | undefined) { + if (typeof value !== "number" || Number.isNaN(value)) return "-"; + return value.toLocaleString("zh-CN"); +} + +function formatPrice(value: number | null | undefined) { + if (typeof value !== "number" || Number.isNaN(value)) return "-"; + return `¥${value.toFixed(2)}`; +} + +function formatPlatform(platform: ProductPlanningPlatform, isTmall = false) { + if (platform === "jd") return "京东"; + return isTmall ? "天猫" : "淘宝"; +} + +function 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 ( +
+ + + + + + + + + + + + + + + + + {rows.map((row) => { + const mainImageKey = `${row.platform}:${row.itemId}:main`; + const skuKey = `${row.platform}:${row.itemId}:sku`; + + return ( + + + + + + + + + + + + + ); + })} + +
平台商品ID主图标题店铺当前价格总销量近期销量材质SKU概览
{formatPlatform(row.platform, row.isTmall)}{row.itemId} + {row.mainImage ? ( +
+ {row.title} + + +
+ ) : ( + "-" + )} +
+
+

{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 ( +
+ + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + + ))} + +
平台商品ID标题近期销量当前价格店铺发货地材质
{formatPlatform(row.platform, row.isTmall)}{row.itemId} +
+

{row.title}

+ {row.detailUrl ? ( + + 查看商品链接 + + ) : null} +
+
{formatInteger(row.recentSales)}{formatPrice(row.price)}{row.shopName || "-"}{row.location || "-"}{row.material || "-"}
+
+ ); +} + +function 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}

+
+ + +
+
+ {`${item.title}-${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,并自动识别关键词、搜索人气、支付转化率等列。"} +

+
+ +
+ +