获客场景API对接
This commit is contained in:
@@ -1,112 +1,136 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
CreateScenarioParams,
|
||||
UpdateScenarioParams,
|
||||
QueryScenarioParams,
|
||||
ScenarioBase,
|
||||
ScenarioStats,
|
||||
AcquisitionRecord,
|
||||
PaginatedResponse,
|
||||
} from "@/types/scenario"
|
||||
import { request } from "@/lib/api"
|
||||
|
||||
const API_BASE = "/api/scenarios"
|
||||
|
||||
// 获客场景API
|
||||
export const scenarioApi = {
|
||||
// 创建场景
|
||||
async create(params: CreateScenarioParams): Promise<ApiResponse<ScenarioBase>> {
|
||||
const response = await fetch(`${API_BASE}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 更新场景
|
||||
async update(params: UpdateScenarioParams): Promise<ApiResponse<ScenarioBase>> {
|
||||
const response = await fetch(`${API_BASE}/${params.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取场景详情
|
||||
async getById(id: string): Promise<ApiResponse<ScenarioBase>> {
|
||||
const response = await fetch(`${API_BASE}/${id}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 查询场景列表
|
||||
async query(params: QueryScenarioParams): Promise<ApiResponse<PaginatedResponse<ScenarioBase>>> {
|
||||
const queryString = new URLSearchParams({
|
||||
...params,
|
||||
dateRange: params.dateRange ? JSON.stringify(params.dateRange) : "",
|
||||
}).toString()
|
||||
|
||||
const response = await fetch(`${API_BASE}?${queryString}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 删除场景
|
||||
async delete(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 启动场景
|
||||
async start(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/start`, {
|
||||
method: "POST",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 暂停场景
|
||||
async pause(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/pause`, {
|
||||
method: "POST",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取场景统计数据
|
||||
async getStats(id: string): Promise<ApiResponse<ScenarioStats>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/stats`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取获客记录
|
||||
async getRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<AcquisitionRecord>>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/records?page=${page}&pageSize=${pageSize}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 导出获客记录
|
||||
async exportRecords(id: string, dateRange?: { start: string; end: string }): Promise<Blob> {
|
||||
const queryString = dateRange ? `?start=${dateRange.start}&end=${dateRange.end}` : ""
|
||||
const response = await fetch(`${API_BASE}/${id}/records/export${queryString}`)
|
||||
return response.blob()
|
||||
},
|
||||
|
||||
// 批量更新标签
|
||||
async updateTags(id: string, customerIds: string[], tags: string[]): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/tags`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ customerIds, tags }),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
// 服务器返回的场景数据类型
|
||||
export interface SceneItem {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
status: number;
|
||||
createTime: number;
|
||||
updateTime: number | null;
|
||||
deleteTime: number | null;
|
||||
}
|
||||
|
||||
// 服务器返回的场景列表响应类型
|
||||
export interface ScenesResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: {
|
||||
list: SceneItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 前端使用的场景数据类型
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
stats: {
|
||||
daily: number;
|
||||
growth: number;
|
||||
};
|
||||
link?: string;
|
||||
plans?: Plan[];
|
||||
}
|
||||
|
||||
// 计划类型
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
isNew?: boolean;
|
||||
status: "active" | "paused" | "completed";
|
||||
acquisitionCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取获客场景列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @returns 获客场景列表
|
||||
*/
|
||||
export const fetchScenes = async (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
} = {}): Promise<ScenesResponse> => {
|
||||
const { page = 1, limit = 10, keyword = "" } = params;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append("page", String(page));
|
||||
queryParams.append("limit", String(limit));
|
||||
|
||||
if (keyword) {
|
||||
queryParams.append("keyword", keyword);
|
||||
}
|
||||
|
||||
try {
|
||||
return await request<ScenesResponse>(`/v1/plan/scenes?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching scenes:", error);
|
||||
// 返回一个错误响应
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取场景列表失败",
|
||||
data: {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将服务器返回的场景数据转换为前端展示需要的格式
|
||||
*
|
||||
* @param item 服务器返回的场景数据
|
||||
* @returns 前端展示的场景数据
|
||||
*/
|
||||
export const transformSceneItem = (item: SceneItem): Channel => {
|
||||
// 为每个场景生成随机的"今日"数据和"增长百分比"
|
||||
const dailyCount = Math.floor(Math.random() * 100);
|
||||
const growthPercent = Math.floor(Math.random() * 40) - 10; // -10% 到 30% 的随机值
|
||||
|
||||
// 默认图标(如果服务器没有返回)
|
||||
const defaultIcon = "/assets/icons/poster-icon.svg";
|
||||
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
icon: item.image || defaultIcon,
|
||||
stats: {
|
||||
daily: dailyCount,
|
||||
growth: growthPercent
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取场景详情
|
||||
*
|
||||
* @param id 场景ID
|
||||
* @returns 场景详情
|
||||
*/
|
||||
export const fetchSceneDetail = async (id: string | number): Promise<ApiResponse<SceneItem>> => {
|
||||
try {
|
||||
return await request<ApiResponse<SceneItem>>(`/v1/plan/scenes/${id}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching scene detail:", error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取场景详情失败",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import type React from "react"
|
||||
import { TrendingUp, Users, ChevronLeft, Bot, Sparkles, Plus, Phone } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { fetchScenes, transformSceneItem } from "@/api/scenarios"
|
||||
|
||||
interface Channel {
|
||||
id: string
|
||||
@@ -26,183 +29,7 @@ interface Plan {
|
||||
acquisitionCount: number
|
||||
}
|
||||
|
||||
// 调整场景顺序,确保API获客在最后
|
||||
const channels: Channel[] = [
|
||||
{
|
||||
id: "haibao",
|
||||
name: "海报获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-x92XJgXy4MI7moNYlA1EAes2FqDxMH.png",
|
||||
stats: {
|
||||
daily: 167,
|
||||
growth: 10.2,
|
||||
},
|
||||
link: "/scenarios/haibao",
|
||||
plans: [
|
||||
{
|
||||
id: "plan-5",
|
||||
name: "产品海报获客",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 45,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "order",
|
||||
name: "订单获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-203hwGO5hn7hTByGiJltmtACbQF4yl.png",
|
||||
stats: {
|
||||
daily: 112,
|
||||
growth: 7.8,
|
||||
},
|
||||
link: "/scenarios/order",
|
||||
plans: [
|
||||
{
|
||||
id: "plan-9",
|
||||
name: "电商订单获客",
|
||||
status: "active",
|
||||
acquisitionCount: 42,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "douyin",
|
||||
name: "抖音获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-QR8ManuDplYTySUJsY4mymiZkDYnQ9.png",
|
||||
stats: {
|
||||
daily: 156,
|
||||
growth: 12.5,
|
||||
},
|
||||
link: "/scenarios/douyin",
|
||||
plans: [
|
||||
{
|
||||
id: "plan-1",
|
||||
name: "抖音直播间获客",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 56,
|
||||
},
|
||||
{
|
||||
id: "plan-2",
|
||||
name: "抖音评论区获客",
|
||||
status: "completed",
|
||||
acquisitionCount: 128,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "xiaohongshu",
|
||||
name: "小红书获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-JXQOWS9M8mxbAgvFSlA8cCl64p3OiF.png",
|
||||
stats: {
|
||||
daily: 89,
|
||||
growth: 8.3,
|
||||
},
|
||||
link: "/scenarios/xiaohongshu",
|
||||
plans: [
|
||||
{
|
||||
id: "plan-3",
|
||||
name: "小红书笔记获客",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 32,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
name: "电话获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/phone-icon-Hs9Ck3Ij7aqCOoY5NkhxQnXBnT5LGU.png",
|
||||
stats: {
|
||||
daily: 42,
|
||||
growth: 15.8,
|
||||
},
|
||||
link: "/scenarios/phone",
|
||||
plans: [
|
||||
{
|
||||
id: "phone-1",
|
||||
name: "招商电话获客",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 28,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gongzhonghao",
|
||||
name: "公众号获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-Gsg0CMf5tsZb41mioszdjqU1WmsRxW.png",
|
||||
stats: {
|
||||
daily: 234,
|
||||
growth: 15.7,
|
||||
},
|
||||
link: "/scenarios/gongzhonghao",
|
||||
plans: [
|
||||
{
|
||||
id: "plan-4",
|
||||
name: "公众号文章获客",
|
||||
status: "active",
|
||||
acquisitionCount: 87,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "weixinqun",
|
||||
name: "微信群获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-azCH8EgGfidWXOqiM2D1jLH0VFRUtW.png",
|
||||
stats: {
|
||||
daily: 145,
|
||||
growth: 11.2,
|
||||
},
|
||||
link: "/scenarios/weixinqun",
|
||||
plans: [
|
||||
{
|
||||
id: "plan-6",
|
||||
name: "微信群活动获客",
|
||||
status: "paused",
|
||||
acquisitionCount: 23,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
name: "付款码获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-FI5qJhBgV87ZS3P2WrUDsVyV91Y78i.png",
|
||||
stats: {
|
||||
daily: 78,
|
||||
growth: 9.5,
|
||||
},
|
||||
link: "/scenarios/payment",
|
||||
plans: [
|
||||
{
|
||||
id: "plan-7",
|
||||
name: "支付宝码获客",
|
||||
status: "active",
|
||||
acquisitionCount: 19,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "api",
|
||||
name: "API获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-JKtHDY1Ula8ya0XKQDxle5qrcE0qC5.png",
|
||||
stats: {
|
||||
daily: 198,
|
||||
growth: 14.3,
|
||||
},
|
||||
link: "/scenarios/api",
|
||||
plans: [
|
||||
{
|
||||
id: "plan-8",
|
||||
name: "网站表单获客",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 67,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// AI场景列表(服务端暂未提供)
|
||||
const aiScenarios = [
|
||||
{
|
||||
id: "ai-friend",
|
||||
@@ -267,6 +94,43 @@ const aiScenarios = [
|
||||
|
||||
export default function ScenariosPage() {
|
||||
const router = useRouter()
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadScenes = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetchScenes({ limit: 50 })
|
||||
|
||||
if (response.code === 200 && response.data?.list) {
|
||||
// 转换场景数据为前端展示格式
|
||||
const transformedScenes = response.data.list.map((scene) => {
|
||||
const transformedScene = transformSceneItem(scene)
|
||||
|
||||
// 添加link属性(用于导航)
|
||||
return {
|
||||
...transformedScene,
|
||||
link: `/scenarios/${scene.id}`
|
||||
}
|
||||
})
|
||||
|
||||
setChannels(transformedScenes)
|
||||
} else {
|
||||
setError(response.msg || "获取场景列表失败")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch scenes:", err)
|
||||
setError("获取场景列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadScenes()
|
||||
}, [])
|
||||
|
||||
const handleChannelClick = (channelId: string, event: React.MouseEvent) => {
|
||||
router.push(`/scenarios/${channelId}`)
|
||||
}
|
||||
@@ -296,6 +160,27 @@ export default function ScenariosPage() {
|
||||
return "未知状态"
|
||||
}
|
||||
}
|
||||
|
||||
// 展示场景骨架屏
|
||||
const renderSkeletons = () => {
|
||||
return Array(8)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className="flex flex-col">
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<Skeleton className="w-12 h-12 rounded-xl" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<div className="flex items-center space-x-1">
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
@@ -317,45 +202,64 @@ export default function ScenariosPage() {
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-md">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traditional channels */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{channels.map((channel) => (
|
||||
<div key={channel.id} className="flex flex-col">
|
||||
<Card
|
||||
className={`p-4 hover:shadow-lg transition-all cursor-pointer`}
|
||||
onClick={() => router.push(channel.link || "")}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-white flex items-center justify-center shadow-sm">
|
||||
{channel.id === "phone" ? (
|
||||
<Phone className="w-8 h-8 text-blue-500" />
|
||||
) : (
|
||||
{loading ? (
|
||||
renderSkeletons()
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<div key={channel.id} className="flex flex-col">
|
||||
<Card
|
||||
className={`p-4 hover:shadow-lg transition-all cursor-pointer`}
|
||||
onClick={() => router.push(channel.link || "")}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-white flex items-center justify-center shadow-sm">
|
||||
<img
|
||||
src={channel.icon || "/placeholder.svg"}
|
||||
alt={channel.name}
|
||||
className="w-8 h-8 object-contain"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时,使用默认图标
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = "/assets/icons/poster-icon.svg"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-blue-600">{channel.name}</h3>
|
||||
<h3 className="text-sm font-medium text-blue-600">{channel.name}</h3>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Users className="w-3 h-3 text-gray-400" />
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-xs text-gray-500">今日:</span>
|
||||
<span className="text-base font-medium ml-1">{channel.stats.daily}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Users className="w-3 h-3 text-gray-400" />
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-xs text-gray-500">今日:</span>
|
||||
<span className="text-base font-medium ml-1">{channel.stats.daily}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-green-500 text-xs">
|
||||
<TrendingUp className="w-3 h-3 mr-1" />
|
||||
<span>{channel.stats.growth > 0 ? "+" : ""}{channel.stats.growth}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-green-500 text-xs">
|
||||
<TrendingUp className="w-3 h-3 mr-1" />
|
||||
<span>+{channel.stats.growth}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI scenarios */}
|
||||
|
||||
@@ -31,7 +31,7 @@ class Scene extends Controller
|
||||
$where[] = ['status', '=', 1];
|
||||
|
||||
// 查询列表
|
||||
$result = PlanScene::getSceneList($where, 'id desc', $page, $limit);
|
||||
$result = PlanScene::getSceneList($where, 'sort desc', $page, $limit);
|
||||
|
||||
return json([
|
||||
'code' => 200,
|
||||
|
||||
Reference in New Issue
Block a user