feat: migrate product planning workspace
This commit is contained in:
parent
5f54531f75
commit
478562c312
@ -49,6 +49,7 @@ export default async function AdminLayout({
|
||||
}))}
|
||||
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
|
||||
/>
|
||||
<div className="flex-1 pl-16">{children}</div>
|
||||
</div>
|
||||
|
||||
@ -33,6 +33,12 @@ import {
|
||||
defaultThirdPartySqlServerConfig,
|
||||
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
|
||||
} from "@/lib/sqlserver/config";
|
||||
import {
|
||||
defaultProductPlanningApiConfig,
|
||||
normalizeProductPlanningApiConfig,
|
||||
PRODUCT_PLANNING_API_CONFIG_KEY,
|
||||
type ProductPlanningApiConfig,
|
||||
} from "@/lib/product-planning/config";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
|
||||
@ -255,6 +261,7 @@ type SiteSettings = {
|
||||
tutorialVideoUrl: string | null;
|
||||
tutorialEnabled: boolean;
|
||||
rankingEnabled: boolean;
|
||||
productPlanningEnabled: boolean;
|
||||
canvasEnabled: boolean;
|
||||
agentEnabled: boolean;
|
||||
canvasAgentModeEnabled: boolean;
|
||||
@ -696,6 +703,7 @@ export function AdminSettingsClient() {
|
||||
tutorialVideoUrl: null,
|
||||
tutorialEnabled: false,
|
||||
rankingEnabled: true,
|
||||
productPlanningEnabled: true,
|
||||
canvasEnabled: true,
|
||||
agentEnabled: true,
|
||||
canvasAgentModeEnabled: true,
|
||||
@ -788,6 +796,7 @@ export function AdminSettingsClient() {
|
||||
tutorialVideoUrl: siteSettingsData.tutorialVideoUrl || null,
|
||||
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
|
||||
rankingEnabled: siteSettingsData.rankingEnabled ?? true,
|
||||
productPlanningEnabled: (siteSettingsData as any).productPlanningEnabled ?? true,
|
||||
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
|
||||
agentEnabled: (siteSettingsData as any).agentEnabled ?? true,
|
||||
canvasAgentModeEnabled: (siteSettingsData as any).canvasAgentModeEnabled ?? true,
|
||||
@ -1080,6 +1089,8 @@ export function AdminSettingsClient() {
|
||||
...defaultThirdPartySqlServerConfig,
|
||||
});
|
||||
const [isTestingThirdPartySqlServer, setIsTestingThirdPartySqlServer] = useState(false);
|
||||
const [productPlanningApiConfig, setProductPlanningApiConfig] =
|
||||
useState<ProductPlanningApiConfig>(defaultProductPlanningApiConfig());
|
||||
|
||||
// 加载抠图API配置
|
||||
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
|
||||
@ -1174,6 +1185,16 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}, [watermarkRemoveConfigData]);
|
||||
|
||||
const { data: productPlanningApiConfigData } = useSWR(
|
||||
`/api/admin/settings?key=${PRODUCT_PLANNING_API_CONFIG_KEY}`,
|
||||
fetcher
|
||||
);
|
||||
useEffect(() => {
|
||||
setProductPlanningApiConfig(
|
||||
normalizeProductPlanningApiConfig(productPlanningApiConfigData?.setting?.value)
|
||||
);
|
||||
}, [productPlanningApiConfigData]);
|
||||
|
||||
// 加载角度编辑API配置
|
||||
const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher);
|
||||
useEffect(() => {
|
||||
@ -2496,6 +2517,36 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProductPlanningApiConfig = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: PRODUCT_PLANNING_API_CONFIG_KEY,
|
||||
value: {
|
||||
oneboundBaseUrl: productPlanningApiConfig.oneboundBaseUrl.trim(),
|
||||
taobaoApiKey: productPlanningApiConfig.taobaoApiKey.trim(),
|
||||
taobaoApiSecret: productPlanningApiConfig.taobaoApiSecret.trim(),
|
||||
qaApiUrl: productPlanningApiConfig.qaApiUrl.trim(),
|
||||
qaToken: productPlanningApiConfig.qaToken.trim(),
|
||||
saleApiUrl: productPlanningApiConfig.saleApiUrl.trim(),
|
||||
saleToken: productPlanningApiConfig.saleToken.trim(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.cause || payload?.message || payload?.error || "保存失败");
|
||||
}
|
||||
|
||||
toast.success("商品策划工具 API 配置保存成功");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存商品策划工具配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveThirdPartySqlServerConfig = async () => {
|
||||
try {
|
||||
if (thirdPartySqlServerConfig.enabled) {
|
||||
@ -6316,6 +6367,21 @@ export function AdminSettingsClient() {
|
||||
/>
|
||||
</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-1 space-y-1">
|
||||
@ -8664,6 +8730,148 @@ export function AdminSettingsClient() {
|
||||
|
||||
{/* 本地AI设置 */}
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Moonshot 官方 Kimi K2.5 配置</CardTitle>
|
||||
|
||||
@ -78,6 +78,7 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||
}))}
|
||||
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
|
||||
agentEnabled={(siteSettings as any).agentEnabled ?? true}
|
||||
>
|
||||
{children}
|
||||
|
||||
21
app/(chat)/product-planning/page.tsx
Normal file
21
app/(chat)/product-planning/page.tsx
Normal 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} />;
|
||||
}
|
||||
@ -13,6 +13,10 @@ import {
|
||||
resolveRealtimeProviders,
|
||||
} from "@/lib/realtime/config";
|
||||
import { DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY } from "@/lib/ai/detail-agent-model-config";
|
||||
import {
|
||||
normalizeProductPlanningApiConfig,
|
||||
PRODUCT_PLANNING_API_CONFIG_KEY,
|
||||
} from "@/lib/product-planning/config";
|
||||
import {
|
||||
normalizeThirdPartySqlServerConfig,
|
||||
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
|
||||
@ -201,6 +205,10 @@ export async function POST(request: Request) {
|
||||
nextValue = normalizedValue;
|
||||
}
|
||||
|
||||
if (key === PRODUCT_PLANNING_API_CONFIG_KEY) {
|
||||
nextValue = normalizeProductPlanningApiConfig(value);
|
||||
}
|
||||
|
||||
const setting = await upsertSystemSetting({
|
||||
key,
|
||||
value: nextValue,
|
||||
|
||||
178
app/api/product-planning/analyze-image/route.ts
Normal file
178
app/api/product-planning/analyze-image/route.ts
Normal 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, "图片分析失败");
|
||||
}
|
||||
}
|
||||
112
app/api/product-planning/analyze-sku/route.ts
Normal file
112
app/api/product-planning/analyze-sku/route.ts
Normal 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 卖点分析失败");
|
||||
}
|
||||
}
|
||||
141
app/api/product-planning/analyze/route.ts
Normal file
141
app/api/product-planning/analyze/route.ts
Normal 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 分析失败");
|
||||
}
|
||||
}
|
||||
100
app/api/product-planning/jd/item/route.ts
Normal file
100
app/api/product-planning/jd/item/route.ts
Normal 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, "京东商品详情接口调用失败");
|
||||
}
|
||||
}
|
||||
133
app/api/product-planning/keyword-insight/route.ts
Normal file
133
app/api/product-planning/keyword-insight/route.ts
Normal 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, "关键词洞察接口调用失败");
|
||||
}
|
||||
}
|
||||
102
app/api/product-planning/taobao/item/route.ts
Normal file
102
app/api/product-planning/taobao/item/route.ts
Normal 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, "淘宝商品详情接口调用失败");
|
||||
}
|
||||
}
|
||||
71
app/api/product-planning/taobao/month-sale/route.ts
Normal file
71
app/api/product-planning/taobao/month-sale/route.ts
Normal 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, "月销量接口调用失败");
|
||||
}
|
||||
}
|
||||
170
app/api/product-planning/taobao/qa/route.ts
Normal file
170
app/api/product-planning/taobao/qa/route.ts
Normal 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, "问大家接口调用失败");
|
||||
}
|
||||
}
|
||||
109
app/api/product-planning/taobao/reviews/route.ts
Normal file
109
app/api/product-planning/taobao/reviews/route.ts
Normal 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, "淘宝评价接口调用失败");
|
||||
}
|
||||
}
|
||||
143
app/api/product-planning/templates/[id]/route.ts
Normal file
143
app/api/product-planning/templates/[id]/route.ts
Normal 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, "模板删除失败");
|
||||
}
|
||||
}
|
||||
187
app/api/product-planning/templates/route.ts
Normal file
187
app/api/product-planning/templates/route.ts
Normal 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, "模板上传失败");
|
||||
}
|
||||
}
|
||||
@ -507,6 +507,7 @@ export async function GET() {
|
||||
tutorialVideoUrl: null,
|
||||
tutorialEnabled: false,
|
||||
rankingEnabled: true,
|
||||
productPlanningEnabled: true,
|
||||
canvasEnabled: true,
|
||||
agentEnabled: true,
|
||||
canvasAgentModeEnabled: true,
|
||||
@ -615,6 +616,7 @@ const updateSchema = z.object({
|
||||
tutorialVideoUrl: z.string().nullable().optional(),
|
||||
tutorialEnabled: z.boolean().optional(),
|
||||
rankingEnabled: z.boolean().optional(),
|
||||
productPlanningEnabled: z.boolean().optional(),
|
||||
canvasEnabled: z.boolean().optional(),
|
||||
agentEnabled: z.boolean().optional(),
|
||||
canvasAgentModeEnabled: z.boolean().optional(),
|
||||
@ -1546,6 +1548,7 @@ export async function PUT(request: NextRequest) {
|
||||
tutorialVideoUrl: siteSettingsPayload.tutorialVideoUrl,
|
||||
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
|
||||
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
|
||||
productPlanningEnabled: siteSettingsPayload.productPlanningEnabled ?? true,
|
||||
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
|
||||
agentEnabled: siteSettingsPayload.agentEnabled ?? true,
|
||||
realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true,
|
||||
|
||||
5
app/ping/route.ts
Normal file
5
app/ping/route.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@ -41,6 +41,7 @@ type ChatShellProps = {
|
||||
tutorialVideos: TutorialVideo[];
|
||||
tutorialEnabled: boolean;
|
||||
rankingEnabled: boolean;
|
||||
productPlanningEnabled: boolean;
|
||||
agentEnabled: boolean;
|
||||
};
|
||||
|
||||
@ -62,6 +63,7 @@ export function ChatShell({
|
||||
tutorialVideos,
|
||||
tutorialEnabled,
|
||||
rankingEnabled,
|
||||
productPlanningEnabled,
|
||||
agentEnabled,
|
||||
}: ChatShellProps) {
|
||||
const pathname = usePathname();
|
||||
@ -87,6 +89,7 @@ export function ChatShell({
|
||||
tutorialVideos={tutorialVideos}
|
||||
tutorialEnabled={tutorialEnabled}
|
||||
rankingEnabled={rankingEnabled}
|
||||
productPlanningEnabled={productPlanningEnabled}
|
||||
agentEnabled={agentEnabled}
|
||||
/>
|
||||
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
|
||||
|
||||
2918
components/product-planning/product-planning-page.tsx
Normal file
2918
components/product-planning/product-planning-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X } from "lucide-react";
|
||||
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X, BarChart3 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
@ -57,6 +57,7 @@ type TopNavbarProps = {
|
||||
tutorialVideos?: TutorialVideo[];
|
||||
tutorialEnabled?: boolean;
|
||||
rankingEnabled?: boolean;
|
||||
productPlanningEnabled?: boolean;
|
||||
agentEnabled?: boolean;
|
||||
};
|
||||
|
||||
@ -74,6 +75,7 @@ export function TopNavbar({
|
||||
tutorialVideos = [],
|
||||
tutorialEnabled = false,
|
||||
rankingEnabled = true,
|
||||
productPlanningEnabled = true,
|
||||
agentEnabled = true,
|
||||
}: TopNavbarProps) {
|
||||
const router = useRouter();
|
||||
@ -141,6 +143,7 @@ export function TopNavbar({
|
||||
if (trimmed === "智能体") return "智能";
|
||||
if (trimmed === "无限画布") return "画布";
|
||||
if (trimmed === "我的图册") return "图册";
|
||||
if (trimmed === "商品策划") return "策划";
|
||||
if (trimmed === "系统设置") return "设置";
|
||||
if (trimmed === "工具箱") return "工具";
|
||||
if (trimmed === "工具箱2") return "工具";
|
||||
@ -318,6 +321,21 @@ export function TopNavbar({
|
||||
<span className={navLabelClass}>{getShortLabel("AI精选")}</span>
|
||||
</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 && (
|
||||
<DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}>
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "productPlanningEnabled" boolean DEFAULT true NOT NULL;
|
||||
@ -330,6 +330,13 @@
|
||||
"when": 1774074000000,
|
||||
"tag": "0060_add_canvas_detail_agent_model",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "7",
|
||||
"when": 1774483200000,
|
||||
"tag": "0061_add_product_planning_enabled_site_setting",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -4081,6 +4081,7 @@ export async function getSiteSettings() {
|
||||
tutorialVideoUrl: null,
|
||||
tutorialEnabled: false,
|
||||
rankingEnabled: true,
|
||||
productPlanningEnabled: true,
|
||||
agentEnabled: true,
|
||||
canvasDetailAgentModelId: null,
|
||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
|
||||
@ -374,6 +374,7 @@ export const siteSettings = pgTable("SiteSettings", {
|
||||
tutorialEnabled: boolean("tutorialEnabled").notNull().default(false), // 是否显示功能教程按钮
|
||||
// 精选榜设置
|
||||
rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮
|
||||
productPlanningEnabled: boolean("productPlanningEnabled").notNull().default(true), // 是否显示并启用商品策划工具
|
||||
// 无限画布设置
|
||||
canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能
|
||||
agentEnabled: boolean("agentEnabled").notNull().default(true), // 是否启用智能体功能
|
||||
|
||||
127
lib/product-planning/analyze.ts
Normal file
127
lib/product-planning/analyze.ts
Normal 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");
|
||||
}
|
||||
59
lib/product-planning/config.ts
Normal file
59
lib/product-planning/config.ts
Normal 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 || ""),
|
||||
};
|
||||
}
|
||||
268
lib/product-planning/export.ts
Normal file
268
lib/product-planning/export.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
311
lib/product-planning/file-parsers.ts
Normal file
311
lib/product-planning/file-parsers.ts
Normal 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;
|
||||
}
|
||||
406
lib/product-planning/keyword-segmentation.ts
Normal file
406
lib/product-planning/keyword-segmentation.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
31
lib/product-planning/page-auth.ts
Normal file
31
lib/product-planning/page-auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
lib/product-planning/server-auth.ts
Normal file
13
lib/product-planning/server-auth.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
18
lib/product-planning/server-config.ts
Normal file
18
lib/product-planning/server-config.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
10
lib/product-planning/server-feature.ts
Normal file
10
lib/product-planning/server-feature.ts
Normal 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;
|
||||
}
|
||||
305
lib/product-planning/taobao.ts
Normal file
305
lib/product-planning/taobao.ts
Normal 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;
|
||||
}
|
||||
79
lib/product-planning/templates.ts
Normal file
79
lib/product-planning/templates.ts
Normal 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));
|
||||
}
|
||||
177
lib/product-planning/types.ts
Normal file
177
lib/product-planning/types.ts
Normal 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;
|
||||
};
|
||||
159
lib/product-planning/utils.ts
Normal file
159
lib/product-planning/utils.ts
Normal 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);
|
||||
}
|
||||
@ -66,6 +66,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.34.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"form-data": "^4.0.5",
|
||||
"framer-motion": "^11.18.2",
|
||||
@ -113,6 +114,7 @@
|
||||
"tokenlens": "1.3.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "5.0.4"
|
||||
},
|
||||
|
||||
561
pnpm-lock.yaml
generated
561
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
proxy.ts
4
proxy.ts
@ -266,8 +266,8 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
// 未登录用户允许访问公开页面,其余需要登录
|
||||
if (!token && !isPublicPage) {
|
||||
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
|
||||
if (pathname.startsWith("/api/") && request.method !== "GET") {
|
||||
// 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "未授权,请先登录" },
|
||||
{ status: 401 }
|
||||
|
||||
3
tests/data/product-planning-keywords.txt
Normal file
3
tests/data/product-planning-keywords.txt
Normal file
@ -0,0 +1,3 @@
|
||||
换鞋凳 门口 入户
|
||||
实木换鞋凳玄关
|
||||
收纳换鞋凳 小户型
|
||||
4
tests/data/product-planning-manual.csv
Normal file
4
tests/data/product-planning-manual.csv
Normal file
@ -0,0 +1,4 @@
|
||||
词语,标签分类,搜索人气,支付转化率,出现次数
|
||||
换鞋凳,品类大词,120,12,3
|
||||
实木,材质词,90,8,2
|
||||
玄关,场景词,80,6,2
|
||||
|
105
tests/e2e/product-planning.test.ts
Normal file
105
tests/e2e/product-planning.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
79
tests/routes/admin-settings.test.ts
Normal file
79
tests/routes/admin-settings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
298
tests/routes/product-planning.test.ts
Normal file
298
tests/routes/product-planning.test.ts
Normal 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: "需要管理员权限",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user