流量分发列表 + 添加 + 修改状态功能提交

This commit is contained in:
wong
2025-06-16 15:42:36 +08:00
parent 9e89e3db9c
commit 51beeee212
14 changed files with 1423 additions and 645 deletions

View File

@@ -19,6 +19,10 @@ interface Task {
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
reqConf?: {
device?: string[]
selectedDevices?: string[]
}
}
interface ScenarioAcquisitionCardProps {
@@ -40,11 +44,21 @@ export function ScenarioAcquisitionCard({
onOpenSettings,
onStatusChange,
}: ScenarioAcquisitionCardProps) {
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
// 兼容后端真实数据结构
const deviceCount = Array.isArray(task.reqConf?.device)
? task.reqConf.device.length
: Array.isArray(task.reqConf?.selectedDevices)
? task.reqConf.selectedDevices.length
: 0
// 获客数和已添加数可根据 msgConf 或其它字段自定义
const acquiredCount = task.stats?.acquired ?? 0
const addedCount = task.stats?.added ?? 0
const passRate = calculatePassRate(acquiredCount, addedCount)
const [menuOpen, setMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const isActive = task.status === 1;
const handleStatusChange = (e: React.MouseEvent) => {
e.stopPropagation()
if (onStatusChange) {
@@ -103,11 +117,11 @@ export function ScenarioAcquisitionCard({
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Badge
variant={task.status === "running" ? "success" : "secondary"}
variant={isActive ? "success" : "secondary"}
className="cursor-pointer hover:opacity-80"
onClick={handleStatusChange}
>
{task.status === "running" ? "进行中" : "已暂停"}
{isActive ? "进行中" : "已暂停"}
</Badge>
</div>
<div className="relative z-20" ref={menuRef}>

View File

@@ -4,12 +4,13 @@ import { useState, useEffect } from "react"
import { ChevronLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { BasicSettings } from "@/plans/new/steps/BasicSettings"
import { FriendRequestSettings } from "@/plans/new/steps/FriendRequestSettings"
import { MessageSettings } from "@/plans/new/steps/MessageSettings"
import { TagSettings } from "@/plans/new/steps/TagSettings"
import { BasicSettings } from "../../../new/steps/BasicSettings"
import { FriendRequestSettings } from "../../../new/steps/FriendRequestSettings"
import { MessageSettings } from "../../../new/steps/MessageSettings"
import { TagSettings } from "@/scenarios/new/steps/TagSettings"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
import { api, ApiResponse } from "@/lib/api"
const steps = [
{ id: 1, title: "步骤一", subtitle: "基础设置" },
@@ -22,6 +23,7 @@ export default function EditAcquisitionPlan({ params }: { params: { channel: str
const router = useRouter()
const [currentStep, setCurrentStep] = useState(1)
const [loading, setLoading] = useState(true)
const [scenes, setScenes] = useState<any[]>([])
const [formData, setFormData] = useState({
planName: "",
accounts: [],
@@ -39,26 +41,21 @@ export default function EditAcquisitionPlan({ params }: { params: { channel: str
})
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: "欢迎",
const [planRes, scenesRes] = await Promise.all([
api.get<ApiResponse>(`/v1/plan/detail?id=${params.id}`),
api.get<ApiResponse>("/v1/plan/scenes")
])
if (planRes.code === 200 && planRes.data) {
setFormData(planRes.data)
}
setFormData(mockData)
if (scenesRes.code === 200 && Array.isArray(scenesRes.data)) {
setScenes(scenesRes.data)
}
setLoading(false)
} catch (error) {
toast({
@@ -71,18 +68,25 @@ export default function EditAcquisitionPlan({ params }: { params: { channel: str
}
fetchPlanData()
}, [])
}, [params.id])
const handleSave = async () => {
try {
// 这里应该是实际的API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
const res = await api.put<ApiResponse>(`/v1/plan/update?id=${params.id}`, formData)
toast({
title: "保存成功",
description: "获客计划已更新",
})
router.push(`/scenarios/${params.channel}`)
if (res.code === 200) {
toast({
title: "保存成功",
description: "获客计划已更新",
})
router.push(`/scenarios/${params.channel}`)
} else {
toast({
title: "保存失败",
description: res.msg || "更新计划失败,请重试",
variant: "destructive",
})
}
} catch (error) {
toast({
title: "保存失败",
@@ -159,7 +163,7 @@ export default function EditAcquisitionPlan({ params }: { params: { channel: str
const renderStepContent = () => {
switch (currentStep) {
case 1:
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} isEdit />
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} scenarios={scenes} isEdit />
case 2:
return (
<FriendRequestSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
@@ -178,7 +182,7 @@ export default function EditAcquisitionPlan({ params }: { params: { channel: str
<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()}>
<Button variant="ghost" size="icon" onClick={() => router.push(`/scenarios/${params.channel}`)}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="ml-2 text-lg font-medium"></h1>

View File

@@ -1,6 +1,6 @@
"use client"
import { use, useState } from "react"
import { use, useState, useEffect } from "react"
import { Copy, Link, HelpCircle, Shield, ChevronLeft, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
@@ -17,24 +17,7 @@ import {
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: "海报",
poster: "海报",
phone: "电话",
gongzhonghao: "公众号",
weixinqun: "微信群",
payment: "付款码",
api: "API",
}
return channelMap[channel] || channel
}
import { api, ApiResponse } from "@/lib/api"
interface Task {
id: string
@@ -83,46 +66,48 @@ function ApiDocumentationTooltip() {
// const channel = unwrappedParams.channel
// const channelName = getChannelName(unwrappedParams.channel)
const channel = resolvedParams.channel
const channelName = getChannelName(resolvedParams.channel)
const [channelName, setChannelName] = useState<string>("")
const initialTasks: Task[] = [
{
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,
})),
},
]
// 1. tasks 初始值设为 []
const [tasks, setTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [tasks, setTasks] = useState<Task[]>(initialTasks)
useEffect(() => {
api.get<ApiResponse>(`/v1/plan/scenes-detail?id=${channel}`)
.then((res: ApiResponse) => {
if (res.code === 200 && res.data?.name) {
setChannelName(res.data.name)
} else {
setChannelName(channel)
}
})
.catch(() => setChannelName(channel))
}, [channel])
// 抽出请求列表的函数
const fetchTasks = () => {
setLoading(true)
setError("")
api.get<ApiResponse>(`/v1/plan/list?sceneId=${channel}&page=${page}&pageSize=${pageSize}`)
.then((res: ApiResponse) => {
if (res.code === 200 && Array.isArray(res.data?.list)) {
setTasks(res.data.list)
setTotal(res.data.total || 0)
} else {
setError(res.msg || "接口返回异常")
}
})
.catch(err => setError(err?.message || "接口请求失败"))
.finally(() => setLoading(false))
}
useEffect(() => {
fetchTasks()
}, [channel, page, pageSize])
const [deviceStats, setDeviceStats] = useState<DeviceStats>({
active: 5,
@@ -141,32 +126,61 @@ function ApiDocumentationTooltip() {
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: "default",
if (!taskToCopy) return;
api.get<ApiResponse>(`/v1/plan/copy?planId=${taskId}`)
.then((res: ApiResponse) => {
if (res.code === 200) {
toast({
title: "计划已复制",
description: `已成功复制"${taskToCopy.name}"`,
variant: "default",
})
setPage(1)
fetchTasks()
} else {
toast({
title: "复制失败",
description: res.msg || "复制计划失败,请重试",
variant: "destructive",
})
}
})
.catch(err => {
toast({
title: "复制失败",
description: err?.message || "复制计划失败,请重试",
variant: "destructive",
})
})
}
}
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: "default",
if (!taskToDelete) return;
api.delete<ApiResponse>(`/v1/plan/delete?planId=${taskId}`)
.then((res: ApiResponse) => {
if (res.code === 200) {
setTasks(tasks.filter((t) => t.id !== taskId))
toast({
title: "计划已删除",
description: `已成功删除"${taskToDelete.name}"`,
variant: "default",
})
} else {
toast({
title: "删除失败",
description: res.msg || "删除计划失败,请重试",
variant: "destructive",
})
}
})
.catch(err => {
toast({
title: "删除失败",
description: err?.message || "删除计划失败,请重试",
variant: "destructive",
})
})
}
}
const handleStatusChange = (taskId: string, newStatus: "running" | "paused") => {
@@ -217,37 +231,48 @@ function ApiDocumentationTooltip() {
<Button variant="ghost" size="icon" onClick={() => router.push("/scenarios")} className="h-8 w-8">
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-xl font-semibold text-blue-600">{channelName}</h1>
<h1 className="text-xl font-semibold text-blue-600">{channelName}</h1>
</div>
<Button onClick={handleCreateNewPlan} size="sm" className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-1" />
{channelName}
</Button>
</div>
</header>
<div className="p-4 md:p-6 lg:p-8 max-w-7xl mx-auto">
<div className="space-y-4">
{tasks.length > 0 ? (
tasks.map((task) => (
<div key={task.id}>
<ScenarioAcquisitionCard
task={task}
channel={channel}
onEdit={() => handleEditPlan(task.id)}
onCopy={handleCopyPlan}
onDelete={handleDeletePlan}
onStatusChange={handleStatusChange}
onOpenSettings={handleOpenApiSettings}
/>
{loading ? (
<div className="text-center py-12 text-gray-400">...</div>
) : error ? (
<div className="text-center py-12 text-red-500">{error}</div>
) : tasks.length > 0 ? (
<>
{tasks.map((task) => (
<div key={task.id}>
<ScenarioAcquisitionCard
task={task}
channel={channel}
onEdit={() => handleEditPlan(task.id)}
onCopy={handleCopyPlan}
onDelete={handleDeletePlan}
onStatusChange={handleStatusChange}
onOpenSettings={handleOpenApiSettings}
/>
</div>
))}
<div className="flex justify-center mt-6 gap-2">
<Button disabled={page === 1} onClick={() => setPage(page - 1)}></Button>
<span className="px-2"> {page} / {Math.max(1, Math.ceil(total / pageSize))} </span>
<Button disabled={page * pageSize >= total} onClick={() => setPage(page + 1)}></Button>
</div>
))
</>
) : (
<div className="text-center py-12 bg-white rounded-lg shadow-sm md:col-span-2 lg:col-span-3">
<div className="text-gray-400 mb-4"></div>
<Button onClick={handleCreateNewPlan} className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-1" />
{channelName}
</Button>
</div>
)}

View File

@@ -23,9 +23,8 @@ export default function NewPlan() {
const [currentStep, setCurrentStep] = useState(1)
const [formData, setFormData] = useState({
planName: "",
scenario: "",
posters: [],
device: "",
device: [],
remarkType: "phone",
greeting: "你好,请通过",
addInterval: 1,
@@ -43,7 +42,6 @@ export default function NewPlan() {
.then(res => {
if (res.code === 200 && Array.isArray(res.data)) {
setScenes(res.data)
setFormData(prev => ({ ...prev, scenario: prev.scenario || (res.data[0]?.id || "") }))
}
})
.finally(() => setLoadingScenes(false))
@@ -57,19 +55,31 @@ export default function NewPlan() {
// 处理保存
const handleSave = async () => {
try {
// 这里应该是实际的API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
toast({
title: "创建成功",
description: "获客计划已创建",
})
// router.push("/plans")
router.push("/scenarios")
} catch (error) {
// 先赋值再去除多余字段
const submitData = {
...formData,
device: formData.selectedDevices || formData.device,
posters: formData.materials || formData.posters,
};
const { selectedDevices, materials, ...finalData } = submitData;
const res = await api.post<ApiResponse>("/v1/plan/create", finalData);
if (res.code === 200) {
toast({
title: "创建成功",
description: "获客计划已创建",
})
router.push("/scenarios")
} else {
toast({
title: "创建失败",
description: res.msg || "创建计划失败,请重试",
variant: "destructive",
})
}
} catch (error: any) {
toast({
title: "创建失败",
description: "创建计划失败,请重试",
description: error?.message || "创建计划失败,请重试",
variant: "destructive",
})
}
@@ -113,7 +123,7 @@ export default function NewPlan() {
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between h-14 px-4">
<div className="flex items-center">
<Button variant="ghost" size="icon" onClick={() => router.push("/plans")}>
<Button variant="ghost" size="icon" onClick={() => router.push("/scenarios")}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="ml-2 text-lg font-medium"></h1>

View File

@@ -2,7 +2,7 @@
import type React from "react"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -19,6 +19,8 @@ import {
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog"
import { toast } from "@/components/ui/use-toast"
import { useSearchParams } from "next/navigation"
interface BasicSettingsProps {
formData: any
@@ -98,24 +100,24 @@ const generatePosterMaterials = (): Material[] => {
// 颜色池分为更浅的未选中和深色的选中
const tagColorPoolLight = [
"bg-blue-50 text-blue-600",
"bg-green-50 text-green-600",
"bg-purple-50 text-purple-600",
"bg-red-50 text-red-600",
"bg-orange-50 text-orange-600",
"bg-yellow-50 text-yellow-600",
"bg-gray-50 text-gray-600",
"bg-pink-50 text-pink-600",
"bg-blue-100 text-blue-600",
"bg-green-100 text-green-600",
"bg-purple-100 text-purple-600",
"bg-red-100 text-red-600",
"bg-orange-100 text-orange-600",
"bg-yellow-100 text-yellow-600",
"bg-gray-100 text-gray-600",
"bg-pink-100 text-pink-600",
];
const tagColorPoolDark = [
"bg-blue-500 text-white",
"bg-green-500 text-white",
"bg-purple-500 text-white",
"bg-red-500 text-white",
"bg-orange-500 text-white",
"bg-yellow-500 text-white",
"bg-gray-500 text-white",
"bg-pink-500 text-white",
"bg-blue-100 text-blue-600",
"bg-green-100 text-green-600",
"bg-purple-100 text-purple-600",
"bg-red-100 text-red-600",
"bg-orange-100 text-orange-600",
"bg-yellow-100 text-yellow-600",
"bg-gray-100 text-gray-600",
"bg-pink-100 text-pink-600",
];
function getTagColorIdx(tag: string) {
let hash = 0;
@@ -125,6 +127,141 @@ function getTagColorIdx(tag: string) {
return Math.abs(hash) % tagColorPoolLight.length;
}
// Section组件示例
const PosterSection = ({ materials, selectedMaterials, onUpload, onSelect, uploading, fileInputRef, onFileChange, onPreview, onRemove }) => (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={onUpload} disabled={uploading} className="w-10 h-10 p-0 flex items-center justify-center rounded-xl">
<Plus className="h-5 w-5" />
<input
type="file"
ref={fileInputRef}
onChange={onFileChange}
className="hidden"
accept="image/*"
/>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
{materials.map((material) => (
<div
key={material.id}
className={`relative cursor-pointer rounded-lg overflow-hidden group ${
selectedMaterials.find((m) => m.id === material.id)
? "ring-2 ring-blue-600"
: "hover:ring-2 hover:ring-blue-600"
}`}
onClick={() => onSelect(material)}
>
<img
src={material.preview || "/placeholder.svg"}
alt={material.name}
className="w-full aspect-[9/16] object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
onPreview(material.preview)
}}
>
<Maximize2 className="h-4 w-4 text-white" />
</Button>
</div>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{material.name}</div>
</div>
</div>
))}
</div>
{selectedMaterials.length > 0 && (
<div className="mt-4">
<Label></Label>
<div className="relative w-full max-w-[120px]">
<img
src={selectedMaterials[0].preview || "/placeholder.svg"}
alt={selectedMaterials[0].name}
className="w-full aspect-[9/16] object-cover rounded-lg cursor-pointer"
onClick={() => onPreview(selectedMaterials[0].preview)}
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => onRemove(selectedMaterials[0].id)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
const OrderSection = ({ materials, onUpload, uploading, fileInputRef, onFileChange }) => (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={onUpload} disabled={uploading} className="w-10 h-10 p-0 flex items-center justify-center rounded-xl">
<Plus className="h-5 w-5" />
<input
type="file"
ref={fileInputRef}
onChange={onFileChange}
className="hidden"
accept="image/*"
/>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
{materials.map((item) => (
<div key={item.id} className="relative cursor-pointer rounded-lg overflow-hidden group">
<img src={item.preview || "/placeholder.svg"} alt={item.name} className="w-full aspect-[9/16] object-cover" />
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{item.name}</div>
</div>
</div>
))}
</div>
</div>
)
const DouyinSection = ({ materials, onUpload, uploading, fileInputRef, onFileChange }) => (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={onUpload} disabled={uploading} className="w-10 h-10 p-0 flex items-center justify-center rounded-xl">
<Plus className="h-5 w-5" />
<input
type="file"
ref={fileInputRef}
onChange={onFileChange}
className="hidden"
accept="image/*"
/>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
{materials.map((item) => (
<div key={item.id} className="relative cursor-pointer rounded-lg overflow-hidden group">
<img src={item.preview || "/placeholder.svg"} alt={item.name} className="w-full aspect-[9/16] object-cover" />
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{item.name}</div>
</div>
</div>
))}
</div>
</div>
)
const PlaceholderSection = ({ title }) => (
<div className="p-8 text-center text-gray-400 border rounded-lg mt-4">{title}</div>
)
export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSettingsProps) {
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false)
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false)
@@ -133,7 +270,7 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
const [isPhoneSettingsOpen, setIsPhoneSettingsOpen] = useState(false)
const [previewImage, setPreviewImage] = useState("")
const [accounts] = useState<Account[]>(generateRandomAccounts(50))
const [materials] = useState<Material[]>(generatePosterMaterials())
const [materials, setMaterials] = useState<Material[]>(generatePosterMaterials())
const [selectedAccounts, setSelectedAccounts] = useState<Account[]>(
formData.accounts?.length > 0 ? formData.accounts : [],
)
@@ -154,9 +291,7 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
const [selectedScenarioTags, setSelectedScenarioTags] = useState<string[]>(formData.scenarioTags || [])
const [customTagInput, setCustomTagInput] = useState("")
const [customTags, setCustomTags] = useState<Array<{ id: string; name: string; color: string }>>(
formData.customTags || [],
)
const [customTags, setCustomTags] = useState<string[]>(formData.customTags || [])
// 初始化电话获客设置
const [phoneSettings, setPhoneSettings] = useState({
@@ -168,7 +303,55 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
const [selectedPhoneTags, setSelectedPhoneTags] = useState<string[]>(formData.phoneTags || [])
const [phoneCallType, setPhoneCallType] = useState(formData.phoneCallType || "both")
// 处理标签选择 (现在处理的是字符串标签)
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadingPoster, setUploadingPoster] = useState(false)
// 新增不同场景的materials和上传逻辑
const [orderMaterials, setOrderMaterials] = useState<any[]>([])
const [douyinMaterials, setDouyinMaterials] = useState<any[]>([])
const orderFileInputRef = useRef<HTMLInputElement>(null)
const douyinFileInputRef = useRef<HTMLInputElement>(null)
const [uploadingOrder, setUploadingOrder] = useState(false)
const [uploadingDouyin, setUploadingDouyin] = useState(false)
// 新增小程序和链接封面上传相关state和ref
const [miniAppCover, setMiniAppCover] = useState(formData.miniAppCover || "")
const [uploadingMiniAppCover, setUploadingMiniAppCover] = useState(false)
const miniAppFileInputRef = useRef<HTMLInputElement>(null)
const [linkCover, setLinkCover] = useState(formData.linkCover || "")
const [uploadingLinkCover, setUploadingLinkCover] = useState(false)
const linkFileInputRef = useRef<HTMLInputElement>(null)
const searchParams = useSearchParams()
const type = searchParams.get("type")
// 类型映射表
const typeMap: Record<string, string> = {
haibao: "poster",
douyin: "douyin",
kuaishou: "kuaishou",
xiaohongshu: "xiaohongshu",
weibo: "weibo",
phone: "phone",
gongzhonghao: "gongzhonghao",
weixinqun: "weixinqun",
payment: "payment",
api: "api",
order: "order"
}
const realType = typeMap[type] || type
const filteredScenarios = scenarios.filter(scene => scene.type === realType)
// 只在有唯一匹配时自动选中,否则不自动选中
useEffect(() => {
if (filteredScenarios.length === 1 && formData.sceneId !== filteredScenarios[0].id) {
onChange({ sceneId: filteredScenarios[0].id })
}
}, [filteredScenarios, formData.sceneId, onChange])
// 展示所有场景
const displayedScenarios = scenarios
const handleTagToggle = (tag: string) => {
const newTags = selectedPhoneTags.includes(tag)
? selectedPhoneTags.filter((t) => t !== tag)
@@ -178,13 +361,11 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
onChange({ ...formData, phoneTags: newTags })
}
// 处理通话类型选择
const handleCallTypeChange = (type: string) => {
setPhoneCallType(type)
onChange({ ...formData, phoneCallType: type })
}
// 初始化时,如果没有选择场景,默认选择海报获客
useEffect(() => {
if (!formData.scenario) {
onChange({ ...formData, scenario: "haibao" })
@@ -201,7 +382,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
}, [formData, onChange])
const handleScenarioSelect = (scenarioId: string) => {
// 如果选择了电话获客,更新计划名称
if (scenarioId === "phone") {
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
onChange({ ...formData, scenario: scenarioId, planName: `电话获客${today}` })
@@ -210,7 +390,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
}
}
// 处理场景标签选择 (现在处理的是字符串标签)
const handleScenarioTagToggle = (tag: string) => {
const newTags = selectedScenarioTags.includes(tag)
? selectedScenarioTags.filter((t) => t !== tag)
@@ -220,41 +399,22 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
onChange({ ...formData, scenarioTags: newTags })
}
// 添加自定义标签
const handleAddCustomTag = () => {
if (!customTagInput.trim()) return
const colors = [
"bg-blue-100 text-blue-800",
"bg-green-100 text-green-800",
"bg-purple-100 text-purple-800",
"bg-red-100 text-red-800",
"bg-orange-100 text-orange-800",
"bg-yellow-100 text-yellow-800",
"bg-gray-100 text-gray-800",
"bg-pink-100 text-pink-800",
]
const newTag = {
id: `custom-${Date.now()}`,
name: customTagInput.trim(),
color: colors[Math.floor(Math.random() * colors.length)],
}
const newTag = customTagInput.trim()
if (customTags.includes(newTag)) return
const updatedCustomTags = [...customTags, newTag]
setCustomTags(updatedCustomTags)
setCustomTagInput("")
onChange({ ...formData, customTags: updatedCustomTags })
}
// 删除自定义标签
const handleRemoveCustomTag = (tagId: string) => {
const updatedCustomTags = customTags.filter((tag) => tag.id !== tagId)
const handleRemoveCustomTag = (tag: string) => {
const updatedCustomTags = customTags.filter((t) => t !== tag)
setCustomTags(updatedCustomTags)
onChange({ ...formData, customTags: updatedCustomTags })
// 同时从选中标签中移除
const updatedSelectedTags = selectedScenarioTags.filter((id) => id !== tagId)
const updatedSelectedTags = selectedScenarioTags.filter((t) => t !== tag)
setSelectedScenarioTags(updatedSelectedTags)
onChange({ ...formData, scenarioTags: updatedSelectedTags, customTags: updatedCustomTags })
}
@@ -271,7 +431,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
onChange({ ...formData, materials: updatedMaterials })
setIsMaterialDialogOpen(false)
// 更新计划名称
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
onChange({ ...formData, planName: `海报${today}`, materials: updatedMaterials })
}
@@ -293,9 +452,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
setIsPreviewOpen(true)
}
// 只显示前三个场景,其他的需要点击展开
const displayedScenarios = showAllScenarios ? scenarios : scenarios.slice(0, 3)
const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
@@ -337,27 +493,296 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
window.URL.revokeObjectURL(url)
}
// 处理电话获客设置更新
const handlePhoneSettingsUpdate = () => {
onChange({ ...formData, phoneSettings })
setIsPhoneSettingsOpen(false)
}
const currentScenario = scenarios.find((s: any) => s.id === formData.scenario);
const handleUploadPoster = () => {
fileInputRef.current?.click()
}
const handlePosterFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast({ title: '请选择图片文件', variant: 'destructive' })
return
}
setUploadingPoster(true)
const formData = new FormData()
formData.append('file', file)
try {
const token = localStorage.getItem('token')
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST',
headers,
body: formData,
})
const result = await response.json()
if (result.code === 200 && result.data?.url) {
const newPoster = {
id: `custom_${Date.now()}`,
name: result.data.name || '自定义海报',
preview: result.data.url,
}
setMaterials(prev => [newPoster, ...prev])
toast({ title: '上传成功', description: '海报已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingPoster(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handleUploadOrder = () => { orderFileInputRef.current?.click() }
const handleUploadDouyin = () => { douyinFileInputRef.current?.click() }
const handleOrderFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingOrder(true)
const formData = new FormData()
formData.append('file', file)
try {
const token = localStorage.getItem('token')
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST', headers, body: formData,
})
const result = await response.json()
if (result.code === 200 && result.data?.url) {
const newItem = { id: `order_${Date.now()}`, name: result.data.name || '自定义订单', preview: result.data.url }
setOrderMaterials(prev => [newItem, ...prev])
toast({ title: '上传成功', description: '订单模板已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingOrder(false)
if (orderFileInputRef.current) orderFileInputRef.current.value = ''
}
}
const handleDouyinFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingDouyin(true)
const formData = new FormData()
formData.append('file', file)
try {
const token = localStorage.getItem('token')
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST', headers, body: formData,
})
const result = await response.json()
if (result.code === 200 && result.data?.url) {
const newItem = { id: `douyin_${Date.now()}`, name: result.data.name || '自定义抖音内容', preview: result.data.url }
setDouyinMaterials(prev => [newItem, ...prev])
toast({ title: '上传成功', description: '抖音内容已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingDouyin(false)
if (douyinFileInputRef.current) douyinFileInputRef.current.value = ''
}
}
// 上传小程序封面
const handleUploadMiniAppCover = () => {
miniAppFileInputRef.current?.click()
}
const handleMiniAppFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingMiniAppCover(true)
const formDataObj = new FormData()
formDataObj.append('file', file)
try {
const token = localStorage.getItem('token')
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST', headers, body: formDataObj,
})
const result = await response.json()
if (result.code === 200 && result.data?.url) {
setMiniAppCover(result.data.url)
onChange({ ...formData, miniAppCover: result.data.url })
toast({ title: '上传成功', description: '小程序封面已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingMiniAppCover(false)
if (miniAppFileInputRef.current) miniAppFileInputRef.current.value = ''
}
}
// 上传链接封面
const handleUploadLinkCover = () => {
linkFileInputRef.current?.click()
}
const handleLinkFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingLinkCover(true)
const formDataObj = new FormData()
formDataObj.append('file', file)
try {
const token = localStorage.getItem('token')
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST', headers, body: formDataObj,
})
const result = await response.json()
if (result.code === 200 && result.data?.url) {
setLinkCover(result.data.url)
onChange({ ...formData, linkCover: result.data.url })
toast({ title: '上传成功', description: '链接封面已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingLinkCover(false)
if (linkFileInputRef.current) linkFileInputRef.current.value = ''
}
}
const renderSceneExtra = () => {
switch (currentScenario?.name) {
case "海报获客":
return (
<PosterSection
materials={materials}
selectedMaterials={selectedMaterials}
onUpload={handleUploadPoster}
onSelect={handleMaterialSelect}
uploading={uploadingPoster}
fileInputRef={fileInputRef}
onFileChange={handlePosterFileChange}
onPreview={handlePreviewImage}
onRemove={handleRemoveMaterial}
/>
)
case "订单获客":
return (
<OrderSection
materials={orderMaterials}
onUpload={handleUploadOrder}
uploading={uploadingOrder}
fileInputRef={orderFileInputRef}
onFileChange={handleOrderFileChange}
/>
)
case "抖音获客":
return (
<DouyinSection
materials={douyinMaterials}
onUpload={handleUploadDouyin}
uploading={uploadingDouyin}
fileInputRef={douyinFileInputRef}
onFileChange={handleDouyinFileChange}
/>
)
case "小红书获客":
return <PlaceholderSection title="小红书内容选择" />
case "电话获客":
return <PlaceholderSection title="电话获客专属设置" />
case "公众号获客":
return <PlaceholderSection title="公众号相关设置" />
case "微信群获客":
return <PlaceholderSection title="微信群管理设置" />
case "付款码获客":
return <PlaceholderSection title="付款码上传与选择" />
case "API获客":
return <PlaceholderSection title="API对接说明或配置" />
case "小程序获客":
return <MiniAppSection />
case "链接获客":
return <LinkSection />
default:
return null
}
}
// 新增小程序和链接场景的功能区
const MiniAppSection = () => (
<div>
<Label></Label>
<div className="flex items-center gap-4 mt-2">
<Button variant="outline" onClick={handleUploadMiniAppCover} disabled={uploadingMiniAppCover}>
<input
type="file"
ref={miniAppFileInputRef}
onChange={handleMiniAppFileChange}
className="hidden"
accept="image/*"
/>
</Button>
{miniAppCover && <img src={miniAppCover} alt="小程序封面" className="h-16 rounded" />}
</div>
</div>
)
const LinkSection = () => (
<div>
<Label></Label>
<div className="flex items-center gap-4 mt-2">
<Button variant="outline" onClick={handleUploadLinkCover} disabled={uploadingLinkCover}>
<input
type="file"
ref={linkFileInputRef}
onChange={handleLinkFileChange}
className="hidden"
accept="image/*"
/>
</Button>
{linkCover && <img src={linkCover} alt="链接封面" className="h-16 rounded" />}
</div>
</div>
)
return (
<TooltipProvider>
<Card className="p-6">
<div className="space-y-6">
<div>
<Label className="text-base mb-4 block"></Label>
<div className="grid grid-cols-3 gap-2">
<div className="mt-2 grid grid-cols-3 gap-3">
{displayedScenarios.map((scenario) => (
<button
key={scenario.id}
className={`p-2 rounded-lg text-center transition-all ${
formData.scenario === scenario.id
? "bg-blue-100 text-blue-600 font-medium"
: "bg-gray-50 text-gray-600 hover:bg-gray-100"
}`}
type="button"
className={
"h-10 rounded-lg text-base transition-all w-full " +
(formData.sceneId === scenario.id
? "bg-blue-100 font-bold"
: "bg-gray-50 text-gray-800 font-medium hover:bg-blue-50")
}
style={formData.sceneId === scenario.id ? { color: "#1677ff" } : {}}
onClick={() => handleScenarioSelect(scenario.id)}
>
{scenario.name.replace("获客", "")}
@@ -372,24 +797,24 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
<div>
<Label htmlFor="planName"></Label>
<Label htmlFor="name" className="text-sm text-gray-600 mb-2 block">
</Label>
<Input
id="planName"
value={formData.planName}
onChange={(e) => onChange({ ...formData, planName: e.target.value })}
id="name"
value={formData.name}
onChange={(e) => onChange({ ...formData, name: e.target.value })}
placeholder="请输入计划名称"
className="mt-2"
className="w-full"
/>
</div>
{/* 场景标签选择 */}
{formData.scenario && (
<div className="mt-6">
<Label className="text-base mb-3 block">
{scenarios.find((s) => s.id === formData.scenario)?.name}
</Label>
{/* 预设标签 */}
<div className="flex flex-wrap gap-2 mb-4">
{(scenarios.find((s) => s.id === formData.scenario)?.scenarioTags || []).map((tag: string) => {
const idx = getTagColorIdx(tag);
@@ -410,29 +835,28 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
})}
</div>
{/* 自定义标签 */}
{customTags.length > 0 && (
<div className="mb-4">
<Label className="text-sm text-gray-600 mb-2 block"></Label>
<div className="flex flex-wrap gap-2">
{customTags.map((tag) => (
<div
key={tag.id}
key={tag}
className={`px-3 py-2 rounded-full text-sm cursor-pointer transition-all relative group ${
selectedScenarioTags.includes(tag.id)
? tag.color + " ring-2 ring-blue-400"
: tag.color + " hover:ring-1 hover:ring-gray-300"
selectedScenarioTags.includes(tag)
? "bg-blue-500 text-white ring-2 ring-blue-400"
: "bg-blue-50 text-blue-600 hover:ring-1 hover:ring-gray-300"
}`}
onClick={() => handleScenarioTagToggle(tag.id)}
onClick={() => handleScenarioTagToggle(tag)}
>
{tag.name}
{tag}
<Button
variant="ghost"
size="sm"
className="absolute -top-1 -right-1 h-5 w-5 p-0 opacity-0 group-hover:opacity-100 bg-red-500 hover:bg-red-600 text-white rounded-full"
onClick={(e) => {
e.stopPropagation()
handleRemoveCustomTag(tag.id)
handleRemoveCustomTag(tag)
}}
>
<X className="h-3 w-3" />
@@ -443,7 +867,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
)}
{/* 添加自定义标签 */}
<div className="flex gap-2">
<Input
value={customTagInput}
@@ -517,7 +940,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
)}
{/* 电话获客特殊设置 */}
{formData.scenario === "phone" && (
<Card className="p-4 border-blue-100 bg-blue-50/50 mt-4">
<div className="flex items-center justify-between mb-3">
@@ -581,7 +1003,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
{formData.scenario === "phone" && (
<>
{/* 添加电话通话类型选择 */}
<div className="mt-6">
<Label className="text-base mb-2 block"></Label>
<div className="grid grid-cols-2 gap-2 mt-2">
@@ -610,7 +1031,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
</div>
{/* 添加标签功能 - 使用从 scenarios 中获取的标签数据 */}
<div className="mt-6">
<Label className="text-base mb-2 block"></Label>
<div className="flex flex-wrap gap-2 mt-2">
@@ -636,77 +1056,11 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</>
)}
{scenarios.find((s: any) => s.id === formData.scenario)?.type === "material" && (
{((currentScenario?.type === "material" || currentScenario?.name === "海报获客" || currentScenario?.id === 1) && (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={() => setIsMaterialDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 海报展示区域 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{materials.map((material) => (
<div
key={material.id}
className={`relative cursor-pointer rounded-lg overflow-hidden group ${
selectedMaterials.find((m) => m.id === material.id)
? "ring-2 ring-blue-600"
: "hover:ring-2 hover:ring-blue-600"
}`}
onClick={() => handleMaterialSelect(material)}
>
<img
src={material.preview || "/placeholder.svg"}
alt={material.name}
className="w-full aspect-[9/16] object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
handlePreviewImage(material.preview)
}}
>
<Maximize2 className="h-4 w-4 text-white" />
</Button>
</div>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{material.name}</div>
</div>
</div>
))}
</div>
{selectedMaterials.length > 0 && (
<div className="mt-4">
<Label></Label>
<div className="mt-2">
<div className="relative w-full max-w-[200px]">
<img
src={selectedMaterials[0].preview || "/placeholder.svg"}
alt={selectedMaterials[0].name}
className="w-full aspect-[9/16] object-cover rounded-lg cursor-pointer"
onClick={() => handlePreviewImage(selectedMaterials[0].preview)}
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => handleRemoveMaterial(selectedMaterials[0].id)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{renderSceneExtra()}
</div>
)}
))}
{scenarios.find((s: any) => s.id === formData.scenario)?.id === "order" && (
<div>
@@ -857,7 +1211,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
</Card>
{/* 账号选择对话框 */}
<Dialog open={isAccountDialogOpen} onOpenChange={setIsAccountDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -883,7 +1236,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</DialogContent>
</Dialog>
{/* 二维码对话框 */}
<Dialog open={isQRCodeOpen} onOpenChange={setIsQRCodeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -898,7 +1250,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</DialogContent>
</Dialog>
{/* 图片预览对话框 */}
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
@@ -910,7 +1261,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</DialogContent>
</Dialog>
{/* 电话获客设置对话框 */}
<Dialog open={isPhoneSettingsOpen} onOpenChange={setIsPhoneSettingsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -977,7 +1327,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</DialogContent>
</Dialog>
{/* 订单导入对话框 */}
<Dialog open={isImportDialogOpen} onOpenChange={setIsImportDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useRef } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -18,6 +18,7 @@ import {
X,
Upload,
Clock,
UploadCloud,
} from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -26,7 +27,7 @@ import { toast } from "@/components/ui/use-toast"
interface MessageContent {
id: string
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group"
content: string
content: string | { url: string, name: string }[]
sendInterval?: number
intervalUnit?: "seconds" | "minutes"
scheduledTime?: {
@@ -40,6 +41,7 @@ interface MessageContent {
coverImage?: string
groupId?: string
linkUrl?: string
cover?: string
}
interface DayPlan {
@@ -90,6 +92,12 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false)
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false)
const [selectedGroupId, setSelectedGroupId] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [uploadTarget, setUploadTarget] = useState<{dayIndex: number, messageIndex: number, type: string} | null>(null)
const coverInputRef = useRef<HTMLInputElement>(null)
const [uploadingCover, setUploadingCover] = useState(false)
const [coverTarget, setCoverTarget] = useState<{dayIndex: number, messageIndex: number} | null>(null)
// 添加新消息
const handleAddMessage = (dayIndex: number, type = "text") => {
@@ -184,11 +192,90 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
// 处理文件上传
const handleFileUpload = (dayIndex: number, messageIndex: number, type: "image" | "video" | "file") => {
// 模拟文件上传
toast({
title: "上传成功",
description: `${type === "image" ? "图片" : type === "video" ? "视频" : "文件"}上传成功`,
})
setUploadTarget({ dayIndex, messageIndex, type })
fileInputRef.current?.setAttribute('accept', type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : '*')
fileInputRef.current?.click()
}
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !uploadTarget) return
setUploading(true)
const formData = new FormData()
formData.append("file", file)
try {
const token = localStorage.getItem('token');
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST',
headers,
body: formData,
})
const result = await response.json()
if (result.code === 200 && result.data?.url) {
if (uploadTarget.type === 'file') {
// 多文件,存对象
const prevFiles = Array.isArray(dayPlans[uploadTarget.dayIndex].messages[uploadTarget.messageIndex].content)
? dayPlans[uploadTarget.dayIndex].messages[uploadTarget.messageIndex].content
: []
handleUpdateMessage(uploadTarget.dayIndex, uploadTarget.messageIndex, { content: [...prevFiles, { url: result.data.url, name: result.data.name || result.data.url.split('/').pop() }] })
} else {
handleUpdateMessage(uploadTarget.dayIndex, uploadTarget.messageIndex, { content: result.data.url })
}
toast({ title: '上传成功', description: `${uploadTarget.type === 'image' ? '图片' : uploadTarget.type === 'video' ? '视频' : '文件'}上传成功` })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploading(false)
setUploadTarget(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handleUploadCover = (dayIndex: number, messageIndex: number) => {
setCoverTarget({ dayIndex, messageIndex })
coverInputRef.current?.click()
}
const handleCoverFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
dayIndex?: number,
messageIndex?: number
) => {
const file = event.target.files?.[0]
if (!file || !coverTarget) return
setUploadingCover(true)
const formData = new FormData()
formData.append("file", file)
try {
const token = localStorage.getItem('token')
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST', headers, body: formData,
})
const result = await response.json()
if (result.code === 200 && result.data?.url) {
handleUpdateMessage(coverTarget.dayIndex, coverTarget.messageIndex, { cover: result.data.url })
toast({ title: '上传成功', description: '封面已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingCover(false)
setCoverTarget(null)
if (coverInputRef.current) coverInputRef.current.value = ''
}
}
const handleRemoveCover = (dayIndex: number, messageIndex: number) => {
handleUpdateMessage(dayIndex, messageIndex, { cover: "" })
}
return (
@@ -353,39 +440,6 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
placeholder="请输入小程序路径"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => handleUpdateMessage(dayIndex, messageIndex, { coverImage: undefined })}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button
variant="outline"
className="w-full h-[120px]"
onClick={() => handleFileUpload(dayIndex, messageIndex, "image")}
>
<Upload className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
@@ -421,39 +475,6 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
placeholder="请输入链接地址"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => handleUpdateMessage(dayIndex, messageIndex, { coverImage: undefined })}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button
variant="outline"
className="w-full h-[120px]"
onClick={() => handleFileUpload(dayIndex, messageIndex, "image")}
>
<Upload className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
@@ -478,10 +499,72 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
variant="outline"
className="w-full h-[120px]"
onClick={() => handleFileUpload(dayIndex, messageIndex, message.type as any)}
disabled={uploading}
>
<Upload className="h-4 w-4 mr-2" />
{message.type === "image" ? "图片" : message.type === "video" ? "视频" : "文件"}
{uploading && uploadTarget && uploadTarget.dayIndex === dayIndex && uploadTarget.messageIndex === messageIndex ? '上传中...' : `上传${message.type === "image" ? "图片" : message.type === "video" ? "视频" : "文件"}`}
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
/>
{/* 文件预览 */}
{message.type === 'image' && message.content && (
<div className="mt-4">
<img src={message.content} alt="图片预览" className="max-h-32 mx-auto rounded-lg border" />
</div>
)}
{message.type === 'video' && message.content && (
<div className="mt-4">
<video src={message.content} controls className="max-h-32 mx-auto rounded-lg border" />
</div>
)}
{message.type === 'file' && Array.isArray(message.content) && message.content.length > 0 && (
<ul className="mt-4 space-y-2 text-left">
{message.content.map((fileObj: {url: string, name: string}, idx: number) => (
<li key={fileObj.url} className="flex items-center gap-2">
<a href={fileObj.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline break-all flex-1">{fileObj.name || fileObj.url.split('/').pop()}</a>
<Button size="icon" variant="ghost" onClick={() => {
const newFiles = message.content.filter((_: any, i: number) => i !== idx)
handleUpdateMessage(dayIndex, messageIndex, { content: newFiles })
}}><X className="h-4 w-4" /></Button>
</li>
))}
</ul>
)}
</div>
)}
{(message.type === "miniprogram" || message.type === "link") && (
<div className="mt-4">
<Label></Label>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.cover ? (
<div className="flex flex-col items-center">
<img src={message.cover} alt="封面" className="h-24 rounded mb-2" />
<Button size="sm" onClick={() => handleRemoveCover(dayIndex, messageIndex)}></Button>
</div>
) : (
<Button
variant="outline"
className="w-full h-[100px] flex flex-col items-center justify-center"
onClick={() => handleUploadCover(dayIndex, messageIndex)}
disabled={uploadingCover}
>
<UploadCloud className="h-8 w-8 mb-2" />
</Button>
)}
<input
type="file"
ref={coverInputRef}
onChange={(e) => handleCoverFileChange(e, dayIndex, messageIndex)}
className="hidden"
accept="image/*"
/>
</div>
</div>
)}
</div>

View File

@@ -1,209 +0,0 @@
"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>
)
}

View File

@@ -401,8 +401,8 @@ class WebSocketController extends BaseController
"cmdType" => "CmdMomentCancelInteract",
"optType" => 1,
"seq" => time(),
"snsId" => $data['snsId'],
"wechatAccountId" => $data['wechatAccountId'],
"snsId" => $data['snsId'],
"wechatAccountId" => $data['wechatAccountId'],
"wechatFriendId" => 0,
];
@@ -794,4 +794,57 @@ class WebSocketController extends BaseController
return json_encode(['code'=>200,'msg'=>$msg,'data'=>$message]);
}
/**
* 邀请好友入群
* @param array $data 请求参数
* @return string JSON响应
*/
public function CmdChatroomInvite($data = [])
{
try {
// 参数验证
if (empty($data)) {
return json_encode(['code' => 400, 'msg' => '参数缺失']);
}
// 验证必要参数
if (empty($data['wechatChatroomId'])) {
return json_encode(['code' => 400, 'msg' => '群ID不能为空']);
}
if (empty($data['wechatFriendId'])) {
return json_encode(['code' => 400, 'msg' => '好友ID不能为空']);
}
if (!is_array($data['wechatFriendId'])) {
return json_encode(['code' => 400, 'msg' => '好友数据格式必须为数组']);
}
if (empty($data['wechatAccountId'])) {
return json_encode(['code' => 400, 'msg' => '微信账号ID不能为空']);
}
// 构建请求参数
$params = [
"cmdType" => "CmdChatroomInvite",
"seq" => time(),
"wechatChatroomId" => $data['wechatChatroomId'],
"wechatFriendId" => $data['wechatFriendId'],
"wechatAccountId" => $data['wechatAccountId']
];
// 记录请求日志
Log::info('邀请好友入群请求:' . json_encode($params, 256));
$message = $this->sendMessage($params);
return json_encode(['code'=>200,'msg'=>'邀请成功','data'=>$message]);
} catch (\Exception $e) {
// 记录错误日志
Log::error('邀请好友入群异常:' . $e->getMessage());
// 返回错误响应
return json_encode(['code' => 500, 'msg' => '邀请好友入群异常:' . $e->getMessage()]);
}
}
}

View File

@@ -39,10 +39,10 @@ Route::group('v1/', function () {
Route::get('scenes', 'app\cunkebao\controller\plan\GetPlanSceneListV1Controller@index');
Route::get('scenes-detail', 'app\cunkebao\controller\plan\GetPlanSceneListV1Controller@detail');
Route::post('create', 'app\cunkebao\controller\plan\PostCreateAddFriendPlanV1Controller@index');
Route::get('list', 'app\cunkebao\controller\Plan@getList');
Route::get('list', 'app\cunkebao\controller\plan\PlanSceneV1Controller@index');
Route::get('copy', 'app\cunkebao\controller\plan\PlanSceneV1Controller@copy');
Route::delete('delete', 'app\cunkebao\controller\plan\PlanSceneV1Controller@delete');
Route::post('updateStatus', 'app\cunkebao\controller\plan\PlanSceneV1Controller@updateStatus');
});
// 流量池相关

View File

@@ -0,0 +1,135 @@
<?php
namespace app\cunkebao\controller\plan;
use library\ResponseHelper;
use think\Db;
use app\cunkebao\controller\BaseController;
/**
* 获取计划任务列表控制器
*/
class GetCreateAddFriendPlanV1Controller extends BaseController
{
/**
* 生成唯一API密钥
*
* @return string
*/
private function generateApiKey()
{
// 生成6组随机字符串每组5个字符
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$apiKey = '';
for ($i = 0; $i < 6; $i++) {
$segment = '';
for ($j = 0; $j < 5; $j++) {
$segment .= $chars[mt_rand(0, strlen($chars) - 1)];
}
$apiKey .= ($i > 0 ? '-' : '') . $segment;
}
// 检查是否已存在
$exists = Db::name('customer_acquisition_task')
->where('apiKey', $apiKey)
->find();
if ($exists) {
// 如果已存在,递归重新生成
return $this->generateApiKey();
}
return $apiKey;
}
/**
* 拷贝计划任务
*
* @return \think\response\Json
*/
public function copy()
{
try {
$params = $this->request->param();
$planId = isset($params['planId']) ? intval($params['planId']) : 0;
if ($planId <= 0) {
return ResponseHelper::error('计划ID不能为空', 400);
}
$plan = Db::name('customer_acquisition_task')->where('id', $planId)->find();
if (!$plan) {
return ResponseHelper::error('计划不存在', 404);
}
unset($plan['id']);
$plan['name'] = $plan['name'] . ' (拷贝)';
$plan['createTime'] = time();
$plan['updateTime'] = time();
$plan['apiKey'] = $this->generateApiKey(); // 生成新的API密钥
$newPlanId = Db::name('customer_acquisition_task')->insertGetId($plan);
if (!$newPlanId) {
return ResponseHelper::error('拷贝计划失败', 500);
}
return ResponseHelper::success(['planId' => $newPlanId], '拷贝计划任务成功');
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}
}
/**
* 删除计划任务
*
* @return \think\response\Json
*/
public function delete()
{
try {
$params = $this->request->param();
$planId = isset($params['planId']) ? intval($params['planId']) : 0;
if ($planId <= 0) {
return ResponseHelper::error('计划ID不能为空', 400);
}
$result = Db::name('customer_acquisition_task')->where('id', $planId)->update(['deleteTime' => time()]);
if (!$result) {
return ResponseHelper::error('删除计划失败', 500);
}
return ResponseHelper::success([], '删除计划任务成功');
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}
}
/**
* 修改计划任务状态
*
* @return \think\response\Json
*/
public function updateStatus()
{
try {
$params = $this->request->param();
$planId = isset($params['planId']) ? intval($params['planId']) : 0;
$status = isset($params['status']) ? intval($params['status']) : 0;
if ($planId <= 0) {
return ResponseHelper::error('计划ID不能为空', 400);
}
$result = Db::name('customer_acquisition_task')->where('id', $planId)->update(['status' => $status, 'updateTime' => time()]);
if (!$result) {
return ResponseHelper::error('修改计划状态失败', 500);
}
return ResponseHelper::success([], '修改计划任务状态成功');
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}
}
}

View File

@@ -15,19 +15,48 @@ class GetPlanSceneListV1Controller extends BaseController
/**
* 获取开启的场景列表
*
* @param array $params 查询参数
* @return array
*/
protected function getSceneList(): array
protected function getSceneList(array $params = []): array
{
$list = PlansSceneModel::where(['status' => PlansSceneModel::STATUS_ACTIVE])->order('sort desc')->select()->toArray();
$userInfo = $this->getUserInfo();
foreach($list as &$val){
$val['scenarioTags'] = json_decode($val['scenarioTags'],true);
$val['count'] = 0;
$val['growth'] = "0%";
try {
// 构建查询条件
$where = ['status' => PlansSceneModel::STATUS_ACTIVE];
// 搜索条件
if (!empty($params['keyword'])) {
$where[] = ['name', 'like', '%' . $params['keyword'] . '%'];
}
// 标签筛选
if (!empty($params['tag'])) {
$where[] = ['scenarioTags', 'like', '%' . $params['tag'] . '%'];
}
// 查询数据
$query = PlansSceneModel::where($where);
// 获取总数
$total = $query->count();
// 获取分页数据
$list = $query->order('sort DESC')->select()->toArray();
// 处理数据
foreach($list as &$val) {
$val['scenarioTags'] = json_decode($val['scenarioTags'], true);
$val['count'] = $this->getPlanCount($val['id']);
$val['growth'] = $this->calculateGrowth($val['id']);
}
unset($val);
return $list;
} catch (\Exception $e) {
throw new \Exception('获取场景列表失败:' . $e->getMessage());
}
unset($val);
return $list;
}
/**
@@ -37,31 +66,90 @@ class GetPlanSceneListV1Controller extends BaseController
*/
public function index()
{
return ResponseHelper::success(
$this->getSceneList()
);
try {
$params = $this->request->param();
$result = $this->getSceneList($params);
return ResponseHelper::success($result);
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), 500);
}
}
/**
* 获取场景详情
*
* @return \think\response\Json
*/
public function detail()
{
$id = $this->request->param('id','');
if(empty($id)){
ResponseHelper::error('参数缺失');
}
try {
$id = $this->request->param('id', '');
if(empty($id)) {
return ResponseHelper::error('参数缺失');
}
$data = PlansSceneModel::where(['status' => PlansSceneModel::STATUS_ACTIVE,'id' => $id])->find();
if(empty($data)){
ResponseHelper::error('场景不存在');
}
$data = PlansSceneModel::where([
'status' => PlansSceneModel::STATUS_ACTIVE,
'id' => $id
])->find();
if(empty($data)) {
return ResponseHelper::error('场景不存在');
}
$data['scenarioTags'] = json_decode($data['scenarioTags'],true);
return ResponseHelper::success($data);
$data['scenarioTags'] = json_decode($data['scenarioTags'], true);
$data['count'] = $this->getPlanCount($id);
$data['growth'] = $this->calculateGrowth($id);
return ResponseHelper::success($data);
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), 500);
}
}
/**
* 获取计划数量
*
* @param int $sceneId 场景ID
* @return int
*/
private function getPlanCount(int $sceneId): int
{
return Db::name('customer_acquisition_task')
->where('sceneId', $sceneId)
->where('status', 1)
->count();
}
/**
* 计算增长率
*
* @param int $sceneId 场景ID
* @return string
*/
private function calculateGrowth(int $sceneId): string
{
// 获取本月和上月的计划数量
$currentMonth = Db::name('customer_acquisition_task')
->where('sceneId', $sceneId)
->where('status', 1)
->whereTime('createTime', '>=', strtotime(date('Y-m-01')))
->count();
$lastMonth = Db::name('customer_acquisition_task')
->where('sceneId', $sceneId)
->where('status', 1)
->whereTime('createTime', 'between', [
strtotime(date('Y-m-01', strtotime('-1 month'))),
strtotime(date('Y-m-01')) - 1
])
->count();
if ($lastMonth == 0) {
return $currentMonth > 0 ? '100%' : '0%';
}
$growth = round(($currentMonth - $lastMonth) / $lastMonth * 100, 2);
return $growth . '%';
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace app\cunkebao\controller\plan;
use library\ResponseHelper;
use think\Db;
use app\cunkebao\controller\BaseController;
/**
* 获取计划任务列表控制器
*/
class PlanSceneV1Controller extends BaseController
{
/**
* 获取计划任务列表
*
* @return \think\response\Json
*/
public function index()
{
try {
$params = $this->request->param();
$page = isset($params['page']) ? intval($params['page']) : 1;
$limit = isset($params['limit']) ? intval($params['limit']) : 10;
$sceneId = $this->request->param('sceneId','');
$where = [
'deleteTime' => 0,
'companyId' => $this->getUserInfo('companyId'),
];
if($this->getUserInfo('isAdmin')){
$where['userId'] = $this->getUserInfo('id');
}
if(!empty($sceneId)){
$where['sceneId'] = $sceneId;
}
$total = Db::name('customer_acquisition_task')->where($where)->count();
$list = Db::name('customer_acquisition_task')
->where($where)
->order('createTime', 'desc')
->page($page, $limit)
->select();
foreach($list as &$val){
$val['createTime'] = date('Y-m-d H:i:s', $val['createTime']);
$val['updateTime'] = date('Y-m-d H:i:s', $val['updateTime']);
$val['sceneConf'] = json_decode($val['sceneConf'],true);
$val['reqConf'] = json_decode($val['reqConf'],true);
$val['msgConf'] = json_decode($val['msgConf'],true);
$val['tagConf'] = json_decode($val['tagConf'],true);
}
unset($val);
return ResponseHelper::success([
'total' => $total,
'list' => $list
], '获取计划任务列表成功');
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}
}
/**
* 拷贝计划任务
*
* @return \think\response\Json
*/
public function copy()
{
try {
$params = $this->request->param();
$planId = isset($params['planId']) ? intval($params['planId']) : 0;
if ($planId <= 0) {
return ResponseHelper::error('计划ID不能为空', 400);
}
$plan = Db::name('customer_acquisition_task')->where('id', $planId)->find();
if (!$plan) {
return ResponseHelper::error('计划不存在', 404);
}
unset($plan['id']);
$plan['name'] = $plan['name'] . ' (拷贝)';
$plan['createTime'] = time();
$plan['updateTime'] = time();
$newPlanId = Db::name('customer_acquisition_task')->insertGetId($plan);
if (!$newPlanId) {
return ResponseHelper::error('拷贝计划失败', 500);
}
return ResponseHelper::success(['planId' => $newPlanId], '拷贝计划任务成功');
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}
}
/**
* 删除计划任务
*
* @return \think\response\Json
*/
public function delete()
{
try {
$params = $this->request->param();
$planId = isset($params['planId']) ? intval($params['planId']) : 0;
if ($planId <= 0) {
return ResponseHelper::error('计划ID不能为空', 400);
}
$result = Db::name('customer_acquisition_task')->where('id', $planId)->update(['deleteTime' => time()]);
if (!$result) {
return ResponseHelper::error('删除计划失败', 500);
}
return ResponseHelper::success([], '删除计划任务成功');
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}
}
/**
* 修改计划任务状态
*
* @return \think\response\Json
*/
public function updateStatus()
{
try {
$params = $this->request->param();
$planId = isset($params['planId']) ? intval($params['planId']) : 0;
$status = isset($params['status']) ? intval($params['status']) : 0;
if ($planId <= 0) {
return ResponseHelper::error('计划ID不能为空', 400);
}
$result = Db::name('customer_acquisition_task')->where('id', $planId)->update(['status' => $status, 'updateTime' => time()]);
if (!$result) {
return ResponseHelper::error('修改计划状态失败', 500);
}
return ResponseHelper::success([], '修改计划任务状态成功');
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}
}
}

View File

@@ -18,6 +18,37 @@ class PostCreateAddFriendPlanV1Controller extends Controller
}
/**
* 生成唯一API密钥
*
* @return string
*/
private function generateApiKey()
{
// 生成5组随机字符串每组5个字符
$chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
$apiKey = '';
for ($i = 0; $i < 5; $i++) {
$segment = '';
for ($j = 0; $j < 5; $j++) {
$segment .= $chars[mt_rand(0, strlen($chars) - 1)];
}
$apiKey .= ($i > 0 ? '-' : '') . $segment;
}
// 检查是否已存在
$exists = Db::name('customer_acquisition_task')
->where('apiKey', $apiKey)
->find();
if ($exists) {
// 如果已存在,递归重新生成
return $this->generateApiKey();
}
return $apiKey;
}
/**
* 添加计划任务
@@ -27,49 +58,91 @@ class PostCreateAddFriendPlanV1Controller extends Controller
public function index()
{
try {
$params = $this->request->only(['name', 'sceneId', 'status', 'reqConf', 'msgConf', 'tagConf']);
dd(
json_decode($params['reqConf']),
json_decode($params['tagConf']),
json_decode($params['msgConf'])
);
$params = $this->request->param();
// 验证必填字段
if (empty($data['name'])) {
if (empty($params['planName'])) {
return ResponseHelper::error('计划名称不能为空', 400);
}
if (empty($data['sceneId'])) {
if (empty($params['scenario'])) {
return ResponseHelper::error('场景ID不能为空', 400);
}
// 验证数据格式
if (!$this->validateJson($data['reqConf'])) {
return ResponseHelper::error('好友申请设置格式不正确', 400);
if (empty($params['device'])) {
return ResponseHelper::error('请选择设备', 400);
}
if (!$this->validateJson($data['msgConf'])) {
return ResponseHelper::error('消息设置格式不正确', 400);
}
if (!$this->validateJson($data['tagConf'])) {
return ResponseHelper::error('标签设置格式不正确', 400);
}
// 插入数据库 customer_acquisition_task
$result = Db::name('friend_plan')->insert($data);
if ($result) {
return ResponseHelper::success([], '添加计划任务成功');
} else {
return ResponseHelper::error('添加计划任务失败', 500);
// 归类参数
$msgConf = isset($params['messagePlans']) ? $params['messagePlans'] : [];
$tagConf = [
'scenarioTags' => $params['scenarioTags'] ?? [],
'customTags' => $params['customTags'] ?? [],
];
$reqConf = [
'device' => $params['device'] ?? [],
'remarkType' => $params['remarkType'] ?? '',
'greeting' => $params['greeting'] ?? '',
'addFriendInterval' => $params['addFriendInterval'] ?? '',
'startTime' => $params['startTime'] ?? '',
'endTime' => $params['endTime'] ?? '',
];
// 其余参数归为sceneConf
$sceneConf = $params;
unset(
$sceneConf['planName'],
$sceneConf['scenario'],
$sceneConf['messagePlans'],
$sceneConf['scenarioTags'],
$sceneConf['customTags'],
$sceneConf['device'],
$sceneConf['remarkType'],
$sceneConf['greeting'],
$sceneConf['addFriendInterval'],
$sceneConf['startTime'],
$sceneConf['endTime']
);
// 构建数据
$data = [
'name' => $params['planName'],
'sceneId' => $params['scenario'],
'sceneConf' => json_encode($sceneConf, JSON_UNESCAPED_UNICODE),
'reqConf' => json_encode($reqConf, JSON_UNESCAPED_UNICODE),
'msgConf' => json_encode($msgConf, JSON_UNESCAPED_UNICODE),
'tagConf' => json_encode($tagConf, JSON_UNESCAPED_UNICODE),
'userId' => $params['userInfo']['id'] ?? 0,
'companyId' => $params['userInfo']['companyId'] ?? 0,
'status' => 1,
'apiKey' => $this->generateApiKey(), // 生成API密钥
'createTime'=> time(),
'updateTime'=> time(),
];
// 开启事务
Db::startTrans();
try {
// 插入数据
$planId = Db::name('customer_acquisition_task')->insertGetId($data);
if (!$planId) {
throw new \Exception('添加计划失败');
}
// 提交事务
Db::commit();
return ResponseHelper::success(['planId' => $planId], '添加计划任务成功');
} catch (\Exception $e) {
// 回滚事务
Db::rollback();
throw $e;
}
} catch (\Exception $e) {
return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500);
}