diff --git a/Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx b/Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx index df5138b1..916b4c52 100644 --- a/Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx +++ b/Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx @@ -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(null) + const isActive = task.status === 1; + const handleStatusChange = (e: React.MouseEvent) => { e.stopPropagation() if (onStatusChange) { @@ -103,11 +117,11 @@ export function ScenarioAcquisitionCard({

{task.name}

- {task.status === "running" ? "进行中" : "已暂停"} + {isActive ? "进行中" : "已暂停"}
diff --git a/Cunkebao/app/scenarios/[channel]/edit/[id]/page.tsx b/Cunkebao/app/scenarios/[channel]/edit/[id]/page.tsx index 2feca4b8..531ad57f 100644 --- a/Cunkebao/app/scenarios/[channel]/edit/[id]/page.tsx +++ b/Cunkebao/app/scenarios/[channel]/edit/[id]/page.tsx @@ -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([]) 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(`/v1/plan/detail?id=${params.id}`), + api.get("/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(`/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 + return case 2: return ( @@ -178,7 +182,7 @@ export default function EditAcquisitionPlan({ params }: { params: { channel: str
-

编辑获客计划

diff --git a/Cunkebao/app/scenarios/[channel]/page.tsx b/Cunkebao/app/scenarios/[channel]/page.tsx index a4306a44..a91958ae 100644 --- a/Cunkebao/app/scenarios/[channel]/page.tsx +++ b/Cunkebao/app/scenarios/[channel]/page.tsx @@ -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 = { - 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("") - 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([]) + 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(initialTasks) + useEffect(() => { + api.get(`/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(`/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({ 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(`/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(`/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() { -

{channelName}获客

+

{channelName}

- {tasks.length > 0 ? ( - tasks.map((task) => ( -
- handleEditPlan(task.id)} - onCopy={handleCopyPlan} - onDelete={handleDeletePlan} - onStatusChange={handleStatusChange} - onOpenSettings={handleOpenApiSettings} - /> + {loading ? ( +
加载中...
+ ) : error ? ( +
{error}
+ ) : tasks.length > 0 ? ( + <> + {tasks.map((task) => ( +
+ handleEditPlan(task.id)} + onCopy={handleCopyPlan} + onDelete={handleDeletePlan} + onStatusChange={handleStatusChange} + onOpenSettings={handleOpenApiSettings} + /> +
+ ))} +
+ + 第 {page} 页 / 共 {Math.max(1, Math.ceil(total / pageSize))} 页 +
- )) + ) : (
暂无获客计划
)} diff --git a/Cunkebao/app/scenarios/new/page.tsx b/Cunkebao/app/scenarios/new/page.tsx index 024c9c50..3d33bbf9 100644 --- a/Cunkebao/app/scenarios/new/page.tsx +++ b/Cunkebao/app/scenarios/new/page.tsx @@ -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("/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() {
-

新建获客计划

diff --git a/Cunkebao/app/scenarios/new/steps/BasicSettings.tsx b/Cunkebao/app/scenarios/new/steps/BasicSettings.tsx index 8c191553..ef2220ab 100644 --- a/Cunkebao/app/scenarios/new/steps/BasicSettings.tsx +++ b/Cunkebao/app/scenarios/new/steps/BasicSettings.tsx @@ -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 }) => ( +
+
+ + +
+
+ {materials.map((material) => ( +
m.id === material.id) + ? "ring-2 ring-blue-600" + : "hover:ring-2 hover:ring-blue-600" + }`} + onClick={() => onSelect(material)} + > + {material.name} +
+ +
+
+
{material.name}
+
+
+ ))} +
+ {selectedMaterials.length > 0 && ( +
+ +
+ {selectedMaterials[0].name} onPreview(selectedMaterials[0].preview)} + /> + +
+
+ )} +
+) + +const OrderSection = ({ materials, onUpload, uploading, fileInputRef, onFileChange }) => ( +
+
+ + +
+
+ {materials.map((item) => ( +
+ {item.name} +
+
{item.name}
+
+
+ ))} +
+
+) + +const DouyinSection = ({ materials, onUpload, uploading, fileInputRef, onFileChange }) => ( +
+
+ + +
+
+ {materials.map((item) => ( +
+ {item.name} +
+
{item.name}
+
+
+ ))} +
+
+) + +const PlaceholderSection = ({ title }) => ( +
{title}功能区待开发
+) + 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(generateRandomAccounts(50)) - const [materials] = useState(generatePosterMaterials()) + const [materials, setMaterials] = useState(generatePosterMaterials()) const [selectedAccounts, setSelectedAccounts] = useState( formData.accounts?.length > 0 ? formData.accounts : [], ) @@ -154,9 +291,7 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe const [selectedScenarioTags, setSelectedScenarioTags] = useState(formData.scenarioTags || []) const [customTagInput, setCustomTagInput] = useState("") - const [customTags, setCustomTags] = useState>( - formData.customTags || [], - ) + const [customTags, setCustomTags] = useState(formData.customTags || []) // 初始化电话获客设置 const [phoneSettings, setPhoneSettings] = useState({ @@ -168,7 +303,55 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe const [selectedPhoneTags, setSelectedPhoneTags] = useState(formData.phoneTags || []) const [phoneCallType, setPhoneCallType] = useState(formData.phoneCallType || "both") - // 处理标签选择 (现在处理的是字符串标签) + const fileInputRef = useRef(null) + const [uploadingPoster, setUploadingPoster] = useState(false) + + // 新增不同场景的materials和上传逻辑 + const [orderMaterials, setOrderMaterials] = useState([]) + const [douyinMaterials, setDouyinMaterials] = useState([]) + const orderFileInputRef = useRef(null) + const douyinFileInputRef = useRef(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(null) + + const [linkCover, setLinkCover] = useState(formData.linkCover || "") + const [uploadingLinkCover, setUploadingLinkCover] = useState(false) + const linkFileInputRef = useRef(null) + + const searchParams = useSearchParams() + const type = searchParams.get("type") + // 类型映射表 + const typeMap: Record = { + 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) => { 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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 ( + + ) + case "订单获客": + return ( + + ) + case "抖音获客": + return ( + + ) + case "小红书获客": + return + case "电话获客": + return + case "公众号获客": + return + case "微信群获客": + return + case "付款码获客": + return + case "API获客": + return + case "小程序获客": + return + case "链接获客": + return + default: + return null + } + } + + // 新增小程序和链接场景的功能区 + const MiniAppSection = () => ( +
+ +
+ + {miniAppCover && 小程序封面} +
+
+ ) + + const LinkSection = () => ( +
+ +
+ + {linkCover && 链接封面} +
+
+ ) + return (
-
+
{displayedScenarios.map((scenario) => (
- + 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" />
- {/* 场景标签选择 */} {formData.scenario && (
- {/* 预设标签 */}
{(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 })}
- {/* 自定义标签 */} {customTags.length > 0 && (
{customTags.map((tag) => (
handleScenarioTagToggle(tag.id)} + onClick={() => handleScenarioTagToggle(tag)} > - {tag.name} + {tag}
)} - {/* 添加自定义标签 */}
)} - {/* 电话获客特殊设置 */} {formData.scenario === "phone" && (
@@ -581,7 +1003,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe {formData.scenario === "phone" && ( <> - {/* 添加电话通话类型选择 */}
@@ -610,7 +1031,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
- {/* 添加标签功能 - 使用从 scenarios 中获取的标签数据 */}
@@ -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) && (
-
- - -
- - {/* 海报展示区域 */} -
- {materials.map((material) => ( -
m.id === material.id) - ? "ring-2 ring-blue-600" - : "hover:ring-2 hover:ring-blue-600" - }`} - onClick={() => handleMaterialSelect(material)} - > - {material.name} -
- -
-
-
{material.name}
-
-
- ))} -
- - {selectedMaterials.length > 0 && ( -
- -
-
- {selectedMaterials[0].name} handlePreviewImage(selectedMaterials[0].preview)} - /> - -
-
-
- )} + {renderSceneExtra()}
- )} + ))} {scenarios.find((s: any) => s.id === formData.scenario)?.id === "order" && (
@@ -857,7 +1211,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
- {/* 账号选择对话框 */} @@ -883,7 +1236,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe - {/* 二维码对话框 */} @@ -898,7 +1250,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe - {/* 图片预览对话框 */} @@ -910,7 +1261,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe - {/* 电话获客设置对话框 */} @@ -977,7 +1327,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe - {/* 订单导入对话框 */} diff --git a/Cunkebao/app/scenarios/new/steps/DeviceSelection b/Cunkebao/app/scenarios/new/steps/DeviceSelection deleted file mode 100644 index 139597f9..00000000 --- a/Cunkebao/app/scenarios/new/steps/DeviceSelection +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/Cunkebao/app/scenarios/new/steps/MessageSettings.tsx b/Cunkebao/app/scenarios/new/steps/MessageSettings.tsx index de80ecd3..800d5fb7 100644 --- a/Cunkebao/app/scenarios/new/steps/MessageSettings.tsx +++ b/Cunkebao/app/scenarios/new/steps/MessageSettings.tsx @@ -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(null) + const [uploading, setUploading] = useState(false) + const [uploadTarget, setUploadTarget] = useState<{dayIndex: number, messageIndex: number, type: string} | null>(null) + const coverInputRef = useRef(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) => { + 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, + 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="请输入小程序路径" />
-
- -
- {message.coverImage ? ( -
- 封面 - -
- ) : ( - - )} -
-
)} @@ -421,39 +475,6 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS placeholder="请输入链接地址" />
-
- -
- {message.coverImage ? ( -
- 封面 - -
- ) : ( - - )} -
-
)} @@ -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} > - 上传{message.type === "image" ? "图片" : message.type === "video" ? "视频" : "文件"} + {uploading && uploadTarget && uploadTarget.dayIndex === dayIndex && uploadTarget.messageIndex === messageIndex ? '上传中...' : `上传${message.type === "image" ? "图片" : message.type === "video" ? "视频" : "文件"}`} + + {/* 文件预览 */} + {message.type === 'image' && message.content && ( +
+ 图片预览 +
+ )} + {message.type === 'video' && message.content && ( +
+
+ )} + {message.type === 'file' && Array.isArray(message.content) && message.content.length > 0 && ( +
    + {message.content.map((fileObj: {url: string, name: string}, idx: number) => ( +
  • + {fileObj.name || fileObj.url.split('/').pop()} + +
  • + ))} +
+ )} +
+ )} + + {(message.type === "miniprogram" || message.type === "link") && ( +
+ +
+ {message.cover ? ( +
+ 封面 + +
+ ) : ( + + )} + handleCoverFileChange(e, dayIndex, messageIndex)} + className="hidden" + accept="image/*" + /> +
)}
diff --git a/Cunkebao/app/scenarios/new/steps/TrafficChannelSettings b/Cunkebao/app/scenarios/new/steps/TrafficChannelSettings deleted file mode 100644 index 0dd41b5e..00000000 --- a/Cunkebao/app/scenarios/new/steps/TrafficChannelSettings +++ /dev/null @@ -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(formData.channels || []) - const [isAddChannelOpen, setIsAddChannelOpen] = useState(false) - const [editingChannel, setEditingChannel] = useState(null) - const [newChannel, setNewChannel] = useState>({ - 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 ( - -
-
-

流量通道设置

- -
- -
- - - - 通道名称 - 类型 - 链接 - 操作 - - - - {channels.length === 0 ? ( - - - 暂无数据 - - - ) : ( - channels.map((channel) => ( - - {channel.name} - {channel.type === "team" ? "打粉团队" : "其他"} - - {channel.link && ( -
- - {channel.link} -
- )} -
- -
- - -
-
-
- )) - )} -
-
-
- -
- - -
-
- - - - - {editingChannel ? "编辑通道" : "添加通道"} - -
-
- - setNewChannel({ ...newChannel, name: e.target.value })} - placeholder="请输入通道名称" - /> -
-
- - -
-
- - setNewChannel({ ...newChannel, link: e.target.value })} - placeholder="请输入通道链接" - /> - {newChannel.link && !isValidUrl(newChannel.link) && ( -

请输入有效的URL

- )} -
-
- - - - -
-
-
- ) -} diff --git a/Server/application/api/controller/WebSocketController.php b/Server/application/api/controller/WebSocketController.php index 249bfe75..a0891051 100644 --- a/Server/application/api/controller/WebSocketController.php +++ b/Server/application/api/controller/WebSocketController.php @@ -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()]); + } + } } \ No newline at end of file diff --git a/Server/application/cunkebao/config/route.php b/Server/application/cunkebao/config/route.php index 18c43310..e7094ff0 100644 --- a/Server/application/cunkebao/config/route.php +++ b/Server/application/cunkebao/config/route.php @@ -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'); }); // 流量池相关 diff --git a/Server/application/cunkebao/controller/plan/GetCreateAddFriendPlanV1Controller.php b/Server/application/cunkebao/controller/plan/GetCreateAddFriendPlanV1Controller.php new file mode 100644 index 00000000..6941a8d2 --- /dev/null +++ b/Server/application/cunkebao/controller/plan/GetCreateAddFriendPlanV1Controller.php @@ -0,0 +1,135 @@ + 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); + } + } +} \ No newline at end of file diff --git a/Server/application/cunkebao/controller/plan/GetPlanSceneListV1Controller.php b/Server/application/cunkebao/controller/plan/GetPlanSceneListV1Controller.php index 64df2445..b47a59be 100644 --- a/Server/application/cunkebao/controller/plan/GetPlanSceneListV1Controller.php +++ b/Server/application/cunkebao/controller/plan/GetPlanSceneListV1Controller.php @@ -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 . '%'; + } } \ No newline at end of file diff --git a/Server/application/cunkebao/controller/plan/PlanSceneV1Controller.php b/Server/application/cunkebao/controller/plan/PlanSceneV1Controller.php new file mode 100644 index 00000000..136a3bcc --- /dev/null +++ b/Server/application/cunkebao/controller/plan/PlanSceneV1Controller.php @@ -0,0 +1,155 @@ +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); + } + } +} \ No newline at end of file diff --git a/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php b/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php index fa0cb1a9..c716ed26 100644 --- a/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php +++ b/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php @@ -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); }