流量分发列表 + 添加 + 修改状态功能提交
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
// 流量池相关
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 . '%';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user