feat: migrate product planning workspace
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-26 16:38:48 +08:00
parent 5f54531f75
commit 478562c312
46 changed files with 7658 additions and 6 deletions

View File

@ -49,6 +49,7 @@ export default async function AdminLayout({
}))} }))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false} tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true} rankingEnabled={siteSettings.rankingEnabled ?? true}
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
/> />
<div className="flex-1 pl-16">{children}</div> <div className="flex-1 pl-16">{children}</div>
</div> </div>

View File

@ -33,6 +33,12 @@ import {
defaultThirdPartySqlServerConfig, defaultThirdPartySqlServerConfig,
THIRD_PARTY_SQLSERVER_CONFIG_KEY, THIRD_PARTY_SQLSERVER_CONFIG_KEY,
} from "@/lib/sqlserver/config"; } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
@ -255,6 +261,7 @@ type SiteSettings = {
tutorialVideoUrl: string | null; tutorialVideoUrl: string | null;
tutorialEnabled: boolean; tutorialEnabled: boolean;
rankingEnabled: boolean; rankingEnabled: boolean;
productPlanningEnabled: boolean;
canvasEnabled: boolean; canvasEnabled: boolean;
agentEnabled: boolean; agentEnabled: boolean;
canvasAgentModeEnabled: boolean; canvasAgentModeEnabled: boolean;
@ -696,6 +703,7 @@ export function AdminSettingsClient() {
tutorialVideoUrl: null, tutorialVideoUrl: null,
tutorialEnabled: false, tutorialEnabled: false,
rankingEnabled: true, rankingEnabled: true,
productPlanningEnabled: true,
canvasEnabled: true, canvasEnabled: true,
agentEnabled: true, agentEnabled: true,
canvasAgentModeEnabled: true, canvasAgentModeEnabled: true,
@ -788,6 +796,7 @@ export function AdminSettingsClient() {
tutorialVideoUrl: siteSettingsData.tutorialVideoUrl || null, tutorialVideoUrl: siteSettingsData.tutorialVideoUrl || null,
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false, tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
rankingEnabled: siteSettingsData.rankingEnabled ?? true, rankingEnabled: siteSettingsData.rankingEnabled ?? true,
productPlanningEnabled: (siteSettingsData as any).productPlanningEnabled ?? true,
canvasEnabled: siteSettingsData.canvasEnabled ?? true, canvasEnabled: siteSettingsData.canvasEnabled ?? true,
agentEnabled: (siteSettingsData as any).agentEnabled ?? true, agentEnabled: (siteSettingsData as any).agentEnabled ?? true,
canvasAgentModeEnabled: (siteSettingsData as any).canvasAgentModeEnabled ?? true, canvasAgentModeEnabled: (siteSettingsData as any).canvasAgentModeEnabled ?? true,
@ -1080,6 +1089,8 @@ export function AdminSettingsClient() {
...defaultThirdPartySqlServerConfig, ...defaultThirdPartySqlServerConfig,
}); });
const [isTestingThirdPartySqlServer, setIsTestingThirdPartySqlServer] = useState(false); const [isTestingThirdPartySqlServer, setIsTestingThirdPartySqlServer] = useState(false);
const [productPlanningApiConfig, setProductPlanningApiConfig] =
useState<ProductPlanningApiConfig>(defaultProductPlanningApiConfig());
// 加载抠图API配置 // 加载抠图API配置
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher); const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
@ -1174,6 +1185,16 @@ export function AdminSettingsClient() {
} }
}, [watermarkRemoveConfigData]); }, [watermarkRemoveConfigData]);
const { data: productPlanningApiConfigData } = useSWR(
`/api/admin/settings?key=${PRODUCT_PLANNING_API_CONFIG_KEY}`,
fetcher
);
useEffect(() => {
setProductPlanningApiConfig(
normalizeProductPlanningApiConfig(productPlanningApiConfigData?.setting?.value)
);
}, [productPlanningApiConfigData]);
// 加载角度编辑API配置 // 加载角度编辑API配置
const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher); const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher);
useEffect(() => { 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 () => { const handleSaveThirdPartySqlServerConfig = async () => {
try { try {
if (thirdPartySqlServerConfig.enabled) { if (thirdPartySqlServerConfig.enabled) {
@ -6316,6 +6367,21 @@ export function AdminSettingsClient() {
/> />
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1">
<Label className="text-base">商品策划</Label>
<div className="text-sm text-muted-foreground">
控制是否在左侧导航显示商品策划入口;关闭后商品策划页面和相关接口也会一并禁用
</div>
</div>
<Switch
checked={siteSettings.productPlanningEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, productPlanningEnabled: checked })
}
/>
</div>
{/* 功能教程设置 */} {/* 功能教程设置 */}
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
@ -8664,6 +8730,148 @@ export function AdminSettingsClient() {
{/* 本地AI设置 */} {/* 本地AI设置 */}
<TabsContent value="rmbg" className="mt-6 space-y-6"> <TabsContent value="rmbg" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">商品策划工具 API 配置</CardTitle>
<CardDescription>
配置商品策划工具页面使用的淘宝 / 京东 Onebound、问大家和销量接口。保存后商品策划相关接口会优先读取这里的配置,未填写时才回退到环境变量。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="productPlanningOneboundBaseUrl">Onebound API 地址</Label>
<Input
id="productPlanningOneboundBaseUrl"
value={productPlanningApiConfig.oneboundBaseUrl}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
oneboundBaseUrl: e.target.value,
}))
}
placeholder="https://api-gw.onebound.cn"
/>
<div className="text-xs text-muted-foreground">
用于淘宝评价、商品详情、关键词洞察以及京东商品详情。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningQaApiUrl">问大家接口地址</Label>
<Input
id="productPlanningQaApiUrl"
value={productPlanningApiConfig.qaApiUrl}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
qaApiUrl: e.target.value,
}))
}
placeholder="http://119.91.237.2:5432/taobao_social_feed_aggregate"
/>
<div className="text-xs text-muted-foreground">
用于商品策划工具中的问大家抓取。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningTaobaoApiKey">淘宝 / 京东 API Key</Label>
<Input
id="productPlanningTaobaoApiKey"
type="password"
value={productPlanningApiConfig.taobaoApiKey}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
taobaoApiKey: e.target.value,
}))
}
placeholder="请输入 Onebound API Key"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningTaobaoApiSecret">淘宝 / 京东 API Secret</Label>
<Input
id="productPlanningTaobaoApiSecret"
type="password"
value={productPlanningApiConfig.taobaoApiSecret}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
taobaoApiSecret: e.target.value,
}))
}
placeholder="请输入 Onebound API Secret"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningQaToken">问大家 Token</Label>
<Input
id="productPlanningQaToken"
type="password"
value={productPlanningApiConfig.qaToken}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
qaToken: e.target.value,
}))
}
placeholder="请输入问大家抓取 Token"
/>
</div>
<div className="space-y-2">
<Label htmlFor="productPlanningSaleApiUrl">销量接口地址</Label>
<Input
id="productPlanningSaleApiUrl"
value={productPlanningApiConfig.saleApiUrl}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
saleApiUrl: e.target.value,
}))
}
placeholder="http://119.91.237.2:5432/taobao_item_month_sale"
/>
<div className="text-xs text-muted-foreground">
用于商品策划工具中的销量查询。
</div>
</div>
<div className="space-y-2 lg:col-span-2">
<Label htmlFor="productPlanningSaleToken">销量 Token</Label>
<Input
id="productPlanningSaleToken"
type="password"
value={productPlanningApiConfig.saleToken}
onChange={(e) =>
setProductPlanningApiConfig((prev) => ({
...prev,
saleToken: e.target.value,
}))
}
placeholder="请输入销量查询 Token"
/>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-800 space-y-1">
<p>• 商品策划工具的评价、关键词洞察、淘宝/京东详情、问大家和销量接口都会优先使用这里的配置。</p>
<p>• 若某个字段留空,系统会继续尝试读取旧环境变量,避免你切换配置期间功能直接中断。</p>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveProductPlanningApiConfig}>
<Save className="mr-2 h-4 w-4" />
保存商品策划工具配置
</Button>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Moonshot 官方 Kimi K2.5 配置</CardTitle> <CardTitle className="text-xl">Moonshot 官方 Kimi K2.5 配置</CardTitle>

View File

@ -78,6 +78,7 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
}))} }))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false} tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true} rankingEnabled={siteSettings.rankingEnabled ?? true}
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
agentEnabled={(siteSettings as any).agentEnabled ?? true} agentEnabled={(siteSettings as any).agentEnabled ?? true}
> >
{children} {children}

View File

@ -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 <ProductPlanningPage canManageTemplates={canManageTemplates} />;
}

View File

@ -13,6 +13,10 @@ import {
resolveRealtimeProviders, resolveRealtimeProviders,
} from "@/lib/realtime/config"; } from "@/lib/realtime/config";
import { DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY } from "@/lib/ai/detail-agent-model-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 { import {
normalizeThirdPartySqlServerConfig, normalizeThirdPartySqlServerConfig,
THIRD_PARTY_SQLSERVER_CONFIG_KEY, THIRD_PARTY_SQLSERVER_CONFIG_KEY,
@ -201,6 +205,10 @@ export async function POST(request: Request) {
nextValue = normalizedValue; nextValue = normalizedValue;
} }
if (key === PRODUCT_PLANNING_API_CONFIG_KEY) {
nextValue = normalizeProductPlanningApiConfig(value);
}
const setting = await upsertSystemSetting({ const setting = await upsertSystemSetting({
key, key,
value: nextValue, value: nextValue,

View File

@ -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, "图片分析失败");
}
}

View File

@ -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 卖点分析失败");
}
}

View File

@ -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 分析失败");
}
}

View File

@ -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, "京东商品详情接口调用失败");
}
}

View File

@ -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<string, unknown>;
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<string, unknown>;
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, "关键词洞察接口调用失败");
}
}

View File

@ -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, "淘宝商品详情接口调用失败");
}
}

View File

@ -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, "月销量接口调用失败");
}
}

View File

@ -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<TaobaoQaRow> = {
success: true,
itemId,
page,
totalPages,
rows,
};
return NextResponse.json(payload);
} catch (error) {
console.error("[ProductPlanning][QA] request failed:", error);
return jsonError(502, "问大家接口调用失败");
}
}

View File

@ -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<string, unknown>).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<TaobaoReviewRow> = {
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, "淘宝评价接口调用失败");
}
}

View File

@ -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<ReturnType<typeof getProductPlanningToken>>) {
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, "模板删除失败");
}
}

View File

@ -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<ReturnType<typeof getProductPlanningToken>>) {
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, "模板上传失败");
}
}

View File

@ -507,6 +507,7 @@ export async function GET() {
tutorialVideoUrl: null, tutorialVideoUrl: null,
tutorialEnabled: false, tutorialEnabled: false,
rankingEnabled: true, rankingEnabled: true,
productPlanningEnabled: true,
canvasEnabled: true, canvasEnabled: true,
agentEnabled: true, agentEnabled: true,
canvasAgentModeEnabled: true, canvasAgentModeEnabled: true,
@ -615,6 +616,7 @@ const updateSchema = z.object({
tutorialVideoUrl: z.string().nullable().optional(), tutorialVideoUrl: z.string().nullable().optional(),
tutorialEnabled: z.boolean().optional(), tutorialEnabled: z.boolean().optional(),
rankingEnabled: z.boolean().optional(), rankingEnabled: z.boolean().optional(),
productPlanningEnabled: z.boolean().optional(),
canvasEnabled: z.boolean().optional(), canvasEnabled: z.boolean().optional(),
agentEnabled: z.boolean().optional(), agentEnabled: z.boolean().optional(),
canvasAgentModeEnabled: z.boolean().optional(), canvasAgentModeEnabled: z.boolean().optional(),
@ -1546,6 +1548,7 @@ export async function PUT(request: NextRequest) {
tutorialVideoUrl: siteSettingsPayload.tutorialVideoUrl, tutorialVideoUrl: siteSettingsPayload.tutorialVideoUrl,
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false, tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true, rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
productPlanningEnabled: siteSettingsPayload.productPlanningEnabled ?? true,
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true, canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
agentEnabled: siteSettingsPayload.agentEnabled ?? true, agentEnabled: siteSettingsPayload.agentEnabled ?? true,
realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true, realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true,

5
app/ping/route.ts Normal file
View File

@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export function GET() {
return NextResponse.json({ ok: true });
}

View File

@ -41,6 +41,7 @@ type ChatShellProps = {
tutorialVideos: TutorialVideo[]; tutorialVideos: TutorialVideo[];
tutorialEnabled: boolean; tutorialEnabled: boolean;
rankingEnabled: boolean; rankingEnabled: boolean;
productPlanningEnabled: boolean;
agentEnabled: boolean; agentEnabled: boolean;
}; };
@ -62,6 +63,7 @@ export function ChatShell({
tutorialVideos, tutorialVideos,
tutorialEnabled, tutorialEnabled,
rankingEnabled, rankingEnabled,
productPlanningEnabled,
agentEnabled, agentEnabled,
}: ChatShellProps) { }: ChatShellProps) {
const pathname = usePathname(); const pathname = usePathname();
@ -87,6 +89,7 @@ export function ChatShell({
tutorialVideos={tutorialVideos} tutorialVideos={tutorialVideos}
tutorialEnabled={tutorialEnabled} tutorialEnabled={tutorialEnabled}
rankingEnabled={rankingEnabled} rankingEnabled={rankingEnabled}
productPlanningEnabled={productPlanningEnabled}
agentEnabled={agentEnabled} agentEnabled={agentEnabled}
/> />
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]"> <div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X } from "lucide-react"; import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X, BarChart3 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
@ -57,6 +57,7 @@ type TopNavbarProps = {
tutorialVideos?: TutorialVideo[]; tutorialVideos?: TutorialVideo[];
tutorialEnabled?: boolean; tutorialEnabled?: boolean;
rankingEnabled?: boolean; rankingEnabled?: boolean;
productPlanningEnabled?: boolean;
agentEnabled?: boolean; agentEnabled?: boolean;
}; };
@ -74,6 +75,7 @@ export function TopNavbar({
tutorialVideos = [], tutorialVideos = [],
tutorialEnabled = false, tutorialEnabled = false,
rankingEnabled = true, rankingEnabled = true,
productPlanningEnabled = true,
agentEnabled = true, agentEnabled = true,
}: TopNavbarProps) { }: TopNavbarProps) {
const router = useRouter(); const router = useRouter();
@ -141,6 +143,7 @@ export function TopNavbar({
if (trimmed === "智能体") return "智能"; if (trimmed === "智能体") return "智能";
if (trimmed === "无限画布") return "画布"; if (trimmed === "无限画布") return "画布";
if (trimmed === "我的图册") return "图册"; if (trimmed === "我的图册") return "图册";
if (trimmed === "商品策划") return "策划";
if (trimmed === "系统设置") return "设置"; if (trimmed === "系统设置") return "设置";
if (trimmed === "工具箱") return "工具"; if (trimmed === "工具箱") return "工具";
if (trimmed === "工具箱2") return "工具"; if (trimmed === "工具箱2") return "工具";
@ -318,6 +321,21 @@ export function TopNavbar({
<span className={navLabelClass}>{getShortLabel("AI精选")}</span> <span className={navLabelClass}>{getShortLabel("AI精选")}</span>
</a> </a>
{productPlanningEnabled && (
<a
href="/product-planning"
className={navItemBaseClass}
title="商品策划"
onClick={(e) => {
e.preventDefault();
window.location.href = "/product-planning";
}}
>
<BarChart3 className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("商品策划")}</span>
</a>
)}
{/* 功能教程 */} {/* 功能教程 */}
{tutorialEnabled && tutorialVideos.length > 0 && ( {tutorialEnabled && tutorialVideos.length > 0 && (
<DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}> <DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}>

View File

@ -0,0 +1,2 @@
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "productPlanningEnabled" boolean DEFAULT true NOT NULL;

View File

@ -330,6 +330,13 @@
"when": 1774074000000, "when": 1774074000000,
"tag": "0060_add_canvas_detail_agent_model", "tag": "0060_add_canvas_detail_agent_model",
"breakpoints": true "breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1774483200000,
"tag": "0061_add_product_planning_enabled_site_setting",
"breakpoints": true
} }
] ]
} }

View File

@ -4081,6 +4081,7 @@ export async function getSiteSettings() {
tutorialVideoUrl: null, tutorialVideoUrl: null,
tutorialEnabled: false, tutorialEnabled: false,
rankingEnabled: true, rankingEnabled: true,
productPlanningEnabled: true,
agentEnabled: true, agentEnabled: true,
canvasDetailAgentModelId: null, canvasDetailAgentModelId: null,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",

View File

@ -374,6 +374,7 @@ export const siteSettings = pgTable("SiteSettings", {
tutorialEnabled: boolean("tutorialEnabled").notNull().default(false), // 是否显示功能教程按钮 tutorialEnabled: boolean("tutorialEnabled").notNull().default(false), // 是否显示功能教程按钮
// 精选榜设置 // 精选榜设置
rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮 rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮
productPlanningEnabled: boolean("productPlanningEnabled").notNull().default(true), // 是否显示并启用商品策划工具
// 无限画布设置 // 无限画布设置
canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能 canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能
agentEnabled: boolean("agentEnabled").notNull().default(true), // 是否启用智能体功能 agentEnabled: boolean("agentEnabled").notNull().default(true), // 是否启用智能体功能

View File

@ -0,0 +1,127 @@
import type {
ProductPlanningAnalyzeRequest,
ProductPlanningSkuAnalyzeRequest,
TaobaoQaRow,
TaobaoReviewRow,
} from "./types";
import { truncateText } from "./utils";
const MAX_ROWS = 120;
function formatReviewRows(rows: TaobaoReviewRow[]) {
return rows
.slice(0, MAX_ROWS)
.map((row, index) =>
[
`### 评价 ${index + 1}`,
`- 商品ID:${row.itemId || "未知"}`,
`- 时间:${row.date || "未知"}`,
`- 用户:${row.userId || "未知"}`,
`- SKU:${row.sku || "未知"}`,
`- 内容:${truncateText(row.content, 220) || "无"}`,
row.appendComment ? `- 追评:${truncateText(row.appendComment, 180)}` : "",
]
.filter(Boolean)
.join("\n")
)
.join("\n\n");
}
function formatQaRows(rows: TaobaoQaRow[]) {
return rows
.slice(0, MAX_ROWS)
.map((row, index) =>
[
`### 问答 ${index + 1}`,
`- 商品ID:${row.itemId || "未知"}`,
`- 问题:${truncateText(row.question, 160) || "无"}`,
row.questionDetail ? `- 问题补充:${truncateText(row.questionDetail, 160)}` : "",
`- 回答:${truncateText(row.answer, 220) || "无"}`,
`- 回答用户:${row.answerUser || "未知"}`,
`- 回答时间:${row.answerDate || "未知"}`,
`- 官方回答:${row.isOfficial ? "是" : "否"}`,
]
.filter(Boolean)
.join("\n")
)
.join("\n\n");
}
function formatSkuRows(request: ProductPlanningSkuAnalyzeRequest) {
return request.skus
.slice(0, MAX_ROWS)
.map((sku, index) =>
[
`### SKU ${index + 1}`,
`- 名称:${truncateText(sku.name, 180) || "无"}`,
typeof sku.price === "number" ? `- 价格:${sku.price}` : "",
]
.filter(Boolean)
.join("\n")
)
.join("\n\n");
}
export function buildProductPlanningAnalyzePrompt(
request: ProductPlanningAnalyzeRequest
) {
if (request.kind === "review") {
return [
"# 淘宝评价数据分析",
"",
"请基于以下买家评价数据,输出一份适合商品策划与详情页优化使用的中文 Markdown 分析。",
"",
"输出要求:",
"1. 总结核心用户痛点",
"2. 提炼高频关注点和决策点",
"3. 按质量、做工、外观、功能、场景分别分析",
"4. 给出详情页文案与卖点强化建议",
"5. 如果数据中存在矛盾反馈,要单独指出",
"",
"评价数据:",
formatReviewRows(request.rows),
].join("\n");
}
return [
"# 淘宝问大家数据分析",
"",
"请基于以下问大家数据,输出一份适合商品策划与详情页优化使用的中文 Markdown 分析。",
"",
"输出要求:",
"1. 总结用户最关心的问题",
"2. 提炼最影响转化的疑虑",
"3. 区分官方回答与普通回答的价值差异",
"4. 给出详情页必须补强的信息点",
"5. 给出客服 FAQ 建议",
"",
"问大家数据:",
formatQaRows(request.rows),
].join("\n");
}
export function buildProductPlanningSkuAnalyzePrompt(
request: ProductPlanningSkuAnalyzeRequest
) {
return [
"# 商品 SKU 卖点分析",
"",
"请基于以下 SKU 命名数据,输出一份适合商品策划与详情页优化使用的中文 Markdown 分析。",
"",
`平台:${request.platform === "jd" ? "京东" : "淘宝/天猫"}`,
`商品ID:${request.itemId}`,
request.title ? `商品标题:${request.title}` : "",
"",
"输出要求:",
"1. 提炼 SKU 命名中正在强调的核心卖点与差异点",
"2. 判断 SKU 维度是否清晰,是否存在信息堆砌或命名混乱",
"3. 总结当前 SKU 命名对转化的帮助与不足",
"4. 给出更利于用户理解和转化的 SKU 命名优化建议",
"5. 如果适合,补充可同步到主图/详情页/标题的卖点表达",
"",
"SKU 数据:",
formatSkuRows(request),
]
.filter(Boolean)
.join("\n");
}

View File

@ -0,0 +1,59 @@
export const PRODUCT_PLANNING_API_CONFIG_KEY = "product_planning_api_config";
export type ProductPlanningApiConfig = {
oneboundBaseUrl: string;
taobaoApiKey: string;
taobaoApiSecret: string;
qaApiUrl: string;
qaToken: string;
saleApiUrl: string;
saleToken: string;
};
const DEFAULT_ONEBOUND_BASE_URL = "https://api-gw.onebound.cn";
const DEFAULT_QA_API_URL = "http://119.91.237.2:5432/taobao_social_feed_aggregate";
const DEFAULT_SALE_API_URL = "http://119.91.237.2:5432/taobao_item_month_sale";
function normalizeUrl(value: unknown, fallback: string) {
const normalized = String(value || "").trim().replace(/\/+$/, "");
const normalizedFallback = String(fallback || "").trim().replace(/\/+$/, "");
return normalized || normalizedFallback;
}
function normalizeSecret(value: unknown, fallback = "") {
const normalized = String(value || "").trim();
const normalizedFallback = String(fallback || "").trim();
return normalized || normalizedFallback;
}
export function defaultProductPlanningApiConfig(): ProductPlanningApiConfig {
return {
oneboundBaseUrl: DEFAULT_ONEBOUND_BASE_URL,
taobaoApiKey: "",
taobaoApiSecret: "",
qaApiUrl: DEFAULT_QA_API_URL,
qaToken: "",
saleApiUrl: DEFAULT_SALE_API_URL,
saleToken: "",
};
}
export function normalizeProductPlanningApiConfig(
value: unknown,
fallback?: Partial<ProductPlanningApiConfig>
): ProductPlanningApiConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
oneboundBaseUrl: normalizeUrl(raw.oneboundBaseUrl, fallback?.oneboundBaseUrl || DEFAULT_ONEBOUND_BASE_URL),
taobaoApiKey: normalizeSecret(raw.taobaoApiKey, fallback?.taobaoApiKey || ""),
taobaoApiSecret: normalizeSecret(raw.taobaoApiSecret, fallback?.taobaoApiSecret || ""),
qaApiUrl: normalizeUrl(raw.qaApiUrl, fallback?.qaApiUrl || DEFAULT_QA_API_URL),
qaToken: normalizeSecret(raw.qaToken, fallback?.qaToken || ""),
saleApiUrl: normalizeUrl(raw.saleApiUrl, fallback?.saleApiUrl || DEFAULT_SALE_API_URL),
saleToken: normalizeSecret(raw.saleToken, fallback?.saleToken || ""),
};
}

View File

@ -0,0 +1,268 @@
import type {
KeywordAnalysisRow,
ProductPlanningCompetitorAnalysisRecord,
ProductPlanningKeywordInsightGroup,
ProductPlanningKeywordInsightSuggestion,
ProductPlanningTaobaoItem,
} from "./types";
function formatPlatform(value: ProductPlanningTaobaoItem["platform"], isTmall: boolean) {
if (value === "jd") return "京东";
return isTmall ? "天猫" : "淘宝";
}
function formatPropertySummary(row: ProductPlanningTaobaoItem, limit = 10) {
return row.properties
.slice(0, limit)
.map((entry) => `${entry.name}:${entry.value}`)
.join(" | ");
}
function formatConversionRate(value?: number) {
if (typeof value !== "number") return "";
return `${(value * 100).toFixed(2)}%`;
}
function triggerBlobDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
export async function downloadKeywordAnalysisWorkbook(
rows: KeywordAnalysisRow[],
baseFileName = "商品策划工具_关键词分析"
) {
const { Workbook } = await import("exceljs");
const workbook = new Workbook();
const sheet = workbook.addWorksheet("关键词拆词结果");
sheet.columns = [
{ header: "词语", key: "keyword", width: 24 },
{ header: "拆分片段", key: "segments", width: 28 },
{ header: "标签分类", key: "category", width: 16 },
{ header: "出现次数", key: "occurrenceCount", width: 12 },
{ header: "搜索人气", key: "searchPopularity", width: 14 },
{ header: "支付转化率", key: "conversionRate", width: 14 },
];
sheet.getRow(1).font = { bold: true };
sheet.views = [{ state: "frozen", ySplit: 1 }];
for (const row of rows) {
sheet.addRow({
keyword: row.keyword,
segments: row.segments.join(" / "),
category: row.category,
occurrenceCount: row.occurrenceCount,
searchPopularity: row.searchPopularity ?? "",
conversionRate: formatConversionRate(row.conversionRate),
});
}
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
triggerBlobDownload(blob, `${baseFileName}_${new Date().toISOString().slice(0, 10)}.xlsx`);
}
async function downloadWorkbook(params: {
worksheetName: string;
columns: Array<{ header: string; key: string; width: number }>;
rows: Array<Record<string, unknown>>;
fileName: string;
}) {
const { Workbook } = await import("exceljs");
const workbook = new Workbook();
const sheet = workbook.addWorksheet(params.worksheetName);
sheet.columns = params.columns;
sheet.getRow(1).font = { bold: true };
sheet.views = [{ state: "frozen", ySplit: 1 }];
for (const row of params.rows) {
sheet.addRow(row);
}
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
triggerBlobDownload(blob, `${params.fileName}_${new Date().toISOString().slice(0, 10)}.xlsx`);
}
export async function downloadCompetitorWorkbook(
rows: ProductPlanningTaobaoItem[],
baseFileName = "商品策划工具_竞品分析"
) {
await downloadWorkbook({
worksheetName: "竞品分析",
fileName: baseFileName,
columns: [
{ header: "平台", key: "platform", width: 12 },
{ header: "商品ID", key: "itemId", width: 18 },
{ header: "商品标题", key: "title", width: 40 },
{ header: "品牌", key: "brand", width: 18 },
{ header: "店铺", key: "shopName", width: 26 },
{ header: "发货地", key: "location", width: 18 },
{ header: "当前价格", key: "price", width: 14 },
{ header: "划线价格", key: "originalPrice", width: 14 },
{ header: "总销量", key: "totalSales", width: 14 },
{ header: "近期销量", key: "recentSales", width: 14 },
{ header: "材质", key: "material", width: 24 },
{ header: "详细材质", key: "detailedMaterial", width: 36 },
{ header: "主图数", key: "itemImageCount", width: 12 },
{ header: "详情图数", key: "detailImageCount", width: 12 },
{ header: "主图视频", key: "videoUrl", width: 36 },
{ header: "参数预览", key: "propertySummary", width: 80 },
{ header: "商品链接", key: "detailUrl", width: 48 },
],
rows: rows.map((row) => ({
platform: formatPlatform(row.platform, row.isTmall),
itemId: row.itemId,
title: row.title,
brand: row.brand || "",
shopName: row.shopName || "",
location: row.location || "",
price: row.price ?? "",
originalPrice: row.originalPrice ?? "",
totalSales: row.totalSales ?? "",
recentSales: row.recentSales ?? "",
material: row.material || "",
detailedMaterial: row.detailedMaterial || "",
itemImageCount: row.itemImages.length,
detailImageCount: row.detailImages.length,
videoUrl: row.videoUrl || "",
propertySummary: formatPropertySummary(row),
detailUrl: row.detailUrl || "",
})),
});
}
export async function downloadSalesWorkbook(
rows: ProductPlanningTaobaoItem[],
baseFileName = "商品策划工具_ID销量查询"
) {
await downloadWorkbook({
worksheetName: "ID销量查询",
fileName: baseFileName,
columns: [
{ header: "平台", key: "platform", width: 12 },
{ header: "商品ID", key: "itemId", width: 18 },
{ header: "商品标题", key: "title", width: 40 },
{ header: "近期销量", key: "recentSales", width: 14 },
{ header: "当前价格", key: "price", width: 14 },
{ header: "划线价格", key: "originalPrice", width: 14 },
{ header: "品牌", key: "brand", width: 18 },
{ header: "店铺", key: "shopName", width: 26 },
{ header: "发货地", key: "location", width: 18 },
{ header: "材质", key: "material", width: 22 },
{ header: "详情图数", key: "detailImageCount", width: 12 },
{ header: "参数预览", key: "propertySummary", width: 80 },
{ header: "商品链接", key: "detailUrl", width: 48 },
],
rows: rows.map((row) => ({
platform: formatPlatform(row.platform, row.isTmall),
itemId: row.itemId,
title: row.title,
recentSales: row.recentSales ?? "",
price: row.price ?? "",
originalPrice: row.originalPrice ?? "",
brand: row.brand || "",
shopName: row.shopName || "",
location: row.location || "",
material: row.material || "",
detailImageCount: row.detailImages.length,
propertySummary: formatPropertySummary(row),
detailUrl: row.detailUrl || "",
})),
});
}
export async function downloadKeywordInsightSuggestionsWorkbook(
rows: ProductPlanningKeywordInsightSuggestion[],
baseFileName = "商品策划工具_关键词洞察_搜索建议"
) {
await downloadWorkbook({
worksheetName: "搜索建议",
fileName: baseFileName,
columns: [
{ header: "排名", key: "rank", width: 10 },
{ header: "关键词", key: "keyword", width: 28 },
{ header: "权重", key: "weight", width: 16 },
],
rows: rows.map((row) => ({
rank: row.rank,
keyword: row.keyword,
weight: row.weight,
})),
});
}
export async function downloadKeywordInsightGroupsWorkbook(
rows: ProductPlanningKeywordInsightGroup[],
baseFileName = "商品策划工具_关键词洞察_标签分组"
) {
await downloadWorkbook({
worksheetName: "标签分组",
fileName: baseFileName,
columns: [
{ header: "组序号", key: "index", width: 10 },
{ header: "分组类型", key: "type", width: 20 },
{ header: "标签名称", key: "title", width: 28 },
{ header: "标签热度", key: "tagType", width: 12 },
],
rows: rows.flatMap((group) =>
group.tags.map((tag) => ({
index: group.index,
type: group.type,
title: tag.title,
tagType: tag.type === "hot" ? "热门" : "普通",
}))
),
});
}
export function downloadMarkdownFile(
markdown: string,
baseFileName = "商品策划工具_分析结果"
) {
const blob = new Blob([markdown], {
type: "text/markdown;charset=utf-8",
});
triggerBlobDownload(blob, `${baseFileName}_${new Date().toISOString().slice(0, 10)}.md`);
}
export async function downloadCompetitorAnalysisWorkbook(
records: Record<string, ProductPlanningCompetitorAnalysisRecord>,
baseFileName = "商品策划工具_竞品分析结果"
) {
const rows = Object.entries(records)
.map(([key, value]) => ({
key,
kind: value.kind === "sku" ? "SKU卖点分析" : "图片分析",
title: value.title,
updatedAt: value.updatedAt,
markdown: value.markdown,
}))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
await downloadWorkbook({
worksheetName: "AI分析结果",
fileName: baseFileName,
columns: [
{ header: "结果键", key: "key", width: 26 },
{ header: "分析类型", key: "kind", width: 16 },
{ header: "标题", key: "title", width: 42 },
{ header: "更新时间", key: "updatedAt", width: 24 },
{ header: "Markdown结果", key: "markdown", width: 120 },
],
rows,
});
}

View File

@ -0,0 +1,311 @@
import * as XLSX from "xlsx";
import Papa from "papaparse";
import type {
KeywordAnalysisRow,
KeywordInputRow,
TaobaoQaRow,
TaobaoReviewRow,
} from "./types";
import {
normalizeHeader,
normalizeText,
parseInteger,
parseOfficialFlag,
parseRangeValue,
splitUrlCell,
} from "./utils";
import { aggregateManualKeywordRows } from "./keyword-segmentation";
type SpreadsheetRecord = Record<string, unknown>;
const KEYWORD_ALIASES = {
keyword: ["关键词", "搜索词", "搜索关键词", "keyword", "keywords", "query"],
searchPopularity: ["搜索人气", "搜索量", "人气", "searchvolume", "popularity", "volume"],
conversionRate: ["支付转化率", "转化率", "conversionrate", "conversion", "cvr"],
};
const MANUAL_ALIASES = {
keyword: ["词语", "关键词", "搜索词", "word", "keyword"],
category: ["标签分类", "分类", "category", "tagcategory"],
searchPopularity: KEYWORD_ALIASES.searchPopularity,
conversionRate: KEYWORD_ALIASES.conversionRate,
occurrenceCount: ["出现次数", "次数", "count", "occurrencecount"],
};
const REVIEW_ALIASES = {
itemId: ["宝贝id", "商品id", "itemid", "numiid"],
date: ["评论时间", "评价时间", "日期", "时间", "date"],
content: ["评价内容", "评论内容", "内容", "content", "ratecontent"],
appendComment: ["追评", "追加评价", "appendcomment", "addfeedback"],
sku: ["评价所属sku", "sku", "规格", "skuinfo", "auctionsku"],
userId: ["买家id", "用户id", "userid", "displayusernick"],
images: ["评论图片", "图片", "pics", "images"],
videoUrls: ["评价视频", "视频", "videourls", "videolist"],
};
const QA_ALIASES = {
itemId: ["商品id", "宝贝id", "itemid"],
question: ["问题", "提问", "question"],
questionDetail: ["问题补充", "问题详情", "questiondetail", "content"],
answer: ["回答", "答案", "answer"],
answerUser: ["回答用户", "用户", "answeruser"],
answerDate: ["回答时间", "时间", "answerdate", "date"],
isOfficial: ["是否官方", "官方回答", "isofficial", "official"],
};
function matchColumn(record: SpreadsheetRecord, aliases: string[]) {
const aliasSet = new Set(aliases.map((item) => normalizeHeader(item)));
return Object.keys(record).find((key) => aliasSet.has(normalizeHeader(key))) || null;
}
async function readSpreadsheetRecords(file: File) {
const extension = file.name.toLowerCase().split(".").pop() || "";
if (extension === "csv") {
const text = await file.text();
const parsed = Papa.parse<SpreadsheetRecord>(text, {
header: true,
skipEmptyLines: true,
transformHeader: (header) => normalizeText(header),
});
if (parsed.errors.length > 0) {
throw new Error(`CSV 解析失败:${parsed.errors[0]?.message || "未知错误"}`);
}
return parsed.data;
}
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: "array" });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new Error("表格中没有可读取的工作表");
}
const worksheet = workbook.Sheets[sheetName];
return XLSX.utils.sheet_to_json<SpreadsheetRecord>(worksheet, {
defval: "",
});
}
export async function parseKeywordInputFile(file: File) {
const extension = file.name.toLowerCase().split(".").pop() || "";
if (extension === "txt") {
const text = await file.text();
const rows = text
.split(/\r?\n/)
.map((line) => normalizeText(line))
.filter(Boolean)
.map<KeywordInputRow>((keyword) => ({ keyword }));
if (rows.length === 0) {
throw new Error("关键词文件中没有有效内容");
}
return rows;
}
if (!["xls", "xlsx", "csv"].includes(extension)) {
throw new Error("仅支持 txt、xls、xlsx、csv 文件");
}
const records = await readSpreadsheetRecords(file);
if (records.length === 0) {
throw new Error("关键词表中没有有效数据");
}
const firstRecord = records[0];
const keywordColumn =
matchColumn(firstRecord, KEYWORD_ALIASES.keyword) || Object.keys(firstRecord)[0] || null;
if (!keywordColumn) {
throw new Error("未找到关键词列");
}
const searchPopularityColumn = matchColumn(firstRecord, KEYWORD_ALIASES.searchPopularity);
const conversionRateColumn = matchColumn(firstRecord, KEYWORD_ALIASES.conversionRate);
const rows = records
.map<KeywordInputRow | null>((record) => {
const keyword = normalizeText(record[keywordColumn]);
if (!keyword) return null;
const popularity = searchPopularityColumn
? parseRangeValue(record[searchPopularityColumn])
: null;
const conversion = conversionRateColumn
? parseRangeValue(record[conversionRateColumn])
: null;
return {
keyword,
searchPopularity: typeof popularity === "number" ? popularity : undefined,
conversionRate:
typeof conversion === "number"
? conversion > 1
? Number((conversion / 100).toFixed(4))
: Number(conversion.toFixed(4))
: undefined,
};
})
.filter((item): item is KeywordInputRow => Boolean(item));
if (rows.length === 0) {
throw new Error("未从关键词文件中提取到有效关键词");
}
return rows;
}
export async function parseManualSegmentationFile(file: File) {
const extension = file.name.toLowerCase().split(".").pop() || "";
if (!["xls", "xlsx", "csv"].includes(extension)) {
throw new Error("手动分词文件仅支持 xls、xlsx、csv");
}
const records = await readSpreadsheetRecords(file);
if (records.length === 0) {
throw new Error("手动分词表中没有有效数据");
}
const firstRecord = records[0];
const keywordColumn = matchColumn(firstRecord, MANUAL_ALIASES.keyword);
const categoryColumn = matchColumn(firstRecord, MANUAL_ALIASES.category);
if (!keywordColumn || !categoryColumn) {
throw new Error("手动分词表缺少核心列:词语、标签分类");
}
const searchPopularityColumn = matchColumn(firstRecord, MANUAL_ALIASES.searchPopularity);
const conversionRateColumn = matchColumn(firstRecord, MANUAL_ALIASES.conversionRate);
const occurrenceCountColumn = matchColumn(firstRecord, MANUAL_ALIASES.occurrenceCount);
const rows = records
.map<KeywordAnalysisRow | null>((record) => {
const keyword = normalizeText(record[keywordColumn]);
const category = normalizeText(record[categoryColumn]);
if (!keyword || !category) return null;
const searchPopularity = searchPopularityColumn
? parseRangeValue(record[searchPopularityColumn])
: null;
const conversionRate = conversionRateColumn
? parseRangeValue(record[conversionRateColumn])
: null;
const occurrenceCount = occurrenceCountColumn
? Math.max(parseInteger(record[occurrenceCountColumn], 1), 1)
: 1;
return {
keyword,
segments: [keyword],
category,
occurrenceCount,
searchPopularity: typeof searchPopularity === "number" ? searchPopularity : undefined,
conversionRate:
typeof conversionRate === "number"
? conversionRate > 1
? Number((conversionRate / 100).toFixed(4))
: Number(conversionRate.toFixed(4))
: undefined,
};
})
.filter((item): item is KeywordAnalysisRow => Boolean(item));
if (rows.length === 0) {
throw new Error("未从手动分词表中提取到有效数据");
}
return aggregateManualKeywordRows(rows);
}
export async function parseReviewImportFile(file: File) {
const records = await readSpreadsheetRecords(file);
if (records.length === 0) {
throw new Error("评价表中没有有效数据");
}
const firstRecord = records[0];
const contentColumn = matchColumn(firstRecord, REVIEW_ALIASES.content);
if (!contentColumn) {
throw new Error("评价表缺少核心列:评价内容");
}
const itemIdColumn = matchColumn(firstRecord, REVIEW_ALIASES.itemId);
const dateColumn = matchColumn(firstRecord, REVIEW_ALIASES.date);
const appendColumn = matchColumn(firstRecord, REVIEW_ALIASES.appendComment);
const skuColumn = matchColumn(firstRecord, REVIEW_ALIASES.sku);
const userIdColumn = matchColumn(firstRecord, REVIEW_ALIASES.userId);
const imagesColumn = matchColumn(firstRecord, REVIEW_ALIASES.images);
const videoUrlsColumn = matchColumn(firstRecord, REVIEW_ALIASES.videoUrls);
const rows = records
.map<TaobaoReviewRow | null>((record) => {
const content = normalizeText(record[contentColumn]);
if (!content) return null;
return {
itemId: itemIdColumn ? normalizeText(record[itemIdColumn]) : "",
date: dateColumn ? normalizeText(record[dateColumn]) : "",
content,
appendComment: appendColumn ? normalizeText(record[appendColumn]) || undefined : undefined,
sku: skuColumn ? normalizeText(record[skuColumn]) || undefined : undefined,
userId: userIdColumn ? normalizeText(record[userIdColumn]) || undefined : undefined,
images: imagesColumn ? splitUrlCell(record[imagesColumn]) : [],
videoUrls: videoUrlsColumn ? splitUrlCell(record[videoUrlsColumn]) : [],
};
})
.filter((item): item is TaobaoReviewRow => Boolean(item));
if (rows.length === 0) {
throw new Error("未从评价表中提取到有效评价数据");
}
return rows;
}
export async function parseQaImportFile(file: File) {
const records = await readSpreadsheetRecords(file);
if (records.length === 0) {
throw new Error("问大家表中没有有效数据");
}
const firstRecord = records[0];
const questionColumn = matchColumn(firstRecord, QA_ALIASES.question);
const answerColumn = matchColumn(firstRecord, QA_ALIASES.answer);
if (!questionColumn || !answerColumn) {
throw new Error("问大家表缺少核心列:问题、回答");
}
const itemIdColumn = matchColumn(firstRecord, QA_ALIASES.itemId);
const detailColumn = matchColumn(firstRecord, QA_ALIASES.questionDetail);
const answerUserColumn = matchColumn(firstRecord, QA_ALIASES.answerUser);
const answerDateColumn = matchColumn(firstRecord, QA_ALIASES.answerDate);
const officialColumn = matchColumn(firstRecord, QA_ALIASES.isOfficial);
const rows = records
.map<TaobaoQaRow | null>((record) => {
const question = normalizeText(record[questionColumn]);
const answer = normalizeText(record[answerColumn]);
if (!question || !answer) return null;
return {
itemId: itemIdColumn ? normalizeText(record[itemIdColumn]) : "",
question,
questionDetail: detailColumn ? normalizeText(record[detailColumn]) || undefined : undefined,
answer,
answerUser: answerUserColumn
? normalizeText(record[answerUserColumn]) || undefined
: undefined,
answerDate: answerDateColumn
? normalizeText(record[answerDateColumn]) || undefined
: undefined,
isOfficial: officialColumn ? parseOfficialFlag(record[officialColumn]) : false,
};
})
.filter((item): item is TaobaoQaRow => Boolean(item));
if (rows.length === 0) {
throw new Error("未从问大家表中提取到有效数据");
}
return rows;
}

View File

@ -0,0 +1,406 @@
import type { KeywordAnalysisRow, KeywordInputRow } from "./types";
import { normalizeText } from "./utils";
const PRODUCT_TERMS = [
"换鞋凳",
"换鞋椅",
"穿鞋凳",
"穿鞋椅",
"储物凳",
"收纳凳",
"鞋柜",
"鞋架",
"鞋凳",
"沙发",
"茶几",
"电视柜",
"书桌",
"衣柜",
"床头柜",
"餐桌",
"椅子",
"书架",
"置物架",
"电脑桌",
"梳妆台",
"床垫",
"床架",
"凳子",
"桌子",
"衣架",
"挂钩",
"热敷包",
"盐袋",
"拖鞋",
"凉拖",
"棉拖",
"人字拖",
"耳环",
"项链",
"戒指",
"手链",
"白酒",
"红酒",
];
const MATERIAL_TERMS = [
"实木",
"木质",
"板式",
"布艺",
"皮质",
"金属",
"不锈钢",
"铁艺",
"藤编",
"塑料",
"大理石",
"玻璃",
"硅胶",
"棉麻",
"羊毛",
"陶瓷",
"亚克力",
"牛皮",
"皮革",
"绒布",
"铝合金",
"abs",
"pvc",
];
const AUDIENCE_TERMS = [
"儿童",
"成人",
"男士",
"女士",
"学生",
"老人",
"孕妇",
"婴儿",
"宝宝",
"青年",
"中年",
"老年",
"情侣",
"新生儿",
];
const SCENARIO_TERMS = [
"家用",
"办公",
"客厅",
"卧室",
"厨房",
"浴室",
"阳台",
"书房",
"门口",
"玄关",
"入户",
"户外",
"旅行",
"露营",
"车载",
"宿舍",
];
const ACTION_TERMS = [
"换鞋",
"收纳",
"储物",
"折叠",
"防滑",
"防水",
"保暖",
"透气",
"静音",
"可折叠",
"可拆洗",
"可移动",
"防臭",
"耐磨",
"加厚",
"加宽",
"加深",
"大容量",
];
const ATTRIBUTE_TERMS = [
"简约",
"现代",
"北欧",
"中式",
"复古",
"轻奢",
"ins",
"新款",
"小户型",
"多功能",
"可坐",
"组合",
"一体",
"年份",
"2024",
"2025",
"2026",
"2027",
"春夏",
"秋冬",
"加长",
"加大",
"双层",
"三层",
];
const BASE_DICTIONARY = Array.from(
new Set([
...PRODUCT_TERMS,
...MATERIAL_TERMS,
...AUDIENCE_TERMS,
...SCENARIO_TERMS,
...ACTION_TERMS,
...ATTRIBUTE_TERMS,
])
).sort((left, right) => right.length - left.length);
const YEAR_PATTERN = /^(19\d{2}|20\d{2}|21\d{2})(?:[年款版新])?/;
const ALPHANUMERIC_PATTERN = /^[a-z0-9&-]+/i;
function isSeparator(char: string) {
return /[\s,,.。/\\|+_\-()[\]{}]/.test(char);
}
function splitUnknownChunk(chunk: string) {
const text = normalizeText(chunk);
if (!text) return [] as string[];
if (text.length <= 4) return [text];
const result: string[] = [];
for (let index = 0; index < text.length; index += 2) {
const next = text.slice(index, index + 2);
if (!next) continue;
if (next.length === 1 && result.length > 0) {
result[result.length - 1] += next;
continue;
}
result.push(next);
}
return result;
}
function getDictionary(categoryWords: string[]) {
const normalizedCategoryWords = Array.from(
new Set(categoryWords.map((item) => normalizeText(item)).filter(Boolean))
).sort((left, right) => right.length - left.length);
const dictionary = Array.from(
new Set([...normalizedCategoryWords, ...BASE_DICTIONARY])
).sort((left, right) => right.length - left.length);
return {
normalizedCategoryWords,
dictionary,
};
}
function hasKnownMatchAt(text: string, start: number, dictionary: string[]) {
const remaining = text.slice(start);
if (YEAR_PATTERN.test(remaining) || ALPHANUMERIC_PATTERN.test(remaining)) {
return true;
}
return dictionary.some((token) => remaining.startsWith(token));
}
function matchLongestToken(text: string, start: number, dictionary: string[]) {
const remaining = text.slice(start);
const yearMatch = remaining.match(YEAR_PATTERN);
if (yearMatch?.[0]) return yearMatch[0];
const alphanumericMatch = remaining.match(ALPHANUMERIC_PATTERN);
if (alphanumericMatch?.[0]) return alphanumericMatch[0];
return dictionary.find((token) => remaining.startsWith(token)) || "";
}
export function segmentKeyword(keyword: string, categoryWords: string[] = []) {
const source = normalizeText(keyword).replace(/\s+/g, "");
if (!source) return [] as string[];
const { dictionary } = getDictionary(categoryWords);
const tokens: string[] = [];
let index = 0;
while (index < source.length) {
const char = source[index];
if (isSeparator(char)) {
index += 1;
continue;
}
const matchedToken = matchLongestToken(source, index, dictionary);
if (matchedToken) {
tokens.push(matchedToken);
index += matchedToken.length;
continue;
}
let end = index + 1;
while (end < source.length) {
if (isSeparator(source[end]) || hasKnownMatchAt(source, end, dictionary)) {
break;
}
end += 1;
}
const chunk = source.slice(index, end);
tokens.push(...splitUnknownChunk(chunk));
index = end;
}
return tokens.filter(Boolean);
}
export function inferKeywordCategory(keyword: string, categoryWords: string[] = []) {
const value = normalizeText(keyword).toLowerCase();
const categorySet = new Set(categoryWords.map((item) => normalizeText(item).toLowerCase()));
if (categorySet.has(value)) return "品类大词";
if (MATERIAL_TERMS.some((item) => item.toLowerCase() === value)) return "材质词";
if (AUDIENCE_TERMS.some((item) => item.toLowerCase() === value)) return "人群词";
if (SCENARIO_TERMS.some((item) => item.toLowerCase() === value)) return "场景词";
if (ACTION_TERMS.some((item) => item.toLowerCase() === value)) return "需求词";
if (ATTRIBUTE_TERMS.some((item) => item.toLowerCase() === value)) return "属性词";
if (YEAR_PATTERN.test(value) || ALPHANUMERIC_PATTERN.test(value)) return "属性词";
return "未分类";
}
export function analyzeKeywordInputs(
inputs: KeywordInputRow[],
options?: { categoryWords?: string[] }
) {
const categoryWords = options?.categoryWords || [];
const aggregates = new Map<
string,
{
occurrenceCount: number;
popularityTotal: number;
popularityCount: number;
conversionTotal: number;
conversionCount: number;
}
>();
for (const input of inputs) {
const keyword = normalizeText(input.keyword);
if (!keyword) continue;
const uniqueTokens = new Set(segmentKeyword(keyword, categoryWords));
for (const token of uniqueTokens) {
const entry = aggregates.get(token) || {
occurrenceCount: 0,
popularityTotal: 0,
popularityCount: 0,
conversionTotal: 0,
conversionCount: 0,
};
entry.occurrenceCount += 1;
if (typeof input.searchPopularity === "number") {
entry.popularityTotal += input.searchPopularity;
entry.popularityCount += 1;
}
if (typeof input.conversionRate === "number") {
entry.conversionTotal += input.conversionRate;
entry.conversionCount += 1;
}
aggregates.set(token, entry);
}
}
const rows: KeywordAnalysisRow[] = Array.from(aggregates.entries()).map(([keyword, stats]) => ({
keyword,
segments: [keyword],
category: inferKeywordCategory(keyword, categoryWords),
occurrenceCount: stats.occurrenceCount,
searchPopularity:
stats.popularityCount > 0
? Number((stats.popularityTotal / stats.popularityCount).toFixed(2))
: undefined,
conversionRate:
stats.conversionCount > 0
? Number((stats.conversionTotal / stats.conversionCount).toFixed(4))
: undefined,
}));
return rows.sort((left, right) => {
if (right.occurrenceCount !== left.occurrenceCount) {
return right.occurrenceCount - left.occurrenceCount;
}
return left.keyword.localeCompare(right.keyword, "zh-CN");
});
}
export function aggregateManualKeywordRows(rows: KeywordAnalysisRow[]) {
const aggregates = new Map<string, KeywordAnalysisRow>();
for (const row of rows) {
const keyword = normalizeText(row.keyword);
if (!keyword) continue;
const existing = aggregates.get(keyword);
if (!existing) {
aggregates.set(keyword, {
keyword,
segments: row.segments.length > 0 ? row.segments : [keyword],
category: row.category || "未分类",
occurrenceCount: Math.max(row.occurrenceCount, 1),
searchPopularity: row.searchPopularity,
conversionRate: row.conversionRate,
});
continue;
}
const totalCount = existing.occurrenceCount + Math.max(row.occurrenceCount, 1);
const popularityValues = [existing.searchPopularity, row.searchPopularity].filter(
(item): item is number => typeof item === "number"
);
const conversionValues = [existing.conversionRate, row.conversionRate].filter(
(item): item is number => typeof item === "number"
);
aggregates.set(keyword, {
keyword,
segments: existing.segments.length > 0 ? existing.segments : [keyword],
category: existing.category !== "未分类" ? existing.category : row.category || "未分类",
occurrenceCount: totalCount,
searchPopularity:
popularityValues.length > 0
? Number(
(
popularityValues.reduce((sum, value) => sum + value, 0) /
popularityValues.length
).toFixed(2)
)
: undefined,
conversionRate:
conversionValues.length > 0
? Number(
(
conversionValues.reduce((sum, value) => sum + value, 0) /
conversionValues.length
).toFixed(4)
)
: undefined,
});
}
return Array.from(aggregates.values()).sort((left, right) => {
if (right.occurrenceCount !== left.occurrenceCount) {
return right.occurrenceCount - left.occurrenceCount;
}
return left.keyword.localeCompare(right.keyword, "zh-CN");
});
}

View File

@ -0,0 +1,31 @@
import { cookies } from "next/headers";
import { decode } from "next-auth/jwt";
export async function getProductPlanningPageToken() {
const cookieStore = await cookies();
const secureCookieName = "__Secure-authjs.session-token";
const regularCookieName = "authjs.session-token";
const rawToken =
cookieStore.get(secureCookieName)?.value ||
cookieStore.get(regularCookieName)?.value ||
null;
if (!rawToken || !process.env.AUTH_SECRET) {
return null;
}
const salt = rawToken === cookieStore.get(secureCookieName)?.value
? secureCookieName
: regularCookieName;
try {
return await decode({
token: rawToken,
secret: process.env.AUTH_SECRET,
salt,
});
} catch {
return null;
}
}

View File

@ -0,0 +1,13 @@
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
export async function getProductPlanningToken(request: NextRequest) {
const isSecure =
process.env.AUTH_URL?.startsWith("https://") || request.nextUrl.protocol === "https:";
return getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: isSecure,
});
}

View File

@ -0,0 +1,18 @@
import "server-only";
import { getSystemSetting } from "@/lib/db/queries";
import {
normalizeProductPlanningApiConfig,
PRODUCT_PLANNING_API_CONFIG_KEY,
} from "./config";
export async function getResolvedProductPlanningApiConfig() {
const setting = await getSystemSetting({ key: PRODUCT_PLANNING_API_CONFIG_KEY }).catch(() => null);
return normalizeProductPlanningApiConfig(setting?.value, {
taobaoApiKey: process.env.TAOBAO_API_KEY,
taobaoApiSecret: process.env.TAOBAO_API_SECRET,
qaToken: process.env.PRODUCT_PLANNING_QA_TOKEN,
saleToken: process.env.TAOBAO_SALE_API_TOKEN,
});
}

View File

@ -0,0 +1,10 @@
import "server-only";
import { getSiteSettings } from "@/lib/db/queries";
export const PRODUCT_PLANNING_DISABLED_MESSAGE = "商品策划功能已关闭,请联系管理员";
export async function isProductPlanningEnabled() {
const settings = await getSiteSettings().catch(() => null);
return (settings as { productPlanningEnabled?: boolean } | null)?.productPlanningEnabled !== false;
}

View File

@ -0,0 +1,305 @@
import type {
ProductPlanningPropertyRow,
ProductPlanningSkuRow,
ProductPlanningTaobaoItem,
} from "./types";
import { ensureHttpsUrl, normalizeText, parseInteger, parseRangeValue } from "./utils";
function normalizePrice(value: unknown) {
const parsed = parseRangeValue(value);
return typeof parsed === "number" ? parsed : null;
}
function normalizeImageList(item: any) {
if (!Array.isArray(item?.item_imgs)) return [] as string[];
return item.item_imgs
.map((entry: unknown) => {
if (typeof entry === "string") return ensureHttpsUrl(entry);
if (entry && typeof entry === "object") {
const url = (entry as Record<string, unknown>).url;
return typeof url === "string" ? ensureHttpsUrl(url) : "";
}
return "";
})
.filter(Boolean);
}
function normalizeMixedImageList(value: unknown) {
if (!Array.isArray(value)) return [] as string[];
return value
.map((entry: unknown) => {
if (typeof entry === "string") return ensureHttpsUrl(entry);
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
return ensureHttpsUrl(record.url || record.img || record.image || "");
}
return "";
})
.filter(Boolean);
}
function extractDetailImagesFromHtml(value: unknown) {
const html = String(value || "");
if (!html) return [] as string[];
const matched = html.match(/(?:data-lazyload|src)=["']([^"']+\.(?:jpg|jpeg|png|gif|webp)(?:[^"']*)?)["']/gi);
if (!matched) return [] as string[];
const urls = matched
.map((entry) => {
const urlMatch = entry.match(/["']([^"']+)["']/);
return ensureHttpsUrl(urlMatch?.[1] || "");
})
.filter(Boolean);
return Array.from(new Set(urls));
}
function normalizeDetailImages(item: any) {
const descImages = normalizeMixedImageList(item?.desc_img);
if (descImages.length > 0) return Array.from(new Set(descImages));
return extractDetailImagesFromHtml(item?.desc);
}
function normalizeSkuList(item: any) {
const skuRows = Array.isArray(item?.skus?.sku) ? item.skus.sku : [];
const result: ProductPlanningSkuRow[] = [];
for (const sku of skuRows) {
const name =
normalizeText(sku?.properties_name) ||
normalizeText(sku?.properties) ||
normalizeText(sku?.sku_id) ||
"默认规格";
result.push({
name,
price: normalizePrice(sku?.price),
});
}
return result;
}
function normalizeJdSkuList(item: any) {
const skuRows = Array.isArray(item?.skus) ? item.skus : [];
const result: ProductPlanningSkuRow[] = [];
for (const sku of skuRows) {
const specs = Array.isArray(sku?.specs) ? sku.specs : [];
const specNames = specs.flatMap((spec: any) =>
Array.isArray(spec?.values)
? spec.values
.map((entry: any) => normalizeText(entry?.vname))
.filter(Boolean)
: []
);
const name =
specNames.join(" / ") ||
normalizeText(sku?.name) ||
normalizeText(sku?.sku_id) ||
"默认规格";
result.push({
name,
price: normalizePrice(sku?.price ?? item?.price),
});
}
return result;
}
function normalizePropertyList(item: any) {
const result: ProductPlanningPropertyRow[] = [];
if (Array.isArray(item?.props)) {
for (const prop of item.props) {
const name = normalizeText(prop?.name);
const value = normalizeText(prop?.value);
if (!name || !value) continue;
result.push({ name, value });
}
}
if (item?.props_list && typeof item.props_list === "object") {
for (const [name, value] of Object.entries(item.props_list)) {
const normalizedName = normalizeText(name);
const normalizedValue = normalizeText(value);
if (!normalizedName || !normalizedValue) continue;
if (result.some((entry) => entry.name === normalizedName && entry.value === normalizedValue)) {
continue;
}
result.push({ name: normalizedName, value: normalizedValue });
}
}
return result;
}
function normalizeJdPropertyList(item: any) {
const result = normalizePropertyList(item);
const attrs = Array.isArray(item?.attrs) ? item.attrs : [];
for (const attr of attrs) {
const name = normalizeText(attr?.name || attr?.attr_name);
const value =
normalizeText(attr?.value || attr?.attr_value) ||
(Array.isArray(attr?.values)
? attr.values.map((entry: any) => normalizeText(entry?.vname || entry)).filter(Boolean).join(" / ")
: "");
if (!name || !value) continue;
if (result.some((entry) => entry.name === name && entry.value === value)) {
continue;
}
result.push({ name, value });
}
return result;
}
export function extractMaterialSummary(properties: ProductPlanningPropertyRow[]) {
const directMaterial = properties.find((property) => property.name === "材质");
const woodMaterial = properties.find((property) => property.name === "木质材质");
if (directMaterial && woodMaterial) {
return `${directMaterial.value}(${woodMaterial.value})`;
}
if (woodMaterial) return woodMaterial.value;
if (directMaterial) return directMaterial.value;
const partialMaterial = properties.find(
(property) =>
property.name.includes("材质") ||
property.name.includes("面料") ||
property.name.toLowerCase().includes("material")
);
return partialMaterial?.value || undefined;
}
export function extractDetailedMaterialSummary(properties: ProductPlanningPropertyRow[]) {
const materialProperties = properties.filter((property) =>
[
"材质",
"木质材质",
"饰面材质",
"实木占比",
"面料",
"成分",
"工艺",
"表面处理",
"风格",
"流行元素",
].some((keyword) => property.name.includes(keyword))
);
if (materialProperties.length === 0) {
return undefined;
}
return materialProperties.map((property) => `${property.name}:${property.value}`).join(" | ");
}
export function normalizeTaobaoItem(item: any, itemId: string): ProductPlanningTaobaoItem {
const itemImages = normalizeImageList(item);
const detailImages = normalizeDetailImages(item);
const skus = normalizeSkuList(item);
const properties = normalizePropertyList(item);
const material = extractMaterialSummary(properties);
const detailedMaterial = extractDetailedMaterialSummary(properties);
return {
platform: "taobao",
itemId,
title: normalizeText(item?.title) || "无标题",
detailUrl: ensureHttpsUrl(item?.detail_url || ""),
shopName:
normalizeText(item?.seller_info?.shop_name) ||
normalizeText(item?.shop_name) ||
normalizeText(item?.nick) ||
undefined,
location: normalizeText(item?.location) || undefined,
price: normalizePrice(item?.price),
originalPrice: normalizePrice(item?.orginal_price || item?.original_price || item?.price),
salesText: normalizeText(item?.sales) || undefined,
totalSales: parseInteger(item?.sales || item?.volume || item?.total_sold, 0),
recentSales: null,
brand: normalizeText(item?.brand) || undefined,
isTmall: Boolean(item?.tmall) || String(item?.detail_url || "").includes("tmall.com"),
mainImage: ensureHttpsUrl(item?.pic_url || itemImages[0] || ""),
itemImages,
detailImages,
descriptionHtml: normalizeText(item?.desc) ? String(item.desc) : undefined,
videoUrl: ensureHttpsUrl(item?.video?.url || ""),
videoThumb: ensureHttpsUrl(item?.video?.thumb || ""),
skus,
properties,
material,
detailedMaterial,
};
}
export function normalizeJdItem(item: any, itemId: string): ProductPlanningTaobaoItem {
const itemImages = normalizeMixedImageList(item?.item_imgs);
const detailImages = normalizeDetailImages(item);
const skus = normalizeJdSkuList(item);
const properties = normalizeJdPropertyList(item);
const material = extractMaterialSummary(properties);
const detailedMaterial = extractDetailedMaterialSummary(properties);
const totalSales = parseInteger(item?.sales || item?.volume || item?.total_sold, 0);
return {
platform: "jd",
itemId,
title: normalizeText(item?.title) || "无标题",
detailUrl: ensureHttpsUrl(item?.detail_url || `https://item.jd.com/${itemId}.html`),
shopName:
normalizeText(item?.shop_name) ||
normalizeText(item?.seller_info?.shop_name) ||
normalizeText(item?.nick) ||
undefined,
location: normalizeText(item?.location) || undefined,
price: normalizePrice(item?.price),
originalPrice: normalizePrice(item?.orginal_price || item?.original_price || item?.price),
salesText: normalizeText(item?.sales || item?.volume) || undefined,
totalSales,
recentSales: totalSales,
brand: normalizeText(item?.brand) || undefined,
isTmall: false,
mainImage: ensureHttpsUrl(item?.pic_url || itemImages[0] || ""),
itemImages,
detailImages,
descriptionHtml: normalizeText(item?.desc) ? String(item.desc) : undefined,
videoUrl: ensureHttpsUrl(item?.video?.url || ""),
videoThumb: ensureHttpsUrl(item?.video?.thumb || ""),
skus,
properties,
material,
detailedMaterial,
};
}
export function extractMonthSale(raw: Record<string, unknown> | null | undefined) {
if (!raw || typeof raw !== "object") return null;
const candidates = [
raw.biz30day,
raw.sales,
raw.month_sales,
(raw.data as Record<string, unknown> | undefined)?.sales,
(raw.data as Record<string, unknown> | undefined)?.month_sales,
(raw.data as Record<string, unknown> | undefined)?.recent_sales,
raw.recent_sales,
];
for (const candidate of candidates) {
const parsed = parseRangeValue(candidate);
if (typeof parsed === "number") {
return parsed;
}
}
return null;
}

View File

@ -0,0 +1,79 @@
import type { ProductPlanningTemplate } from "./types";
import { normalizeText } from "./utils";
export const PRODUCT_PLANNING_TEMPLATES_KEY = "product_planning_keyword_templates";
function toValidDateString(value: unknown) {
const text = String(value || "").trim();
if (!text) return null;
const date = new Date(text);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
export function normalizeTemplateCategoryName(value: unknown) {
return normalizeText(value).replace(/\s+/g, " ");
}
export function parseProductPlanningTemplates(value: unknown) {
if (!Array.isArray(value)) return [] as ProductPlanningTemplate[];
const templates: ProductPlanningTemplate[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const record = entry as Record<string, unknown>;
const id = normalizeText(record.id);
const categoryName = normalizeTemplateCategoryName(record.categoryName);
const fileName = normalizeText(record.fileName);
const fileUrl = normalizeText(record.fileUrl);
const createdAt = toValidDateString(record.createdAt);
const updatedAt = toValidDateString(record.updatedAt) || createdAt;
const fileSize = Number.parseInt(String(record.fileSize || "0"), 10);
if (
!id ||
!categoryName ||
!fileName ||
!fileUrl ||
!createdAt ||
!updatedAt ||
!Number.isFinite(fileSize)
) {
continue;
}
templates.push({
id,
categoryName,
description: normalizeText(record.description) || undefined,
fileName,
fileUrl,
filePath: normalizeText(record.filePath) || undefined,
fileSize,
contentType: normalizeText(record.contentType) || undefined,
createdAt,
updatedAt,
uploadedById: normalizeText(record.uploadedById) || undefined,
});
}
return templates.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
export function upsertProductPlanningTemplate(
templates: ProductPlanningTemplate[],
template: ProductPlanningTemplate
) {
return [
template,
...templates.filter(
(entry) =>
entry.id !== template.id &&
normalizeTemplateCategoryName(entry.categoryName) !==
normalizeTemplateCategoryName(template.categoryName)
),
].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}

View File

@ -0,0 +1,177 @@
export type KeywordInputRow = {
keyword: string;
searchPopularity?: number;
conversionRate?: number;
};
export type KeywordAnalysisRow = {
keyword: string;
segments: string[];
category: string;
occurrenceCount: number;
searchPopularity?: number;
conversionRate?: number;
};
export type TaobaoReviewRow = {
itemId: string;
date: string;
content: string;
appendComment?: string;
sku?: string;
userId?: string;
images: string[];
videoUrls: string[];
};
export type TaobaoQaRow = {
itemId: string;
question: string;
questionDetail?: string;
answer: string;
answerUser?: string;
answerDate?: string;
isOfficial: boolean;
};
export type ProductPlanningAnalyzeRequest =
| { kind: "review"; rows: TaobaoReviewRow[] }
| { kind: "qa"; rows: TaobaoQaRow[] };
export type ProductPlanningAnalyzeResponse = {
success: boolean;
markdown?: string;
error?: string;
};
export type ProductPlanningImageAnalyzeRequest = {
itemId: string;
imageUrl: string;
title?: string;
};
export type ProductPlanningSkuAnalyzeRequest = {
itemId: string;
title?: string;
platform: ProductPlanningPlatform;
skus: ProductPlanningSkuRow[];
};
export type ProductPlanningCompetitorAnalysisRecord = {
title: string;
markdown: string;
kind: "image" | "sku";
updatedAt: string;
};
export type ProductPlanningRowsResponse<T> = {
success: boolean;
itemId: string;
page: number;
totalPages: number;
rows: T[];
error?: string;
};
export type ProductPlanningSkuRow = {
name: string;
price: number | null;
};
export type ProductPlanningPlatform = "taobao" | "jd";
export type ProductPlanningPropertyRow = {
name: string;
value: string;
};
export type ProductPlanningTaobaoItem = {
platform: ProductPlanningPlatform;
itemId: string;
title: string;
detailUrl?: string;
shopName?: string;
location?: string;
price: number | null;
originalPrice: number | null;
salesText?: string;
totalSales: number | null;
recentSales: number | null;
brand?: string;
isTmall: boolean;
mainImage?: string;
itemImages: string[];
detailImages: string[];
descriptionHtml?: string;
videoUrl?: string;
videoThumb?: string;
skus: ProductPlanningSkuRow[];
properties: ProductPlanningPropertyRow[];
material?: string;
detailedMaterial?: string;
};
export type ProductPlanningTaobaoItemResponse = {
success: boolean;
item?: ProductPlanningTaobaoItem;
error?: string;
};
export type ProductPlanningMonthSaleResponse = {
success: boolean;
itemId: string;
recentSales: number | null;
raw?: Record<string, unknown> | null;
error?: string;
};
export type ProductPlanningKeywordInsightSuggestion = {
keyword: string;
weight: number;
rank: number;
};
export type ProductPlanningKeywordInsightTag = {
title: string;
type: "hot" | "normal";
};
export type ProductPlanningKeywordInsightGroup = {
index: number;
type: string;
tags: ProductPlanningKeywordInsightTag[];
};
export type ProductPlanningKeywordInsightResponse = {
success: boolean;
keyword: string;
suggestions: ProductPlanningKeywordInsightSuggestion[];
groups: ProductPlanningKeywordInsightGroup[];
error?: string;
};
export type ProductPlanningTemplate = {
id: string;
categoryName: string;
description?: string;
fileName: string;
fileUrl: string;
filePath?: string;
fileSize: number;
contentType?: string;
createdAt: string;
updatedAt: string;
uploadedById?: string;
};
export type ProductPlanningTemplateListResponse = {
success: boolean;
templates: ProductPlanningTemplate[];
error?: string;
};
export type ProductPlanningTemplateMutationResponse = {
success: boolean;
template?: ProductPlanningTemplate;
error?: string;
};

View File

@ -0,0 +1,159 @@
import type { ProductPlanningPlatform } from "./types";
const HEADER_SANITIZE_PATTERN = /[\s_\-./\\()[\]{}::,,;;'"`~!@#$%^&*+|?<>]/g;
export function normalizeHeader(value: unknown) {
return String(value || "")
.replace(/^\ufeff/, "")
.trim()
.toLowerCase()
.replace(HEADER_SANITIZE_PATTERN, "");
}
export function normalizeText(value: unknown) {
return String(value || "").replace(/^\ufeff/, "").trim();
}
export function parseRangeValue(value: unknown): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
const text = String(value).trim();
if (!text) return null;
const rangeMatch = text.match(
/^(\d+(?:\.\d+)?)\s*[-~~至到]\s*(\d+(?:\.\d+)?)$/
);
if (rangeMatch) {
return Math.min(Number.parseFloat(rangeMatch[1]), Number.parseFloat(rangeMatch[2]));
}
const cleaned = text.replace(/[,,%%\s]/g, "");
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : null;
}
export function parseInteger(value: unknown, fallback = 0) {
const parsed = Number.parseInt(String(value || "").trim(), 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function ensureHttpsUrl(value: unknown) {
const text = String(value || "").trim();
if (!text) return "";
if (text.startsWith("//")) return `https:${text}`;
if (/^https?:\/\//i.test(text)) return text;
return `https://${text}`;
}
export function splitUrlCell(value: unknown) {
const text = String(value || "").trim();
if (!text) return [] as string[];
return text
.split(/[\n,,;;\s]+/)
.map((item) => ensureHttpsUrl(item))
.filter((item) => /^https?:\/\//i.test(item));
}
export function extractTaobaoItemId(value: string) {
const input = String(value || "").trim();
if (!input) return null;
if (/^\d+$/.test(input)) return input;
const patterns = [
/item\.taobao\.com\/item\.htm.*?[?&]id=(\d+)/i,
/detail\.tmall\.com\/item\.htm.*?[?&]id=(\d+)/i,
/a\.m\.taobao\.com\/i(\d+)/i,
/[?&]id=(\d+)/i,
/(\d{10,})/,
];
for (const pattern of patterns) {
const matched = input.match(pattern);
if (matched?.[1]) {
return matched[1];
}
}
return null;
}
export function extractJdItemId(value: string) {
const input = String(value || "").trim();
if (!input) return null;
const explicitPrefix = input.match(/^jd[\s::]+(\d{6,})$/i);
if (explicitPrefix?.[1]) {
return explicitPrefix[1];
}
const patterns = [
/item\.jd\.com\/(\d+)\.html/i,
/sku=(\d+)/i,
/\/product\/(\d+)\.html/i,
/[?&]sku=(\d+)/i,
];
for (const pattern of patterns) {
const matched = input.match(pattern);
if (matched?.[1]) {
return matched[1];
}
}
return null;
}
export function parseMarketplaceItemReference(value: string): {
platform: ProductPlanningPlatform;
itemId: string;
} | null {
const input = String(value || "").trim();
if (!input) return null;
const jdItemId = extractJdItemId(input);
if (jdItemId) {
return {
platform: "jd",
itemId: jdItemId,
};
}
const taobaoItemId = extractTaobaoItemId(input);
if (taobaoItemId) {
return {
platform: "taobao",
itemId: taobaoItemId,
};
}
return null;
}
export function uniqueBy<T>(items: T[], getKey: (item: T) => string) {
const seen = new Set<string>();
const result: T[] = [];
for (const item of items) {
const key = getKey(item);
if (!key || seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export function truncateText(value: unknown, maxLength: number) {
const text = normalizeText(value);
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength)}...`;
}
export function parseOfficialFlag(value: unknown) {
const text = normalizeText(value).toLowerCase();
return ["true", "1", "yes", "y", "是", "官方"].includes(text);
}

View File

@ -66,6 +66,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.34.0", "drizzle-orm": "^0.34.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"exceljs": "^4.4.0",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
@ -113,6 +114,7 @@
"tokenlens": "1.3.0", "tokenlens": "1.3.0",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
"xlsx": "^0.18.5",
"zod": "^3.25.76", "zod": "^3.25.76",
"zustand": "5.0.4" "zustand": "5.0.4"
}, },

561
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -266,8 +266,8 @@ export async function proxy(request: NextRequest) {
// 未登录用户允许访问公开页面,其余需要登录 // 未登录用户允许访问公开页面,其余需要登录
if (!token && !isPublicPage) { if (!token && !isPublicPage) {
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向) // 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页
if (pathname.startsWith("/api/") && request.method !== "GET") { if (pathname.startsWith("/api/")) {
return NextResponse.json( return NextResponse.json(
{ error: "未授权,请先登录" }, { error: "未授权,请先登录" },
{ status: 401 } { status: 401 }

View File

@ -0,0 +1,3 @@
换鞋凳 门口 入户
实木换鞋凳玄关
收纳换鞋凳 小户型

View File

@ -0,0 +1,4 @@
词语,标签分类,搜索人气,支付转化率,出现次数
换鞋凳,品类大词,120,12,3
实木,材质词,90,8,2
玄关,场景词,80,6,2
1 词语 标签分类 搜索人气 支付转化率 出现次数
2 换鞋凳 品类大词 120 12 3
3 实木 材质词 90 8 2
4 玄关 场景词 80 6 2

View File

@ -0,0 +1,105 @@
import path from "node:path";
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";
const SESSION_COOKIE_NAME = "authjs.session-token";
async function createSessionCookie() {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error("AUTH_SECRET 未配置,无法生成测试会话");
}
return encode({
secret,
salt: SESSION_COOKIE_NAME,
token: {
sub: "product-planning-page-user",
id: "product-planning-page-user",
email: "product-planning-page@example.com",
name: "Product Planning Page User",
type: "regular",
},
});
}
test.describe.serial("Product planning page", () => {
const keywordFixture = path.join(process.cwd(), "tests/data/product-planning-keywords.txt");
const manualFixture = path.join(process.cwd(), "tests/data/product-planning-manual.csv");
test("anonymous user is redirected to login", async ({ page }) => {
await page.goto("/product-planning", { waitUntil: "commit" }).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
if (!/ERR_ABORTED|frame was detached/i.test(message)) {
throw error;
}
});
await page.waitForURL(/\/login/);
await expect(page).toHaveURL(/\/login/);
});
test("authenticated user can open product planning page", async ({ page }) => {
const token = await createSessionCookie();
await page.context().addCookies([
{
name: SESSION_COOKIE_NAME,
value: token,
url: "http://localhost:3000",
httpOnly: true,
sameSite: "Lax",
},
]);
await page.goto("/product-planning");
await expect(page).toHaveURL(/\/product-planning/);
await expect(page.getByRole("heading", { name: "商品策划工具" })).toBeVisible();
await expect(page.getByRole("tab", { name: "关键词拆词" })).toBeVisible();
await expect(page.getByRole("tab", { name: "评价 / 问大家分析" })).toBeVisible();
await expect(page.getByRole("tab", { name: "拓展分析" })).toBeVisible();
await expect(page.getByText("云端模板库")).toBeVisible();
});
test("authenticated user can upload keyword file and get results", async ({ page }) => {
const token = await createSessionCookie();
await page.context().addCookies([
{
name: SESSION_COOKIE_NAME,
value: token,
url: "http://localhost:3000",
httpOnly: true,
sameSite: "Lax",
},
]);
await page.goto("/product-planning");
await page.locator("input[type='file']").first().setInputFiles(keywordFixture);
await page.getByRole("button", { name: "开始拆词" }).click();
await expect(page.getByText("已生成")).toBeVisible();
await expect(page.getByRole("cell", { name: "换鞋凳" }).first()).toBeVisible();
await expect(page.getByRole("cell", { name: "场景词" }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "导出 xlsx" })).toBeEnabled();
});
test("authenticated user can import manual segmentation file", async ({ page }) => {
const token = await createSessionCookie();
await page.context().addCookies([
{
name: SESSION_COOKIE_NAME,
value: token,
url: "http://localhost:3000",
httpOnly: true,
sameSite: "Lax",
},
]);
await page.goto("/product-planning");
await page.getByRole("switch").click();
await page.locator("input[type='file']").first().setInputFiles(manualFixture);
await page.getByRole("button", { name: "开始拆词" }).click();
await expect(page.getByRole("cell", { name: "换鞋凳" }).first()).toBeVisible();
await expect(page.getByRole("cell", { name: "品类大词" })).toBeVisible();
await expect(page.getByRole("cell", { name: "实木" }).first()).toBeVisible();
});
});

View File

@ -0,0 +1,79 @@
import { expect, test } from "@playwright/test";
import {
defaultProductPlanningApiConfig,
normalizeProductPlanningApiConfig,
PRODUCT_PLANNING_API_CONFIG_KEY,
type ProductPlanningApiConfig,
} from "@/lib/product-planning/config";
test.describe.serial("/api/admin/settings", () => {
test("guest cannot read product planning system config", async ({ request }) => {
const response = await request.get(
`/api/admin/settings?key=${PRODUCT_PLANNING_API_CONFIG_KEY}`
);
expect(response.status()).toBe(401);
const payload = await response.json();
expect(payload).toMatchObject({
error: "未授权,请先登录",
});
});
test("product planning config normalization trims values and falls back to defaults", () => {
const defaults = defaultProductPlanningApiConfig();
const expectedValue: ProductPlanningApiConfig = {
oneboundBaseUrl: "https://api.example.com/onebound",
taobaoApiKey: "demo-key",
taobaoApiSecret: "demo-secret",
qaApiUrl: defaults.qaApiUrl,
qaToken: "qa-token",
saleApiUrl: "http://sales.example.com/api",
saleToken: "sale-token",
};
const normalized = normalizeProductPlanningApiConfig({
oneboundBaseUrl: " https://api.example.com/onebound/// ",
taobaoApiKey: " demo-key ",
taobaoApiSecret: " demo-secret ",
qaApiUrl: " ",
qaToken: " qa-token ",
saleApiUrl: " http://sales.example.com/api/// ",
saleToken: " sale-token ",
});
expect(normalized).toEqual(expectedValue);
});
test("product planning config normalization can reuse fallback secrets and urls", () => {
const normalized = normalizeProductPlanningApiConfig(
{
oneboundBaseUrl: " ",
taobaoApiKey: "",
taobaoApiSecret: " custom-secret ",
qaApiUrl: "",
qaToken: " ",
saleApiUrl: " ",
saleToken: "",
},
{
oneboundBaseUrl: "https://fallback.example.com/root/",
taobaoApiKey: "fallback-key",
taobaoApiSecret: "fallback-secret",
qaApiUrl: "https://fallback.example.com/qa/",
qaToken: "fallback-qa-token",
saleApiUrl: "https://fallback.example.com/sale/",
saleToken: "fallback-sale-token",
}
);
expect(normalized).toEqual({
oneboundBaseUrl: "https://fallback.example.com/root",
taobaoApiKey: "fallback-key",
taobaoApiSecret: "custom-secret",
qaApiUrl: "https://fallback.example.com/qa",
qaToken: "fallback-qa-token",
saleApiUrl: "https://fallback.example.com/sale",
saleToken: "fallback-sale-token",
} satisfies ProductPlanningApiConfig);
});
});

View File

@ -0,0 +1,298 @@
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";
const SESSION_COOKIE_NAME = "authjs.session-token";
async function createSessionCookie(type: "regular" | "admin" | "agent_admin" = "regular") {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error("AUTH_SECRET 未配置,无法生成测试会话");
}
const token = await encode({
secret,
salt: SESSION_COOKIE_NAME,
token: {
sub: "product-planning-test-user",
id: "product-planning-test-user",
email: "product-planning-test@example.com",
name: "Product Planning Test User",
type,
},
});
return `${SESSION_COOKIE_NAME}=${token}`;
}
test.describe.serial("/api/product-planning unauthorized", () => {
test("guest cannot access reviews route", async ({ request }) => {
const response = await request.get("/api/product-planning/taobao/reviews?id=1234567890&page=1");
expect(response.status()).toBe(401);
const payload = await response.json();
expect(payload).toMatchObject({
error: "未授权,请先登录",
});
});
test("guest cannot access template list route", async ({ request }) => {
const response = await request.get("/api/product-planning/templates");
expect(response.status()).toBe(401);
const payload = await response.json();
expect(payload).toMatchObject({
error: "未授权,请先登录",
});
});
test("guest cannot access image analyze route", async ({ request }) => {
const response = await request.post("/api/product-planning/analyze-image", {
data: {
itemId: "123",
imageUrl: "https://example.com/demo.jpg",
},
});
expect(response.status()).toBe(401);
const payload = await response.json();
expect(payload).toMatchObject({
error: "未授权,请先登录",
});
});
test("guest cannot access SKU analyze route", async ({ request }) => {
const response = await request.post("/api/product-planning/analyze-sku", {
data: {
itemId: "123",
platform: "taobao",
skus: [{ name: "原木款", price: 199 }],
},
});
expect(response.status()).toBe(401);
const payload = await response.json();
expect(payload).toMatchObject({
error: "未授权,请先登录",
});
});
});
test.describe.serial("/api/product-planning", () => {
test("authenticated user gets 400 when reviews id is missing", async ({ request }) => {
const cookie = await createSessionCookie();
const response = await request.get("/api/product-planning/taobao/reviews?page=1", {
headers: {
Cookie: cookie,
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "缺少商品ID参数",
});
});
test("authenticated user gets 400 when QA itemId is missing", async ({ request }) => {
const cookie = await createSessionCookie();
const response = await request.post("/api/product-planning/taobao/qa", {
headers: {
Cookie: cookie,
},
data: {
itemId: "",
page: 1,
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "请求参数不合法",
});
});
test("authenticated user gets 400 when analyze rows are empty", async ({ request }) => {
const cookie = await createSessionCookie();
const response = await request.post("/api/product-planning/analyze", {
headers: {
Cookie: cookie,
},
data: {
kind: "review",
rows: [],
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "请求参数不合法或 rows 为空",
});
});
test("authenticated user gets 400 when image analyze payload is invalid", async ({ request }) => {
const cookie = await createSessionCookie();
const response = await request.post("/api/product-planning/analyze-image", {
headers: {
Cookie: cookie,
},
data: {
itemId: "",
imageUrl: "not-a-url",
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "请求参数不合法",
});
});
test("authenticated user gets 400 when SKU analyze payload is invalid", async ({ request }) => {
const cookie = await createSessionCookie();
const response = await request.post("/api/product-planning/analyze-sku", {
headers: {
Cookie: cookie,
},
data: {
itemId: "",
platform: "taobao",
skus: [],
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "请求参数不合法或 skus 为空",
});
});
test("authenticated user gets 400 when item id is missing for competitor item route", async ({
request,
}) => {
const cookie = await createSessionCookie();
const response = await request.get("/api/product-planning/taobao/item", {
headers: {
Cookie: cookie,
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "缺少商品ID参数",
});
});
test("authenticated user gets 400 when item id is missing for JD item route", async ({
request,
}) => {
const cookie = await createSessionCookie();
const response = await request.get("/api/product-planning/jd/item", {
headers: {
Cookie: cookie,
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "缺少商品ID参数",
});
});
test("authenticated user gets 400 when item id is missing for month-sale route", async ({
request,
}) => {
const cookie = await createSessionCookie();
const response = await request.get("/api/product-planning/taobao/month-sale", {
headers: {
Cookie: cookie,
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "缺少商品ID参数",
});
});
test("authenticated user gets 400 when keyword insight q is missing", async ({ request }) => {
const cookie = await createSessionCookie();
const response = await request.get("/api/product-planning/keyword-insight", {
headers: {
Cookie: cookie,
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "缺少关键词参数",
});
});
test("regular user cannot upload shared templates", async ({ request }) => {
const cookie = await createSessionCookie("regular");
const response = await request.post("/api/product-planning/templates", {
headers: {
Cookie: cookie,
},
});
expect(response.status()).toBe(403);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "需要管理员权限",
});
});
test("admin upload rejects missing template file", async ({ request }) => {
const cookie = await createSessionCookie("admin");
const response = await request.post("/api/product-planning/templates", {
headers: {
Cookie: cookie,
},
multipart: {
categoryName: "换鞋凳",
},
});
expect(response.status()).toBe(400);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "缺少模板文件",
});
});
test("regular user cannot delete shared templates", async ({ request }) => {
const cookie = await createSessionCookie("regular");
const response = await request.delete("/api/product-planning/templates/mock-template-id", {
headers: {
Cookie: cookie,
},
});
expect(response.status()).toBe(403);
const payload = await response.json();
expect(payload).toMatchObject({
success: false,
error: "需要管理员权限",
});
});
});