存客宝 React
This commit is contained in:
119
Cunkebao/app/scenarios/[channel]/acquired/page.tsx
Normal file
119
Cunkebao/app/scenarios/[channel]/acquired/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Plus } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
tags: string[]
|
||||
acquiredTime: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export default function AcquiredCustomersPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
const [customers] = useState<Customer[]>(
|
||||
Array.from({ length: 31 }, (_, i) => ({
|
||||
id: `customer-${i + 1}`,
|
||||
nickname: `用户${i + 1}`,
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
|
||||
tags: ["直播间", "高互动", Math.random() > 0.5 ? "潜在客户" : "意向客户"],
|
||||
acquiredTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
|
||||
source: Math.random() > 0.5 ? "直播间" : "评论区",
|
||||
})),
|
||||
)
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
const totalPages = Math.ceil(customers.length / itemsPerPage)
|
||||
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}已获客</h1>
|
||||
</div>
|
||||
<Button variant="default" onClick={() => router.push(`/scenarios/new`)} className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{currentCustomers.map((customer) => (
|
||||
<Card key={customer.id} className="p-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={customer.avatar || "/placeholder.svg"}
|
||||
alt={customer.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">{customer.nickname}</h3>
|
||||
<span className="text-sm text-gray-500">{customer.acquiredTime}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{customer.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">来源:{customer.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {currentPage} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
123
Cunkebao/app/scenarios/[channel]/added/page.tsx
Normal file
123
Cunkebao/app/scenarios/[channel]/added/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Plus } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
interface AddedCustomer {
|
||||
id: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
tags: string[]
|
||||
addedTime: string
|
||||
source: string
|
||||
wechatId: string
|
||||
}
|
||||
|
||||
export default function AddedCustomersPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const { channel } = params // Extract channel from params
|
||||
const channelName = getChannelName(channel)
|
||||
|
||||
const [customers] = useState<AddedCustomer[]>(
|
||||
Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `customer-${i + 1}`,
|
||||
nickname: `用户${i + 1}`,
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
|
||||
tags: ["已添加", Math.random() > 0.5 ? "高意向" : "待跟进", "直播间"],
|
||||
addedTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
|
||||
source: Math.random() > 0.5 ? "直播间" : "评论区",
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
})),
|
||||
)
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
const totalPages = Math.ceil(customers.length / itemsPerPage)
|
||||
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}已添加</h1>
|
||||
</div>
|
||||
<Button variant="default" onClick={() => router.push(`/scenarios/new`)} className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{currentCustomers.map((customer) => (
|
||||
<Card key={customer.id} className="p-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={customer.avatar || "/placeholder.svg"}
|
||||
alt={customer.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">{customer.nickname}</h3>
|
||||
<span className="text-sm text-gray-500">{customer.addedTime}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">微信号:{customer.wechatId}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{customer.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">来源:{customer.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {currentPage} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
463
Cunkebao/app/scenarios/[channel]/api/page.tsx
Normal file
463
Cunkebao/app/scenarios/[channel]/api/page.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Copy, Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
haibao: "海报",
|
||||
phone: "电话",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
createdAt: string
|
||||
lastUsed: string | null
|
||||
status: "active" | "inactive"
|
||||
}
|
||||
|
||||
interface Webhook {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
events: string[]
|
||||
createdAt: string
|
||||
lastTriggered: string | null
|
||||
status: "active" | "inactive"
|
||||
}
|
||||
|
||||
export default function ApiManagementPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const channel = params.channel
|
||||
const channelName = getChannelName(channel)
|
||||
|
||||
// 模拟API密钥数据
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: `${channelName}获客API密钥`,
|
||||
key: `api_${channel}_${Math.random().toString(36).substring(2, 10)}`,
|
||||
createdAt: "2024-03-20 14:30:00",
|
||||
lastUsed: "2024-03-21 09:15:22",
|
||||
status: "active",
|
||||
},
|
||||
])
|
||||
|
||||
// 模拟Webhook数据
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: `${channelName}获客回调`,
|
||||
url: `https://api.example.com/webhooks/${channel}`,
|
||||
events: ["customer.created", "customer.updated", "tag.added"],
|
||||
createdAt: "2024-03-20 14:35:00",
|
||||
lastTriggered: "2024-03-21 09:16:45",
|
||||
status: "active",
|
||||
},
|
||||
])
|
||||
|
||||
// 对话框状态
|
||||
const [showNewApiKeyDialog, setShowNewApiKeyDialog] = useState(false)
|
||||
const [showNewWebhookDialog, setShowNewWebhookDialog] = useState(false)
|
||||
const [newApiKeyName, setNewApiKeyName] = useState("")
|
||||
const [newWebhookData, setNewWebhookData] = useState({
|
||||
name: "",
|
||||
url: "",
|
||||
events: ["customer.created", "customer.updated", "tag.added"],
|
||||
})
|
||||
|
||||
// 创建新API密钥
|
||||
const handleCreateApiKey = () => {
|
||||
if (!newApiKeyName.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入API密钥名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newKey: ApiKey = {
|
||||
id: `${Date.now()}`,
|
||||
name: newApiKeyName,
|
||||
key: `api_${channel}_${Math.random().toString(36).substring(2, 15)}`,
|
||||
createdAt: new Date().toLocaleString(),
|
||||
lastUsed: null,
|
||||
status: "active",
|
||||
}
|
||||
|
||||
setApiKeys([...apiKeys, newKey])
|
||||
setNewApiKeyName("")
|
||||
setShowNewApiKeyDialog(false)
|
||||
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "新的API密钥已创建",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 创建新Webhook
|
||||
const handleCreateWebhook = () => {
|
||||
if (!newWebhookData.name.trim() || !newWebhookData.url.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请填写所有必填字段",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newWebhook: Webhook = {
|
||||
id: `${Date.now()}`,
|
||||
name: newWebhookData.name,
|
||||
url: newWebhookData.url,
|
||||
events: newWebhookData.events,
|
||||
createdAt: new Date().toLocaleString(),
|
||||
lastTriggered: null,
|
||||
status: "active",
|
||||
}
|
||||
|
||||
setWebhooks([...webhooks, newWebhook])
|
||||
setNewWebhookData({
|
||||
name: "",
|
||||
url: "",
|
||||
events: ["customer.created", "customer.updated", "tag.added"],
|
||||
})
|
||||
setShowNewWebhookDialog(false)
|
||||
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "新的Webhook已创建",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 删除API密钥
|
||||
const handleDeleteApiKey = (id: string) => {
|
||||
setApiKeys(apiKeys.filter((key) => key.id !== id))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "API密钥已删除",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 删除Webhook
|
||||
const handleDeleteWebhook = (id: string) => {
|
||||
setWebhooks(webhooks.filter((webhook) => webhook.id !== id))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "Webhook已删除",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 切换API密钥状态
|
||||
const toggleApiKeyStatus = (id: string) => {
|
||||
setApiKeys(
|
||||
apiKeys.map((key) => (key.id === id ? { ...key, status: key.status === "active" ? "inactive" : "active" } : key)),
|
||||
)
|
||||
}
|
||||
|
||||
// 切换Webhook状态
|
||||
const toggleWebhookStatus = (id: string) => {
|
||||
setWebhooks(
|
||||
webhooks.map((webhook) =>
|
||||
webhook.id === id ? { ...webhook, status: webhook.status === "active" ? "inactive" : "active" } : webhook,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600 ml-2">{channelName}获客接口管理</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
<Tabs defaultValue="api-keys" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||
<TabsTrigger value="api-keys">API密钥</TabsTrigger>
|
||||
<TabsTrigger value="webhooks">Webhook</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="api-keys" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-medium">API密钥管理</h2>
|
||||
<Button onClick={() => setShowNewApiKeyDialog(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建API密钥
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{apiKeys.map((apiKey) => (
|
||||
<Card key={apiKey.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>{apiKey.name}</CardTitle>
|
||||
<CardDescription>创建于 {apiKey.createdAt}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={apiKey.status === "active"}
|
||||
onCheckedChange={() => toggleApiKeyStatus(apiKey.id)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{apiKey.status === "active" ? "启用" : "禁用"}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleDeleteApiKey(apiKey.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input value={apiKey.key} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey.key)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "API密钥已复制到剪贴板",
|
||||
variant: "success",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{apiKey.lastUsed && <p className="text-sm text-gray-500">上次使用: {apiKey.lastUsed}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{apiKeys.length === 0 && (
|
||||
<div className="text-center py-8 bg-white rounded-lg shadow-sm">
|
||||
<p className="text-gray-500">暂无API密钥</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="webhooks" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-medium">Webhook管理</h2>
|
||||
<Button onClick={() => setShowNewWebhookDialog(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建Webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{webhooks.map((webhook) => (
|
||||
<Card key={webhook.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>{webhook.name}</CardTitle>
|
||||
<CardDescription>创建于 {webhook.createdAt}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={webhook.status === "active"}
|
||||
onCheckedChange={() => toggleWebhookStatus(webhook.id)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{webhook.status === "active" ? "启用" : "禁用"}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleDeleteWebhook(webhook.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input value={webhook.url} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(webhook.url)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "Webhook URL已复制到剪贴板",
|
||||
variant: "success",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{webhook.events.map((event) => (
|
||||
<span key={event} className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs">
|
||||
{event}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{webhook.lastTriggered && (
|
||||
<p className="text-sm text-gray-500">上次触发: {webhook.lastTriggered}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{webhooks.length === 0 && (
|
||||
<div className="text-center py-8 bg-white rounded-lg shadow-sm">
|
||||
<p className="text-gray-500">暂无Webhook</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 创建API密钥对话框 */}
|
||||
<Dialog open={showNewApiKeyDialog} onOpenChange={setShowNewApiKeyDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新API密钥</DialogTitle>
|
||||
<DialogDescription>创建一个新的API密钥用于访问{channelName}获客接口</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-name">API密钥名称</Label>
|
||||
<Input
|
||||
id="api-key-name"
|
||||
placeholder="例如:获客系统集成"
|
||||
value={newApiKeyName}
|
||||
onChange={(e) => setNewApiKeyName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewApiKeyDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreateApiKey}>创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 创建Webhook对话框 */}
|
||||
<Dialog open={showNewWebhookDialog} onOpenChange={setShowNewWebhookDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新Webhook</DialogTitle>
|
||||
<DialogDescription>创建一个新的Webhook用于接收{channelName}获客事件通知</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-name">Webhook名称</Label>
|
||||
<Input
|
||||
id="webhook-name"
|
||||
placeholder="例如:CRM系统集成"
|
||||
value={newWebhookData.name}
|
||||
onChange={(e) => setNewWebhookData({ ...newWebhookData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-url">Webhook URL</Label>
|
||||
<Input
|
||||
id="webhook-url"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={newWebhookData.url}
|
||||
onChange={(e) => setNewWebhookData({ ...newWebhookData, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>事件类型</Label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{["customer.created", "customer.updated", "tag.added", "tag.removed"].map((event) => (
|
||||
<div key={event} className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={newWebhookData.events.includes(event)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setNewWebhookData({
|
||||
...newWebhookData,
|
||||
events: [...newWebhookData.events, event],
|
||||
})
|
||||
} else {
|
||||
setNewWebhookData({
|
||||
...newWebhookData,
|
||||
events: newWebhookData.events.filter((e) => e !== event),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{event}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewWebhookDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreateWebhook}>创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
4
Cunkebao/app/scenarios/[channel]/devices/loading.tsx
Normal file
4
Cunkebao/app/scenarios/[channel]/devices/loading.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
266
Cunkebao/app/scenarios/[channel]/devices/page.tsx
Normal file
266
Cunkebao/app/scenarios/[channel]/devices/page.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ChevronLeft, Filter, Search, RefreshCw } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { Device } from "@/types/device"
|
||||
|
||||
export default function ScenarioDevicesPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
const devicesPerPage = 10
|
||||
const maxDevices = 5
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟API调用
|
||||
const fetchDevices = async () => {
|
||||
const mockDevices = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `device-${i + 1}`,
|
||||
imei: `sd${123123 + i}`,
|
||||
name: `设备 ${i + 1}`,
|
||||
remark: `${channelName}获客设备 ${i + 1}`,
|
||||
status: Math.random() > 0.2 ? "online" : "offline",
|
||||
battery: Math.floor(Math.random() * 100),
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
friendCount: Math.floor(Math.random() * 1000),
|
||||
todayAdded: Math.floor(Math.random() * 50),
|
||||
messageCount: Math.floor(Math.random() * 200),
|
||||
lastActive: new Date(Date.now() - Math.random() * 86400000).toLocaleString(),
|
||||
addFriendStatus: Math.random() > 0.2 ? "normal" : "abnormal",
|
||||
}))
|
||||
setDevices(mockDevices)
|
||||
}
|
||||
|
||||
fetchDevices()
|
||||
}, [channelName])
|
||||
|
||||
const handleRefresh = () => {
|
||||
toast({
|
||||
title: "刷新成功",
|
||||
description: "设备列表已更新",
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDevices.length === devices.length || selectedDevices.length === maxDevices) {
|
||||
setSelectedDevices([])
|
||||
} else {
|
||||
const newSelection = devices.slice(0, maxDevices).map((d) => d.id)
|
||||
setSelectedDevices(newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
} else {
|
||||
if (selectedDevices.length >= maxDevices) {
|
||||
toast({
|
||||
title: "选择超出限制",
|
||||
description: `最多可选择${maxDevices}个设备`,
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
setSelectedDevices([...selectedDevices, deviceId])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用来保存选中的设备
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "已更新计划设备",
|
||||
})
|
||||
router.back()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "更新设备失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesSearch =
|
||||
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.wechatId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesStatus = statusFilter === "all" || device.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}设备</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selectedDevices.length}/{maxDevices} 个设备
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedDevices.length > 0 &&
|
||||
(selectedDevices.length === devices.length || selectedDevices.length === maxDevices)
|
||||
}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm">全选</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paginatedDevices.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">暂无设备</div>
|
||||
) : (
|
||||
paginatedDevices.map((device) => (
|
||||
<Card
|
||||
key={device.id}
|
||||
className={`p-3 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selectedDevices.includes(device.id) ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
onClick={() => handleDeviceSelect(device.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
className="mt-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeviceSelect(device.id)
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium truncate">{device.name}</div>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
|
||||
<div className="text-sm text-gray-500">微信号: {device.wechatId}</div>
|
||||
<div className="flex items-center justify-between mt-1 text-sm">
|
||||
<span className="text-gray-500">好友数: {device.friendCount}</span>
|
||||
<span className="text-gray-500">今日新增: +{device.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredDevices.length > devicesPerPage && (
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {currentPage} / {Math.ceil(filteredDevices.length / devicesPerPage)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
|
||||
}
|
||||
disabled={currentPage === Math.ceil(filteredDevices.length / devicesPerPage)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white p-4 border-t flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={selectedDevices.length === 0}>
|
||||
保存 ({selectedDevices.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
241
Cunkebao/app/scenarios/[channel]/edit/[id]/page.tsx
Normal file
241
Cunkebao/app/scenarios/[channel]/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BasicSettings } from "../../../new/steps/BasicSettings"
|
||||
import { FriendRequestSettings } from "../../../new/steps/FriendRequestSettings"
|
||||
import { MessageSettings } from "../../../new/steps/MessageSettings"
|
||||
import { TagSettings } from "../../../new/steps/TagSettings"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
{ id: 4, title: "步骤四", subtitle: "流量标签设置" },
|
||||
]
|
||||
|
||||
export default function EditAcquisitionPlan({ params }: { params: { channel: string; id: string } }) {
|
||||
const router = useRouter()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [formData, setFormData] = useState({
|
||||
planName: "",
|
||||
accounts: [],
|
||||
dailyLimit: 10,
|
||||
enabled: true,
|
||||
remarkType: "phone",
|
||||
remarkKeyword: "",
|
||||
greeting: "",
|
||||
addFriendTimeStart: "09:00",
|
||||
addFriendTimeEnd: "18:00",
|
||||
addFriendInterval: 1,
|
||||
maxDailyFriends: 20,
|
||||
messageInterval: 1,
|
||||
messageContent: "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟从API获取计划数据
|
||||
const fetchPlanData = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
const mockData = {
|
||||
planName: "测试计划",
|
||||
accounts: ["account1"],
|
||||
dailyLimit: 15,
|
||||
enabled: true,
|
||||
remarkType: "phone",
|
||||
remarkKeyword: "测试",
|
||||
greeting: "你好",
|
||||
addFriendTimeStart: "09:00",
|
||||
addFriendTimeEnd: "18:00",
|
||||
addFriendInterval: 2,
|
||||
maxDailyFriends: 25,
|
||||
messageInterval: 2,
|
||||
messageContent: "欢迎",
|
||||
}
|
||||
setFormData(mockData)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "加载失败",
|
||||
description: "获取计划数据失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPlanData()
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "获客计划已更新",
|
||||
})
|
||||
router.push(`/scenarios/${params.channel}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "更新计划失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prevStep) => Math.max(prevStep - 1, 1))
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (isStepValid()) {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave()
|
||||
} else {
|
||||
setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isStepValid = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
if (!formData.planName.trim() || formData.accounts.length === 0) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写计划名称并选择至少一个账号",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 2:
|
||||
if (!formData.greeting.trim()) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写好友申请信息",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 3:
|
||||
if (!formData.messageContent.trim()) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写消息内容",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 4:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} isEdit />
|
||||
case 2:
|
||||
return (
|
||||
<FriendRequestSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
|
||||
)
|
||||
case 3:
|
||||
return <MessageSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
|
||||
case 4:
|
||||
return <TagSettings formData={formData} onChange={setFormData} onNext={handleSave} onPrev={handlePrev} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">编辑获客计划</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="px-4 py-6">
|
||||
<div className="relative flex justify-between">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
"flex flex-col items-center relative z-10",
|
||||
currentStep >= step.id ? "text-blue-600" : "text-gray-400",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 transition-colors",
|
||||
currentStep >= step.id
|
||||
? "border-blue-600 bg-blue-600 text-white"
|
||||
: "border-gray-300 bg-white text-gray-400",
|
||||
)}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
<div className="text-xs mt-1">{step.title}</div>
|
||||
<div className="text-xs mt-0.5 font-medium">{step.subtitle}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="absolute top-4 left-0 right-0 h-0.5 bg-gray-200 -z-10">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
|
||||
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-white border-t p-4">
|
||||
<div className="flex justify-between max-w-[390px] mx-auto">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
<Button className={cn("min-w-[120px]", currentStep === 1 ? "w-full" : "ml-auto")} onClick={handleNext}>
|
||||
{currentStep === steps.length ? "保存" : "下一步"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
321
Cunkebao/app/scenarios/[channel]/page.tsx
Normal file
321
Cunkebao/app/scenarios/[channel]/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Copy, Link, HelpCircle } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { ScenarioAcquisitionCard } from "@/app/components/acquisition/ScenarioAcquisitionCard"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
haibao: "海报",
|
||||
phone: "电话",
|
||||
gongzhonghao: "公众号",
|
||||
weixinqun: "微信群",
|
||||
payment: "付款码",
|
||||
api: "API",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
status: "running" | "paused" | "completed"
|
||||
stats: {
|
||||
devices: number
|
||||
acquired: number
|
||||
added: number
|
||||
}
|
||||
lastUpdated: string
|
||||
executionTime: string
|
||||
nextExecutionTime: string
|
||||
trend: { date: string; customers: number }[]
|
||||
}
|
||||
|
||||
interface DeviceStats {
|
||||
active: number
|
||||
}
|
||||
|
||||
// API文档提示组件
|
||||
function ApiDocumentationTooltip() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-gray-400 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
计划接口允许您通过API将外部系统的客户数据直接导入到存客宝。支持多种编程语言和第三方平台集成。
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChannelPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const channel = params.channel
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
const initialTasks = [
|
||||
{
|
||||
id: "1",
|
||||
name: `${channelName}直播获客计划`,
|
||||
status: "running",
|
||||
stats: {
|
||||
devices: 5,
|
||||
acquired: 31,
|
||||
added: 25,
|
||||
},
|
||||
lastUpdated: "2024-02-09 15:30",
|
||||
executionTime: "2024-02-09 17:24:10",
|
||||
nextExecutionTime: "2024-02-09 17:25:36",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2月${String(i + 1)}日`,
|
||||
customers: Math.floor(Math.random() * 30) + 30,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: `${channelName}评论区获客计划`,
|
||||
status: "paused",
|
||||
stats: {
|
||||
devices: 3,
|
||||
acquired: 15,
|
||||
added: 12,
|
||||
},
|
||||
lastUpdated: "2024-02-09 14:00",
|
||||
executionTime: "2024-02-09 16:30:00",
|
||||
nextExecutionTime: "2024-02-09 16:45:00",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2月${String(i + 1)}日`,
|
||||
customers: Math.floor(Math.random() * 20) + 20,
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>(initialTasks)
|
||||
|
||||
const [deviceStats, setDeviceStats] = useState<DeviceStats>({
|
||||
active: 5,
|
||||
})
|
||||
|
||||
const [showApiDialog, setShowApiDialog] = useState(false)
|
||||
const [currentApiSettings, setCurrentApiSettings] = useState({
|
||||
apiKey: "",
|
||||
webhookUrl: "",
|
||||
taskId: "",
|
||||
})
|
||||
|
||||
const handleEditPlan = (taskId: string) => {
|
||||
router.push(`/scenarios/${channel}/edit/${taskId}`)
|
||||
}
|
||||
|
||||
const handleCopyPlan = (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId)
|
||||
if (taskToCopy) {
|
||||
const newTask = {
|
||||
...taskToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${taskToCopy.name} (副本)`,
|
||||
status: "paused" as const,
|
||||
}
|
||||
setTasks([...tasks, newTask])
|
||||
toast({
|
||||
title: "计划已复制",
|
||||
description: `已成功复制"${taskToCopy.name}"`,
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlan = (taskId: string) => {
|
||||
const taskToDelete = tasks.find((t) => t.id === taskId)
|
||||
if (taskToDelete) {
|
||||
setTasks(tasks.filter((t) => t.id !== taskId))
|
||||
toast({
|
||||
title: "计划已删除",
|
||||
description: `已成功删除"${taskToDelete.name}"`,
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = (taskId: string, newStatus: "running" | "paused") => {
|
||||
setTasks(tasks.map((task) => (task.id === taskId ? { ...task, status: newStatus } : task)))
|
||||
|
||||
toast({
|
||||
title: newStatus === "running" ? "计划已启动" : "计划已暂停",
|
||||
description: `已${newStatus === "running" ? "启动" : "暂停"}获客计划`,
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenApiSettings = (taskId: string) => {
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
if (task) {
|
||||
setCurrentApiSettings({
|
||||
apiKey: `api_${taskId}_${Math.random().toString(36).substring(2, 10)}`,
|
||||
webhookUrl: `${window.location.origin}/api/scenarios/${channel}/${taskId}/webhook`,
|
||||
taskId,
|
||||
})
|
||||
setShowApiDialog(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyApiUrl = (url: string, withParams = false) => {
|
||||
let copyUrl = url
|
||||
if (withParams) {
|
||||
copyUrl = `${url}?name=张三&phone=13800138000&source=外部系统&remark=测试数据`
|
||||
}
|
||||
navigator.clipboard.writeText(copyUrl)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: withParams ? "接口地址(含示例参数)已复制到剪贴板" : "接口地址已复制到剪贴板",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}获客</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task) => (
|
||||
<div key={task.id} className="mb-6">
|
||||
<ScenarioAcquisitionCard
|
||||
task={task}
|
||||
channel={channel}
|
||||
onEdit={() => handleEditPlan(task.id)}
|
||||
onCopy={handleCopyPlan}
|
||||
onDelete={handleDeletePlan}
|
||||
onStatusChange={handleStatusChange}
|
||||
onOpenSettings={handleOpenApiSettings}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<div className="text-gray-400 mb-4">暂无获客计划</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* API接口设置对话框 */}
|
||||
<Dialog open={showApiDialog} onOpenChange={setShowApiDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle>计划接口</DialogTitle>
|
||||
<ApiDocumentationTooltip />
|
||||
</div>
|
||||
<DialogDescription>使用此接口直接导入客资到该获客计划,支持多种编程语言。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">API密钥</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input id="api-key" value={currentApiSettings.apiKey} readOnly className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(currentApiSettings.apiKey)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "API密钥已复制到剪贴板",
|
||||
variant: "success",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="webhook-url">接口地址</Label>
|
||||
<button
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl, true)}
|
||||
>
|
||||
复制(含示例参数)
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input id="webhook-url" value={currentApiSettings.webhookUrl} readOnly className="flex-1" />
|
||||
<Button variant="outline" size="icon" onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">支持GET/POST请求,必要参数:name(姓名)、phone(电话)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>接口文档</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={() => {
|
||||
window.open(`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}`, "_blank")
|
||||
}}
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
查看详细接口文档与集成指南
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
<a
|
||||
href={`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}#examples`}
|
||||
target="_blank"
|
||||
className="text-blue-600 hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看Python、Java等多语言示例代码
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowApiDialog(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
4
Cunkebao/app/scenarios/[channel]/traffic/loading.tsx
Normal file
4
Cunkebao/app/scenarios/[channel]/traffic/loading.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
236
Cunkebao/app/scenarios/[channel]/traffic/page.tsx
Normal file
236
Cunkebao/app/scenarios/[channel]/traffic/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ChevronLeft, Search, Filter, RefreshCw, Plus } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
|
||||
interface TrafficUser {
|
||||
id: string
|
||||
avatar: string
|
||||
nickname: string
|
||||
wechatId: string
|
||||
phone: string
|
||||
region: string
|
||||
note: string
|
||||
status: "pending" | "added" | "failed"
|
||||
addTime: string
|
||||
source: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export default function ChannelTrafficPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { channel: string }
|
||||
searchParams: { type?: string }
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [users, setUsers] = useState<TrafficUser[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.type || "all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
// 模拟API调用
|
||||
const mockUsers = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `user-${i + 1}`,
|
||||
avatar: "/placeholder.svg?height=40&width=40",
|
||||
nickname: `用户${i + 1}`,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () =>
|
||||
Math.floor(Math.random() * 10),
|
||||
).join("")}`,
|
||||
region: ["广东", "浙江", "江苏", "北京", "上海"][Math.floor(Math.random() * 5)],
|
||||
note: ["感兴趣", "需要了解", "想购买", "咨询价格"][Math.floor(Math.random() * 4)],
|
||||
status: searchParams.type === "added" ? "added" : ["pending", "added", "failed"][Math.floor(Math.random() * 3)],
|
||||
addTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
|
||||
source: params.channel,
|
||||
tags: ["意向客户", "高活跃度", "新用户"][Math.floor(Math.random() * 3)].split(" "),
|
||||
}))
|
||||
setUsers(mockUsers)
|
||||
}
|
||||
|
||||
fetchUsers()
|
||||
}, [params.channel, searchParams.type])
|
||||
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const matchesSearch =
|
||||
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.phone.includes(searchQuery)
|
||||
const matchesStatus = statusFilter === "all" || user.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const paginatedUsers = filteredUsers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}流量池</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => router.push(`/scenarios/${params.channel}/new`)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索用户"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待处理</SelectItem>
|
||||
<SelectItem value="added">已添加</SelectItem>
|
||||
<SelectItem value="failed">已失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{paginatedUsers.map((user) => (
|
||||
<Card key={user.id} className="p-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={user.avatar || "/placeholder.svg"}
|
||||
alt={user.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium truncate">{user.nickname}</div>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "added" ? "success" : user.status === "failed" ? "destructive" : "secondary"
|
||||
}
|
||||
>
|
||||
{user.status === "added" ? "已添加" : user.status === "failed" ? "已失败" : "待处理"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
<div>微信号: {user.wechatId}</div>
|
||||
<div>手机号: {user.phone}</div>
|
||||
<div>地区: {user.region}</div>
|
||||
<div>添加时间: {user.addTime}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{user.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={currentPage === page}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage(page)
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
374
Cunkebao/app/scenarios/api/page.tsx
Normal file
374
Cunkebao/app/scenarios/api/page.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus, Filter, Search, RefreshCw, MoreVertical, Clock, Copy, Code } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import Link from "next/link"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { useRouter } from "next/navigation"
|
||||
import type { Device } from "@/components/device-grid"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge" // Import Badge component
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
status: "running" | "paused" | "completed"
|
||||
stats: {
|
||||
devices: number
|
||||
customersPerDevice: number
|
||||
totalCalls: number
|
||||
successCalls: number
|
||||
errorCalls: number
|
||||
successRate: number
|
||||
}
|
||||
lastUpdated: string
|
||||
executionTime: string
|
||||
nextExecutionTime: string
|
||||
trend: { date: string; calls: number; success: number }[]
|
||||
deviceList: Device[]
|
||||
apiInfo: {
|
||||
endpoint: string
|
||||
method: string
|
||||
token: string
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机数据的辅助函数
|
||||
function generateRandomStats() {
|
||||
const devices = Math.floor(Math.random() * 16) + 5
|
||||
const customersPerDevice = Math.floor(Math.random() * 11) + 10
|
||||
const totalCalls = Math.floor(Math.random() * 1000) + 500
|
||||
const successCalls = Math.floor(totalCalls * (Math.random() * 0.3 + 0.6))
|
||||
const errorCalls = totalCalls - successCalls
|
||||
|
||||
return {
|
||||
devices,
|
||||
customersPerDevice,
|
||||
totalCalls,
|
||||
successCalls,
|
||||
errorCalls,
|
||||
successRate: Math.round((successCalls / totalCalls) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
export default function ApiPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "APP加友接口",
|
||||
status: "running",
|
||||
stats: generateRandomStats(),
|
||||
lastUpdated: "2024-02-09 15:30",
|
||||
executionTime: "2024-02-09 17:24:10",
|
||||
nextExecutionTime: "2024-02-09 17:25:36",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2024-02-${String(i + 1).padStart(2, "0")}`,
|
||||
calls: Math.floor(Math.random() * 100) + 50,
|
||||
success: Math.floor(Math.random() * 80) + 40,
|
||||
})),
|
||||
deviceList: [],
|
||||
apiInfo: {
|
||||
endpoint: "https://api.ckb.quwanzhi.com/api/open/task/addFriend",
|
||||
method: "POST",
|
||||
token: "ckb_token_xxxxx",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
|
||||
const [isApiDocsOpen, setIsApiDocsOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const toggleTaskStatus = (taskId: string) => {
|
||||
setTasks(
|
||||
tasks.map((task) => {
|
||||
if (task.id === taskId) {
|
||||
return {
|
||||
...task,
|
||||
status: task.status === "running" ? "paused" : "running",
|
||||
}
|
||||
}
|
||||
return task
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const copyTask = (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId)
|
||||
if (taskToCopy) {
|
||||
const newTask = {
|
||||
...taskToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${taskToCopy.name} (副本)`,
|
||||
stats: generateRandomStats(),
|
||||
status: "paused" as const,
|
||||
lastUpdated: new Date().toLocaleString(),
|
||||
}
|
||||
setTasks([...tasks, newTask])
|
||||
toast({
|
||||
title: "复制成功",
|
||||
description: "已创建计划副本",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskClick = (taskId: string) => {
|
||||
router.push(`/scenarios/api/${taskId}/edit`)
|
||||
}
|
||||
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-violet-50 to-white">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h1 className="text-xl font-semibold text-violet-600">API获客</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="/scenarios/api/new">
|
||||
<Button className="bg-violet-600 hover:bg-violet-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建计划
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
|
||||
<Input className="pl-9" placeholder="搜索计划名称" />
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => (
|
||||
<Card
|
||||
key={task.id}
|
||||
className="p-6 hover:shadow-lg transition-all cursor-pointer"
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium text-lg">{task.name}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
task.status === "running"
|
||||
? "bg-green-50 text-green-600"
|
||||
: task.status === "paused"
|
||||
? "bg-yellow-50 text-yellow-600"
|
||||
: "bg-gray-50 text-gray-600"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleTaskStatus(task.id)
|
||||
}}
|
||||
>
|
||||
{task.status === "running" ? "进行中" : task.status === "paused" ? "已暂停" : "已完成"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsApiDocsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
查看文档
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => router.push(`/scenarios/api/${task.id}/edit`)}>
|
||||
编辑计划
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => copyTask(task.id)}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制计划
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>查看详情</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-600">删除计划</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-violet-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500">总调用次数</div>
|
||||
<div className="text-lg font-semibold text-violet-600">{task.stats.totalCalls}</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-violet-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500">成功调用</div>
|
||||
<div className="text-lg font-semibold text-violet-600">{task.stats.successCalls}</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-violet-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500">失败调用</div>
|
||||
<div className="text-lg font-semibold text-violet-600">{task.stats.errorCalls}</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-violet-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500">成功率</div>
|
||||
<div className="text-lg font-semibold text-violet-600">{task.stats.successRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="h-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={task.trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="calls" name="调用次数" stroke="#8b5cf6" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="success" name="成功次数" stroke="#22c55e" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm border-t pt-4">
|
||||
<div className="flex items-center space-x-2 text-gray-500">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>上次执行: {task.executionTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-gray-500">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>下次执行: {task.nextExecutionTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isApiDocsOpen} onOpenChange={setIsApiDocsOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>API 文档</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="overview" className="mt-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">概述</TabsTrigger>
|
||||
<TabsTrigger value="endpoints">接口列表</TabsTrigger>
|
||||
<TabsTrigger value="examples">示例代码</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="p-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">概述</h3>
|
||||
<p className="text-sm text-gray-600">本接口文档依照REST标准,请求头需要添加token作为鉴权。</p>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium">基础域名</p>
|
||||
<code className="text-sm text-violet-600">https://api.ckb.quwanzhi.com</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="endpoints" className="p-4">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">手机微信号加友接口</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline">POST</Badge> {/* Badge component used here */}
|
||||
<code className="text-sm">/api/open/task/addFriend</code>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium mb-2">请求参数</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>是否必填</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>phone</td>
|
||||
<td>string</td>
|
||||
<td>是</td>
|
||||
<td>手机或微信号</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>tags</td>
|
||||
<td>string</td>
|
||||
<td>否</td>
|
||||
<td>标签</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>taskId</td>
|
||||
<td>int</td>
|
||||
<td>是</td>
|
||||
<td>计划任务ID</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="examples" className="p-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">请求示例</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
|
||||
<pre className="text-sm">
|
||||
{JSON.stringify(
|
||||
{
|
||||
phone: "18956545898",
|
||||
taskId: 593,
|
||||
tags: "90后,女生,美女",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">返回示例</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
|
||||
<pre className="text-sm">
|
||||
{JSON.stringify(
|
||||
{
|
||||
code: 10000,
|
||||
data: null,
|
||||
message: "操作成功",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
123
Cunkebao/app/scenarios/douyin/page.tsx
Normal file
123
Cunkebao/app/scenarios/douyin/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { ExpandableAcquisitionCard } from "@/components/acquisition/ExpandableAcquisitionCard"
|
||||
import Link from "next/link"
|
||||
import { DeviceTreeChart } from "@/app/components/acquisition/DeviceTreeChart"
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
status: "running" | "paused" | "completed"
|
||||
stats: {
|
||||
devices: number
|
||||
acquired: number
|
||||
added: number
|
||||
}
|
||||
lastUpdated: string
|
||||
executionTime: string
|
||||
nextExecutionTime: string
|
||||
trend: { date: string; customers: number }[]
|
||||
dailyData: { date: string; acquired: number; added: number }[]
|
||||
}
|
||||
|
||||
export default function DouyinAcquisitionPage() {
|
||||
const router = useRouter()
|
||||
const channel = "douyin"
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "抖音直播获客计划",
|
||||
status: "running",
|
||||
stats: {
|
||||
devices: 3,
|
||||
acquired: 45,
|
||||
added: 32,
|
||||
},
|
||||
lastUpdated: "2024-03-18 15:30",
|
||||
executionTime: "2024-03-18 15:30",
|
||||
nextExecutionTime: "预计30分钟后",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `3月${String(i + 12)}日`,
|
||||
customers: Math.floor(Math.random() * 10) + 5,
|
||||
})),
|
||||
dailyData: [
|
||||
{ date: "3/12", acquired: 12, added: 8 },
|
||||
{ date: "3/13", acquired: 15, added: 10 },
|
||||
{ date: "3/14", acquired: 8, added: 6 },
|
||||
{ date: "3/15", acquired: 10, added: 7 },
|
||||
{ date: "3/16", acquired: 14, added: 11 },
|
||||
{ date: "3/17", acquired: 9, added: 7 },
|
||||
{ date: "3/18", acquired: 11, added: 9 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const handleCopyPlan = (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId)
|
||||
if (taskToCopy) {
|
||||
const newTask = {
|
||||
...taskToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${taskToCopy.name} (副本)`,
|
||||
status: "paused" as const,
|
||||
}
|
||||
setTasks([...tasks, newTask])
|
||||
toast({
|
||||
title: "计划已复制",
|
||||
description: `已成功复制"${taskToCopy.name}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlan = (taskId: string) => {
|
||||
const taskToDelete = tasks.find((t) => t.id === taskId)
|
||||
if (taskToDelete) {
|
||||
setTasks(tasks.filter((t) => t.id !== taskId))
|
||||
toast({
|
||||
title: "计划已删除",
|
||||
description: `已成功删除"${taskToDelete.name}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">抖音获客</h1>
|
||||
</div>
|
||||
<Link href="/scenarios/douyin/new" className="ml-auto">
|
||||
<Button className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
{tasks.map((task) => (
|
||||
<ExpandableAcquisitionCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
channel={channel}
|
||||
onCopy={handleCopyPlan}
|
||||
onDelete={handleDeletePlan}
|
||||
/>
|
||||
))}
|
||||
<DeviceTreeChart />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
2
Cunkebao/app/scenarios/new/steps/DeviceSelection
Normal file
2
Cunkebao/app/scenarios/new/steps/DeviceSelection
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
479
Cunkebao/app/scenarios/new/steps/PosterEditor.tsx
Normal file
479
Cunkebao/app/scenarios/new/steps/PosterEditor.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Card, CardFooter } from "@/components/ui/card"
|
||||
import {
|
||||
Upload,
|
||||
PenTool,
|
||||
Type,
|
||||
QrCode,
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
Smartphone,
|
||||
Monitor,
|
||||
Tablet,
|
||||
Save,
|
||||
Share2,
|
||||
Eye,
|
||||
} from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DEFAULT_TEMPLATES = [
|
||||
{
|
||||
id: "register",
|
||||
name: "点击报名",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E9%8E%B6%E3%83%A5%E6%82%95-vJDCYhJ9ENr8jN3YGP9jVeQ5Ub3czl.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "claim",
|
||||
name: "点击领取",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E6%A3%B0%E5%97%97%E5%BD%871-cskUmYR6oO0n4uHdZVeB4naKUSUilb.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "consult",
|
||||
name: "点击咨询",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E9%8D%9C%E3%84%A8%EE%87%97-OUtJxwRbr4ydYRjt8FLOCMELC16Vw6.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "checkin",
|
||||
name: "点击签到",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E7%BB%9B%E6%83%A7%E5%9F%8C-bYcTocSdNrcykfBXmt51q6D4Yzh26h.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "cooperation",
|
||||
name: "点击合作",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E9%8D%9A%E5%A0%9C%E7%B6%94-kisPT3kV9A0aB7YpxO6AHUZ8aHvFLT.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "learn",
|
||||
name: "点击了解",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E6%B5%9C%E5%97%9A%D0%92-iE654sFFuO1PuvwmccV67yVLQZoLcx.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "claim_static",
|
||||
name: "点击领取(静态)",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E6%A3%B0%E5%97%97%E5%BD%87-jO6FPRCCzz6Irkm5suKeNkUDd98Y0f.png",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
]
|
||||
|
||||
export function PosterEditor({
|
||||
onChange,
|
||||
initialValue = null,
|
||||
}: {
|
||||
onChange: (value: any) => void
|
||||
initialValue?: any
|
||||
}) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(initialValue?.template || DEFAULT_TEMPLATES[0])
|
||||
const [customText, setCustomText] = useState(initialValue?.customText || selectedTemplate.name)
|
||||
const [mainColor, setMainColor] = useState(initialValue?.mainColor || selectedTemplate.color)
|
||||
const [textColor, setTextColor] = useState(initialValue?.textColor || selectedTemplate.textColor)
|
||||
const [hasQrCode, setHasQrCode] = useState(initialValue?.hasQrCode || false)
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState(initialValue?.qrCodeUrl || "")
|
||||
const [offerText, setOfferText] = useState(initialValue?.offerText || "")
|
||||
const [previewDevice, setPreviewDevice] = useState("mobile")
|
||||
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// 当任何相关状态变化时更新父组件
|
||||
useEffect(() => {
|
||||
onChange({
|
||||
template: selectedTemplate,
|
||||
customText,
|
||||
mainColor,
|
||||
textColor,
|
||||
hasQrCode,
|
||||
qrCodeUrl,
|
||||
offerText,
|
||||
})
|
||||
}, [selectedTemplate, customText, mainColor, textColor, hasQrCode, qrCodeUrl, offerText, onChange])
|
||||
|
||||
// 预览海报的渲染
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 获取原始海报图像
|
||||
const img = new Image()
|
||||
img.crossOrigin = "anonymous"
|
||||
img.src = selectedTemplate.src
|
||||
|
||||
img.onload = () => {
|
||||
// 绘制原始海报
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 自定义文本
|
||||
if (customText !== selectedTemplate.name) {
|
||||
// 首先绘制一个半透明背景,盖住原文字
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.85)"
|
||||
ctx.fillRect(20, 30, canvas.width - 40, 150)
|
||||
|
||||
// 绘制自定义文字
|
||||
ctx.fillStyle = mainColor
|
||||
ctx.font = "bold 42px sans-serif"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText(customText, canvas.width / 2, 100)
|
||||
}
|
||||
|
||||
// 如果添加了二维码
|
||||
if (hasQrCode) {
|
||||
// 添加一个二维码占位背景
|
||||
ctx.fillStyle = "#ffffff"
|
||||
ctx.fillRect(canvas.width - 120, canvas.height - 120, 100, 100)
|
||||
ctx.strokeStyle = "#dddddd"
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(canvas.width - 120, canvas.height - 120, 100, 100)
|
||||
|
||||
// 添加二维码图标占位
|
||||
ctx.fillStyle = "#888888"
|
||||
ctx.fillRect(canvas.width - 100, canvas.height - 100, 60, 60)
|
||||
|
||||
// 添加"扫码获取"文本
|
||||
ctx.fillStyle = "#333333"
|
||||
ctx.font = "14px sans-serif"
|
||||
ctx.textAlign = "center"
|
||||
ctx.fillText("扫码获取", canvas.width - 70, canvas.height - 20)
|
||||
}
|
||||
|
||||
// 如果添加了优惠文本
|
||||
if (offerText) {
|
||||
// 添加一个醒目的优惠信息标签
|
||||
ctx.fillStyle = "#ffeb3b"
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, canvas.height - 200)
|
||||
ctx.lineTo(200, canvas.height - 200)
|
||||
ctx.lineTo(170, canvas.height - 150)
|
||||
ctx.lineTo(0, canvas.height - 150)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// 添加优惠文本
|
||||
ctx.fillStyle = "#d32121"
|
||||
ctx.font = "bold 18px sans-serif"
|
||||
ctx.textAlign = "left"
|
||||
ctx.fillText(offerText, 15, canvas.height - 175)
|
||||
}
|
||||
}
|
||||
}, [selectedTemplate, customText, mainColor, hasQrCode, offerText])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2 className="text-lg font-medium">海报编辑器</h2>
|
||||
|
||||
<Tabs defaultValue="design" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-4">
|
||||
<TabsTrigger value="design" className="flex items-center gap-1">
|
||||
<PenTool className="h-4 w-4" />
|
||||
设计
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="content" className="flex items-center gap-1">
|
||||
<Type className="h-4 w-4" />
|
||||
内容
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="interactive" className="flex items-center gap-1">
|
||||
<QrCode className="h-4 w-4" />
|
||||
互动
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1">
|
||||
<Eye className="h-4 w-4" />
|
||||
预览
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="design" className="space-y-4 pt-4">
|
||||
<div>
|
||||
<Label>选择模板</Label>
|
||||
<div className="mt-2 flex justify-between">
|
||||
<div className="w-24 h-40 bg-gray-100 rounded relative overflow-hidden">
|
||||
{selectedTemplate && (
|
||||
<img
|
||||
src={selectedTemplate.src || "/placeholder.svg"}
|
||||
alt={selectedTemplate.name}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{selectedTemplate?.name || "请选择模板"}</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">点击右侧按钮选择其他模板</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="mt-2">
|
||||
更换模板
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择海报模板</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto p-2">
|
||||
{DEFAULT_TEMPLATES.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className={cn(
|
||||
"overflow-hidden cursor-pointer transition-all transform hover:scale-105",
|
||||
selectedTemplate.id === template.id && "ring-2 ring-blue-500",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedTemplate(template)
|
||||
setCustomText(template.name)
|
||||
setMainColor(template.color)
|
||||
setTextColor(template.textColor)
|
||||
setIsTemplateDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[9/16] bg-gray-100 relative">
|
||||
<img
|
||||
src={template.src || "/placeholder.svg"}
|
||||
alt={template.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<CardFooter className="p-2">
|
||||
<p className="text-sm text-center w-full">{template.name}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card className="overflow-hidden cursor-pointer transition-all transform hover:scale-105">
|
||||
<div className="aspect-[9/16] bg-gray-100 flex flex-col items-center justify-center text-gray-500">
|
||||
<Upload className="h-8 w-8 mb-2" />
|
||||
<p className="text-sm">上传自定义模板</p>
|
||||
</div>
|
||||
<CardFooter className="p-2">
|
||||
<p className="text-sm text-center w-full">自定义上传</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="mainColor">主色调</Label>
|
||||
<div className="flex mt-2">
|
||||
<div className="w-10 h-8 rounded-l border" style={{ backgroundColor: mainColor }}></div>
|
||||
<Input
|
||||
id="mainColor"
|
||||
type="text"
|
||||
value={mainColor}
|
||||
onChange={(e) => setMainColor(e.target.value)}
|
||||
className="rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="textColor">文字颜色</Label>
|
||||
<div className="flex mt-2">
|
||||
<div className="w-10 h-8 rounded-l border" style={{ backgroundColor: textColor }}></div>
|
||||
<Input
|
||||
id="textColor"
|
||||
type="text"
|
||||
value={textColor}
|
||||
onChange={(e) => setTextColor(e.target.value)}
|
||||
className="rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" size="sm">
|
||||
重置样式
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
保存为模板
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="content" className="space-y-4 pt-4">
|
||||
<div>
|
||||
<Label htmlFor="customText">海报文字</Label>
|
||||
<Input
|
||||
id="customText"
|
||||
value={customText}
|
||||
onChange={(e) => setCustomText(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="offerText">优惠信息(选填)</Label>
|
||||
<Input
|
||||
id="offerText"
|
||||
value={offerText}
|
||||
onChange={(e) => setOfferText(e.target.value)}
|
||||
className="mt-2"
|
||||
placeholder="例如: 限时8.5折 | 新人专享"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">添加醒目的优惠信息,提升用户点击欲望</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>推荐措辞</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{["限时特惠", "新人专享", "首单立减", "买一送一", "折扣优惠", "免费领取"].map((tag) => (
|
||||
<Button key={tag} variant="outline" size="sm" onClick={() => setOfferText(tag)}>
|
||||
{tag}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="interactive" className="space-y-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="qrcode-switch" className="cursor-pointer">
|
||||
添加二维码
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500 mt-1">添加二维码提高用户转化率</p>
|
||||
</div>
|
||||
<Switch id="qrcode-switch" checked={hasQrCode} onCheckedChange={setHasQrCode} />
|
||||
</div>
|
||||
|
||||
{hasQrCode && (
|
||||
<div>
|
||||
<Label htmlFor="qrcode-url">二维码链接(选填)</Label>
|
||||
<Input
|
||||
id="qrcode-url"
|
||||
value={qrCodeUrl}
|
||||
onChange={(e) => setQrCodeUrl(e.target.value)}
|
||||
className="mt-2"
|
||||
placeholder="输入链接或小程序路径"
|
||||
/>
|
||||
<div className="mt-3 flex justify-between items-center">
|
||||
<p className="text-sm">上传自定义二维码</p>
|
||||
<Button variant="outline" size="sm" className="flex items-center">
|
||||
<Upload className="h-3 w-3 mr-1" />
|
||||
上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="font-medium mb-2">点击行为</h4>
|
||||
<Select defaultValue="form">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择点击行为" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="form">填写表单</SelectItem>
|
||||
<SelectItem value="qrcode">扫码添加</SelectItem>
|
||||
<SelectItem value="call">拨打电话</SelectItem>
|
||||
<SelectItem value="link">访问链接</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500 mt-2">设置用户点击海报后的行为</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="space-y-4 pt-4">
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant={previewDevice === "mobile" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPreviewDevice("mobile")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Smartphone className="h-4 w-4 mr-1" />
|
||||
手机
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewDevice === "tablet" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPreviewDevice("tablet")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Tablet className="h-4 w-4 mr-1" />
|
||||
平板
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewDevice === "desktop" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPreviewDevice("desktop")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Monitor className="h-4 w-4 mr-1" />
|
||||
电脑
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-gray-100 border flex items-center justify-center p-2",
|
||||
previewDevice === "mobile" && "w-[320px] h-[568px]",
|
||||
previewDevice === "tablet" && "w-[480px] h-[640px]",
|
||||
previewDevice === "desktop" && "w-[640px] h-[480px] max-w-full",
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={300}
|
||||
height={534}
|
||||
className={cn("w-full h-full object-contain", previewDevice === "desktop" && "max-h-[90%]")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button variant="outline" size="sm" className="flex items-center">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
保存
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex items-center">
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
分享
|
||||
</Button>
|
||||
<Button className="flex items-center">
|
||||
确认使用
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
210
Cunkebao/app/scenarios/new/steps/TrafficChannelSettings
Normal file
210
Cunkebao/app/scenarios/new/steps/TrafficChannelSettings
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client"
|
||||
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Plus, Pencil, Trash2, Link2 } from "lucide-react"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
type: "team" | "other"
|
||||
link?: string
|
||||
}
|
||||
|
||||
interface TrafficChannelSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
}
|
||||
|
||||
function isValidUrl(string: string) {
|
||||
try {
|
||||
new URL(string)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function TrafficChannelSettings({ formData, onChange, onNext, onPrev }: TrafficChannelSettingsProps) {
|
||||
const [channels, setChannels] = useState<Channel[]>(formData.channels || [])
|
||||
const [isAddChannelOpen, setIsAddChannelOpen] = useState(false)
|
||||
const [editingChannel, setEditingChannel] = useState<Channel | null>(null)
|
||||
const [newChannel, setNewChannel] = useState<Partial<Channel>>({
|
||||
name: "",
|
||||
type: "team",
|
||||
link: "",
|
||||
})
|
||||
|
||||
const handleAddChannel = () => {
|
||||
if (!newChannel.name) return
|
||||
if (newChannel.link && !isValidUrl(newChannel.link)) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入有效的URL",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (editingChannel) {
|
||||
setChannels(
|
||||
channels.map((channel) => (channel.id === editingChannel.id ? { ...channel, ...newChannel } : channel)),
|
||||
)
|
||||
} else {
|
||||
setChannels([
|
||||
...channels,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
name: newChannel.name,
|
||||
type: newChannel.type || "team",
|
||||
link: newChannel.link,
|
||||
} as Channel,
|
||||
])
|
||||
}
|
||||
|
||||
setIsAddChannelOpen(false)
|
||||
setNewChannel({ name: "", type: "team", link: "" })
|
||||
setEditingChannel(null)
|
||||
onChange({ ...formData, channels })
|
||||
}
|
||||
|
||||
const handleEditChannel = (channel: Channel) => {
|
||||
setEditingChannel(channel)
|
||||
setNewChannel(channel)
|
||||
setIsAddChannelOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteChannel = (channelId: string) => {
|
||||
setChannels(channels.filter((channel) => channel.id !== channelId))
|
||||
onChange({ ...formData, channels: channels.filter((channel) => channel.id !== channelId) })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold">流量通道设置</h2>
|
||||
<Button onClick={() => setIsAddChannelOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加通道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>通道名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>链接</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-gray-500">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>{channel.name}</TableCell>
|
||||
<TableCell>{channel.type === "team" ? "打粉团队" : "其他"}</TableCell>
|
||||
<TableCell>
|
||||
{channel.link && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="text-blue-600">{channel.link}</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditChannel(channel)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteChannel(channel.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={onNext}>完成</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddChannelOpen} onOpenChange={setIsAddChannelOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingChannel ? "编辑通道" : "添加通道"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>通道名称</Label>
|
||||
<Input
|
||||
value={newChannel.name}
|
||||
onChange={(e) => setNewChannel({ ...newChannel, name: e.target.value })}
|
||||
placeholder="请输入通道名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>通道类型</Label>
|
||||
<Select
|
||||
value={newChannel.type}
|
||||
onValueChange={(value) => setNewChannel({ ...newChannel, type: value as "team" | "other" })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择通道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="team">打粉团队</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>通道链接(选填)</Label>
|
||||
<Input
|
||||
value={newChannel.link}
|
||||
onChange={(e) => setNewChannel({ ...newChannel, link: e.target.value })}
|
||||
placeholder="请输入通道链接"
|
||||
/>
|
||||
{newChannel.link && !isValidUrl(newChannel.link) && (
|
||||
<p className="text-sm text-red-500">请输入有效的URL</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddChannelOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddChannel}>{editingChannel ? "保存" : "添加"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
403
Cunkebao/app/scenarios/page.tsx
Normal file
403
Cunkebao/app/scenarios/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
stats: {
|
||||
daily: number
|
||||
growth: number
|
||||
}
|
||||
link?: string
|
||||
plans?: Plan[]
|
||||
}
|
||||
|
||||
interface Plan {
|
||||
id: string
|
||||
name: string
|
||||
isNew?: boolean
|
||||
status: "active" | "paused" | "completed"
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const aiScenarios = [
|
||||
{
|
||||
id: "ai-friend",
|
||||
name: "AI智能加友",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-azCH8EgGfidWXOqiM2D1jLH0VFRUtW.png",
|
||||
description: "智能分析目标用户画像,自动筛选优质客户",
|
||||
stats: {
|
||||
daily: 245,
|
||||
growth: 18.5,
|
||||
},
|
||||
plans: [
|
||||
{
|
||||
id: "ai-plan-1",
|
||||
name: "AI智能筛选计划",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 78,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ai-group",
|
||||
name: "AI群引流",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-azCH8EgGfidWXOqiM2D1jLH0VFRUtW.png",
|
||||
description: "智能群聊互动,提高群活跃度和转化率",
|
||||
stats: {
|
||||
daily: 178,
|
||||
growth: 15.2,
|
||||
},
|
||||
plans: [
|
||||
{
|
||||
id: "ai-plan-2",
|
||||
name: "AI群聊互动计划",
|
||||
status: "active",
|
||||
acquisitionCount: 56,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ai-conversion",
|
||||
name: "AI场景转化",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-m4ENUaZon82EPFHod2dP1dajlrRdVG.png",
|
||||
description: "多场景智能营销,提升获客转化效果",
|
||||
stats: {
|
||||
daily: 134,
|
||||
growth: 12.8,
|
||||
},
|
||||
plans: [
|
||||
{
|
||||
id: "ai-plan-3",
|
||||
name: "AI多场景营销",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 43,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function ScenariosPage() {
|
||||
const router = useRouter()
|
||||
const handleChannelClick = (channelId: string, event: React.MouseEvent) => {
|
||||
router.push(`/scenarios/${channelId}`)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-100 text-green-700"
|
||||
case "paused":
|
||||
return "bg-amber-100 text-amber-700"
|
||||
case "completed":
|
||||
return "bg-blue-100 text-blue-700"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700"
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "执行中"
|
||||
case "paused":
|
||||
return "已暂停"
|
||||
case "completed":
|
||||
return "已完成"
|
||||
default:
|
||||
return "未知状态"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex justify-between items-center p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => window.history.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">场景获客</h1>
|
||||
</div>
|
||||
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => router.push("/plans/new")}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Traditional channels */}
|
||||
<div className="grid grid-cols-2 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" />
|
||||
) : (
|
||||
<img
|
||||
src={channel.icon || "/placeholder.svg"}
|
||||
alt={channel.name}
|
||||
className="w-8 h-8 object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI scenarios */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Bot className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-lg font-medium">AI智能获客</h2>
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs rounded-full">Beta</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{aiScenarios.map((scenario) => (
|
||||
<div key={scenario.id} className="flex flex-col">
|
||||
<Card
|
||||
className={`p-4 hover:shadow-lg transition-all bg-gradient-to-br from-blue-50/50 to-white border-2 border-blue-100`}
|
||||
onClick={() => router.push(scenario.link || "")}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center shadow-sm">
|
||||
<Sparkles className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-blue-600">{scenario.name}</h3>
|
||||
<p className="text-xs text-gray-500 text-center line-clamp-2">{scenario.description}</p>
|
||||
|
||||
<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">{scenario.stats.daily}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-green-500 text-xs">
|
||||
<TrendingUp className="w-3 h-3 mr-1" />
|
||||
<span>+{scenario.stats.growth}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
4
Cunkebao/app/scenarios/phone/acquired/loading.tsx
Normal file
4
Cunkebao/app/scenarios/phone/acquired/loading.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
179
Cunkebao/app/scenarios/phone/acquired/page.tsx
Normal file
179
Cunkebao/app/scenarios/phone/acquired/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Search, Phone, MoreVertical, UserPlus, Plus } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
|
||||
interface Customer {
|
||||
id: string
|
||||
phoneNumber: string
|
||||
time: string
|
||||
status: "pending" | "added"
|
||||
question: string
|
||||
}
|
||||
|
||||
export default function PhoneAcquiredPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [customers, setCustomers] = useState<Customer[]>([
|
||||
{
|
||||
id: "1",
|
||||
phoneNumber: "138****1234",
|
||||
time: "2024-03-18 15:30",
|
||||
status: "pending",
|
||||
question: "请问贵公司的产品有什么特点?",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
phoneNumber: "139****5678",
|
||||
time: "2024-03-18 14:15",
|
||||
status: "added",
|
||||
question: "你们的合作方式是怎样的?",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
phoneNumber: "137****9012",
|
||||
time: "2024-03-18 11:22",
|
||||
status: "added",
|
||||
question: "能详细介绍一下你们的服务吗?",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
phoneNumber: "135****3456",
|
||||
time: "2024-03-17 16:45",
|
||||
status: "added",
|
||||
question: "你们有什么优惠政策?",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
phoneNumber: "136****7890",
|
||||
time: "2024-03-17 10:20",
|
||||
status: "pending",
|
||||
question: "未识别到有效问题",
|
||||
},
|
||||
])
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredCustomers = customers.filter(
|
||||
(customer) => customer.phoneNumber.includes(searchQuery) || customer.question.includes(searchQuery),
|
||||
)
|
||||
|
||||
const handleAddFriend = (customerId: string) => {
|
||||
setCustomers(
|
||||
customers.map((customer) => (customer.id === customerId ? { ...customer, status: "added" } : customer)),
|
||||
)
|
||||
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: "已成功添加为微信好友",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">电话已获客</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => router.push(`/scenarios/phone/new`)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
<Card className="p-6 bg-white/80 backdrop-blur-sm">
|
||||
<div className="mb-4 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索电话号码或问题"
|
||||
className="pl-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>电话号码</TableHead>
|
||||
<TableHead>获客时间</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>首句问题</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCustomers.map((customer) => (
|
||||
<TableRow key={customer.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="h-8 w-8 bg-blue-100">
|
||||
<AvatarFallback className="bg-blue-100 text-blue-600">
|
||||
<Phone className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{customer.phoneNumber}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{customer.time}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={customer.status === "added" ? "success" : "secondary"}>
|
||||
{customer.status === "added" ? "已添加" : "未添加"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{customer.question || "未识别到问题"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{customer.status === "pending" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-blue-600"
|
||||
onClick={() => handleAddFriend(customer.id)}
|
||||
>
|
||||
<UserPlus className="h-4 w-4 mr-1" />
|
||||
添加好友
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem className="cursor-pointer">查看详情</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
4
Cunkebao/app/scenarios/phone/added/loading.tsx
Normal file
4
Cunkebao/app/scenarios/phone/added/loading.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
133
Cunkebao/app/scenarios/phone/added/page.tsx
Normal file
133
Cunkebao/app/scenarios/phone/added/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Search, User, MessageSquare, Plus } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
|
||||
interface Friend {
|
||||
id: string
|
||||
phoneNumber: string
|
||||
nickname: string
|
||||
addedTime: string
|
||||
question: string
|
||||
}
|
||||
|
||||
export default function PhoneAddedPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [friends, setFriends] = useState<Friend[]>([
|
||||
{
|
||||
id: "1",
|
||||
phoneNumber: "139****5678",
|
||||
nickname: "张先生",
|
||||
addedTime: "2024-03-18 14:20",
|
||||
question: "你们的合作方式是怎样的?",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
phoneNumber: "137****9012",
|
||||
nickname: "李女士",
|
||||
addedTime: "2024-03-18 11:30",
|
||||
question: "能详细介绍一下你们的服务吗?",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
phoneNumber: "135****3456",
|
||||
nickname: "王经理",
|
||||
addedTime: "2024-03-17 16:50",
|
||||
question: "你们有什么优惠政策?",
|
||||
},
|
||||
])
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredFriends = friends.filter(
|
||||
(friend) =>
|
||||
friend.phoneNumber.includes(searchQuery) ||
|
||||
friend.nickname.includes(searchQuery) ||
|
||||
friend.question.includes(searchQuery),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">电话已添加</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => router.push(`/scenarios/phone/new`)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
<Card className="p-6 bg-white/80 backdrop-blur-sm">
|
||||
<div className="mb-4 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索电话号码或昵称"
|
||||
className="pl-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>好友信息</TableHead>
|
||||
<TableHead>添加时间</TableHead>
|
||||
<TableHead>首句问题</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredFriends.map((friend) => (
|
||||
<TableRow key={friend.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="h-8 w-8 bg-green-100">
|
||||
<AvatarFallback className="bg-green-100 text-green-600">
|
||||
<User className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{friend.nickname}</div>
|
||||
<div className="text-sm text-gray-500">{friend.phoneNumber}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{friend.addedTime}</TableCell>
|
||||
<TableCell className="max-w-[300px] truncate">{friend.question || "未识别到问题"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline">
|
||||
<MessageSquare className="h-4 w-4 mr-1" />
|
||||
发送消息
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
187
Cunkebao/app/scenarios/phone/devices/page.tsx
Normal file
187
Cunkebao/app/scenarios/phone/devices/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Plus, MoreVertical, Trash2 } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
status: "online" | "offline"
|
||||
lastActive: string
|
||||
acquired: number
|
||||
}
|
||||
|
||||
export default function PhoneDevicesPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const [devices, setDevices] = useState<Device[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "电话设备 1",
|
||||
status: "online",
|
||||
lastActive: "2024-03-18 15:30",
|
||||
acquired: 12,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "电话设备 2",
|
||||
status: "online",
|
||||
lastActive: "2024-03-18 14:45",
|
||||
acquired: 8,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "电话设备 3",
|
||||
status: "offline",
|
||||
lastActive: "2024-03-17 10:20",
|
||||
acquired: 18,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "电话设备 4",
|
||||
status: "online",
|
||||
lastActive: "2024-03-18 16:10",
|
||||
acquired: 0,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "电话设备 5",
|
||||
status: "online",
|
||||
lastActive: "2024-03-18 13:55",
|
||||
acquired: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
|
||||
|
||||
const handleRemoveDevice = (deviceId: string) => {
|
||||
const deviceToRemove = devices.find((d) => d.id === deviceId)
|
||||
if (deviceToRemove) {
|
||||
setDevices(devices.filter((d) => d.id !== deviceId))
|
||||
toast({
|
||||
title: "设备已移除",
|
||||
description: `已成功移除"${deviceToRemove.name}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">电话获客设备</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => router.push(`/scenarios/phone/new`)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
<Card className="p-6 bg-white/80 backdrop-blur-sm">
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>设备名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>最后活跃</TableHead>
|
||||
<TableHead>已获客</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((device) => (
|
||||
<TableRow key={device.id}>
|
||||
<TableCell className="font-medium">{device.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{device.lastActive}</TableCell>
|
||||
<TableCell>{device.acquired}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRemoveDevice(device.id)}
|
||||
className="text-red-600 cursor-pointer hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
移除设备
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 添加设备对话框 */}
|
||||
<Dialog open={isAddDeviceOpen} onOpenChange={setIsAddDeviceOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加电话获客设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-center text-gray-500 mb-4">请选择要添加的电话设备</p>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((num) => (
|
||||
<Card key={num} className="p-3 cursor-pointer hover:bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">电话设备 {num + 5}</p>
|
||||
<p className="text-sm text-gray-500">最后活跃: 2024-03-18</p>
|
||||
</div>
|
||||
<Badge variant="outline">可用</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsAddDeviceOpen(false)
|
||||
toast({
|
||||
title: "设备已添加",
|
||||
description: "已成功添加电话获客设备",
|
||||
})
|
||||
}}
|
||||
>
|
||||
确认添加
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
402
Cunkebao/app/scenarios/phone/edit/[id]/page.tsx
Normal file
402
Cunkebao/app/scenarios/phone/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronLeft, Phone, MessageSquare, Settings, Users } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
export default function EditPhoneAcquisitionPlan({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [formData, setFormData] = useState({
|
||||
planName: "",
|
||||
phoneNumbers: ["400-123-4567"],
|
||||
autoAddEnabled: true,
|
||||
speechToTextEnabled: true,
|
||||
questionExtractionEnabled: true,
|
||||
greetingMessage: "",
|
||||
followUpMessages: [],
|
||||
addToGroups: [],
|
||||
tags: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟从API获取计划数据
|
||||
const fetchPlanData = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
const mockData = {
|
||||
planName: "招商电话获客",
|
||||
phoneNumbers: ["400-123-4567"],
|
||||
autoAddEnabled: true,
|
||||
speechToTextEnabled: true,
|
||||
questionExtractionEnabled: true,
|
||||
greetingMessage: "您好,感谢您的来电咨询,我稍后会添加您为好友,为您提供更详细的资料。",
|
||||
followUpMessages: [
|
||||
"您好,我是XX公司的客服,刚才接到您的电话,现在方便聊一下吗?",
|
||||
"关于您刚才咨询的问题,我这边整理了一些资料,希望对您有所帮助。",
|
||||
],
|
||||
addToGroups: ["产品咨询群", "招商合作群"],
|
||||
tags: ["电话咨询", "招商意向"],
|
||||
}
|
||||
setFormData(mockData)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "加载失败",
|
||||
description: "获取计划数据失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPlanData()
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "电话获客计划已更新",
|
||||
})
|
||||
router.push(`/scenarios/phone`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "更新计划失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPhoneNumber = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
phoneNumbers: [...formData.phoneNumbers, ""],
|
||||
})
|
||||
}
|
||||
|
||||
const handlePhoneNumberChange = (index: number, value: string) => {
|
||||
const updatedPhoneNumbers = [...formData.phoneNumbers]
|
||||
updatedPhoneNumbers[index] = value
|
||||
setFormData({
|
||||
...formData,
|
||||
phoneNumbers: updatedPhoneNumbers,
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemovePhoneNumber = (index: number) => {
|
||||
const updatedPhoneNumbers = [...formData.phoneNumbers]
|
||||
updatedPhoneNumbers.splice(index, 1)
|
||||
setFormData({
|
||||
...formData,
|
||||
phoneNumbers: updatedPhoneNumbers,
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddFollowUpMessage = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
followUpMessages: [...formData.followUpMessages, ""],
|
||||
})
|
||||
}
|
||||
|
||||
const handleFollowUpMessageChange = (index: number, value: string) => {
|
||||
const updatedMessages = [...formData.followUpMessages]
|
||||
updatedMessages[index] = value
|
||||
setFormData({
|
||||
...formData,
|
||||
followUpMessages: updatedMessages,
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveFollowUpMessage = (index: number) => {
|
||||
const updatedMessages = [...formData.followUpMessages]
|
||||
updatedMessages.splice(index, 1)
|
||||
setFormData({
|
||||
...formData,
|
||||
followUpMessages: updatedMessages,
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">编辑电话获客计划</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-4">
|
||||
<TabsTrigger value="basic">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
基础设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="message">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
消息设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
高级设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="planName">计划名称</Label>
|
||||
<Input
|
||||
id="planName"
|
||||
value={formData.planName}
|
||||
onChange={(e) => setFormData({ ...formData, planName: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>接听电话</Label>
|
||||
<div className="space-y-2 mt-1">
|
||||
{formData.phoneNumbers.map((phone, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={phone}
|
||||
onChange={(e) => handlePhoneNumberChange(index, e.target.value)}
|
||||
placeholder="输入电话号码"
|
||||
/>
|
||||
{formData.phoneNumbers.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemovePhoneNumber(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={handleAddPhoneNumber}>
|
||||
添加电话号码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-add">自动添加客户</Label>
|
||||
<p className="text-xs text-gray-500">来电后自动将客户添加为微信好友</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-add"
|
||||
checked={formData.autoAddEnabled}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, autoAddEnabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="speech-to-text">语音转文字</Label>
|
||||
<p className="text-xs text-gray-500">自动将通话内容转换为文字记录</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="speech-to-text"
|
||||
checked={formData.speechToTextEnabled}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, speechToTextEnabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="question-extraction">问题提取</Label>
|
||||
<p className="text-xs text-gray-500">自动从通话中提取客户的首句问题</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="question-extraction"
|
||||
checked={formData.questionExtractionEnabled}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, questionExtractionEnabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="message" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="greeting">添加好友后的问候语</Label>
|
||||
<Textarea
|
||||
id="greeting"
|
||||
value={formData.greetingMessage}
|
||||
onChange={(e) => setFormData({ ...formData, greetingMessage: e.target.value })}
|
||||
placeholder="输入添加好友后的问候语"
|
||||
className="mt-1 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>后续跟进消息</Label>
|
||||
<div className="space-y-2 mt-1">
|
||||
{formData.followUpMessages.map((message, index) => (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">消息 {index + 1}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveFollowUpMessage(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => handleFollowUpMessageChange(index, e.target.value)}
|
||||
placeholder="输入跟进消息内容"
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={handleAddFollowUpMessage}>
|
||||
添加跟进消息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>添加到群组</Label>
|
||||
<Select
|
||||
value={formData.addToGroups.length > 0 ? "selected" : "none"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
setFormData({ ...formData, addToGroups: [] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="选择群组" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不添加到群组</SelectItem>
|
||||
<SelectItem value="selected">已选择 {formData.addToGroups.length} 个群组</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.addToGroups.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{formData.addToGroups.map((group, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center bg-blue-50 text-blue-700 rounded-full px-3 py-1"
|
||||
>
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
<span className="text-sm">{group}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-1 p-0 h-4 w-4 rounded-full"
|
||||
onClick={() => {
|
||||
const updatedGroups = [...formData.addToGroups]
|
||||
updatedGroups.splice(index, 1)
|
||||
setFormData({ ...formData, addToGroups: updatedGroups })
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>标签设置</Label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<div key={index} className="flex items-center bg-gray-100 rounded-full px-3 py-1">
|
||||
<span className="text-sm">{tag}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-1 p-0 h-4 w-4 rounded-full"
|
||||
onClick={() => {
|
||||
const updatedTags = [...formData.tags]
|
||||
updatedTags.splice(index, 1)
|
||||
setFormData({ ...formData, tags: updatedTags })
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Input
|
||||
placeholder="添加标签"
|
||||
className="w-32"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value) {
|
||||
e.preventDefault()
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, e.currentTarget.value],
|
||||
})
|
||||
e.currentTarget.value = ""
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-white border-t p-4">
|
||||
<Button className="w-full" onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
123
Cunkebao/app/scenarios/phone/page.tsx
Normal file
123
Cunkebao/app/scenarios/phone/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { ExpandableAcquisitionCard } from "@/components/acquisition/ExpandableAcquisitionCard"
|
||||
import Link from "next/link"
|
||||
import { DeviceTreeChart } from "@/app/components/acquisition/DeviceTreeChart"
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
status: "running" | "paused" | "completed"
|
||||
stats: {
|
||||
devices: number
|
||||
acquired: number
|
||||
added: number
|
||||
}
|
||||
lastUpdated: string
|
||||
executionTime: string
|
||||
nextExecutionTime: string
|
||||
trend: { date: string; customers: number }[]
|
||||
dailyData: { date: string; acquired: number; added: number }[]
|
||||
}
|
||||
|
||||
export default function PhoneAcquisitionPage() {
|
||||
const router = useRouter()
|
||||
const channel = "phone"
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "电话招商获客计划",
|
||||
status: "running",
|
||||
stats: {
|
||||
devices: 5,
|
||||
acquired: 38,
|
||||
added: 31,
|
||||
},
|
||||
lastUpdated: "2024-03-18 15:30",
|
||||
executionTime: "2024-03-18 15:30",
|
||||
nextExecutionTime: "预计30分钟后",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `3月${String(i + 12)}日`,
|
||||
customers: Math.floor(Math.random() * 10) + 5,
|
||||
})),
|
||||
dailyData: [
|
||||
{ date: "3/12", acquired: 12, added: 8 },
|
||||
{ date: "3/13", acquired: 15, added: 10 },
|
||||
{ date: "3/14", acquired: 8, added: 6 },
|
||||
{ date: "3/15", acquired: 10, added: 7 },
|
||||
{ date: "3/16", acquired: 14, added: 11 },
|
||||
{ date: "3/17", acquired: 9, added: 7 },
|
||||
{ date: "3/18", acquired: 11, added: 9 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const handleCopyPlan = (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId)
|
||||
if (taskToCopy) {
|
||||
const newTask = {
|
||||
...taskToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${taskToCopy.name} (副本)`,
|
||||
status: "paused" as const,
|
||||
}
|
||||
setTasks([...tasks, newTask])
|
||||
toast({
|
||||
title: "计划已复制",
|
||||
description: `已成功复制"${taskToCopy.name}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlan = (taskId: string) => {
|
||||
const taskToDelete = tasks.find((t) => t.id === taskId)
|
||||
if (taskToDelete) {
|
||||
setTasks(tasks.filter((t) => t.id !== taskId))
|
||||
toast({
|
||||
title: "计划已删除",
|
||||
description: `已成功删除"${taskToDelete.name}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">电话获客</h1>
|
||||
</div>
|
||||
<Link href="/scenarios/phone/new" className="ml-auto">
|
||||
<Button className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
{tasks.map((task) => (
|
||||
<ExpandableAcquisitionCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
channel={channel}
|
||||
onCopy={handleCopyPlan}
|
||||
onDelete={handleDeletePlan}
|
||||
/>
|
||||
))}
|
||||
<DeviceTreeChart />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user