From 15b899b0e8df774f092734645c4da3ab2a558de7 Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Sun, 6 Jul 2025 00:17:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cunkebao/app/content/[id]/materials/page.tsx | 17 +- .../app/scenarios/[channel]/devices/page.tsx | 180 +++++++++-- .../app/scenarios/new/steps/BasicSettings.tsx | 214 +++++++------ .../app/workspace/moments-sync/[id]/page.tsx | 70 +---- Server/application/cunkebao/config/route.php | 2 +- .../controller/WorkbenchController.php | 45 ++- .../controller/plan/PlanSceneV1Controller.php | 292 +++++++++++++++--- .../PostCreateAddFriendPlanV1Controller.php | 6 +- .../Adapters/ChuKeBao/Adapter.php | 10 +- 9 files changed, 606 insertions(+), 230 deletions(-) diff --git a/Cunkebao/app/content/[id]/materials/page.tsx b/Cunkebao/app/content/[id]/materials/page.tsx index 13ac8da8..f96c97a1 100644 --- a/Cunkebao/app/content/[id]/materials/page.tsx +++ b/Cunkebao/app/content/[id]/materials/page.tsx @@ -153,22 +153,28 @@ export default function MaterialsPage({ params }: { params: Promise<{ id: string href={first.url} target="_blank" rel="noopener noreferrer" - className="flex items-center bg-white rounded p-2 hover:bg-gray-50 transition group" + className=" items-center bg-white rounded p-2 hover:bg-gray-50 transition group" style={{ textDecoration: 'none' }} > - {first.image && ( +
+

{material.content}

+
+ + +
封面图
- )} +
-
{first.desc}
+
{first.desc ?? '这是一条链接'}
+
); @@ -179,6 +185,7 @@ export default function MaterialsPage({ params }: { params: Promise<{ id: string const videoUrl = typeof first === "string" ? first : (first.url || ""); return videoUrl ? (
+

{material.content}

) : null; diff --git a/Cunkebao/app/scenarios/[channel]/devices/page.tsx b/Cunkebao/app/scenarios/[channel]/devices/page.tsx index 735bce48..1972f3d2 100644 --- a/Cunkebao/app/scenarios/[channel]/devices/page.tsx +++ b/Cunkebao/app/scenarios/[channel]/devices/page.tsx @@ -2,13 +2,53 @@ import { useState } from "react" import { useRouter } from "next/navigation" -import { DeviceSelector } from "@/app/components/common/DeviceSelector" import { Button } from "@/components/ui/button" -import { ChevronLeft } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { DeviceSelectionDialog } from "@/app/components/device-selection-dialog" +import { ChevronLeft, Trash2 } from "lucide-react" + +interface WechatAccount { + avatar: string + nickname: string + wechatId: string +} +interface Device { + id: string + imei: string + remark?: string + wechatAccounts: WechatAccount[] + online: boolean + friendStatus: "正常" | "异常" +} + +// mock 设备数据 +const mockDevices: Device[] = [ + { + id: "aa6d4c2f7b1fe24d04d34f4f409883e6", + imei: "aa6d4c2f7b1fe24d04d34f4f409883e6", + remark: "游戏2 19445", + wechatAccounts: [ + { + avatar: "https://img2.baidu.com/it/u=123456789,123456789&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500", + nickname: "老钟爹-解放双手,释放时间", + wechatId: "wxid_480es52qsj2812" + }, + { + avatar: "https://img2.baidu.com/it/u=123456789,123456789&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500", + nickname: "", + wechatId: "w28533368 15375804003" + } + ], + online: true, + friendStatus: "正常" + } +] export default function ScenarioDevicesPage({ params }: { params: { channel: string } }) { const router = useRouter() - const [selectedDevices, setSelectedDevices] = useState([]) + const [isDeviceSelectorOpen, setIsDeviceSelectorOpen] = useState(false) + const [selectedDeviceIds, setSelectedDeviceIds] = useState(mockDevices.map(d=>d.id)) + const [selectedDevices, setSelectedDevices] = useState(mockDevices) // 获取渠道中文名称 const getChannelName = (channel: string) => { @@ -30,14 +70,19 @@ export default function ScenarioDevicesPage({ params }: { params: { channel: str const channelName = getChannelName(params.channel) - const handleSave = async () => { - try { - // 这里应该是实际的API调用来保存选中的设备 - await new Promise((resolve) => setTimeout(resolve, 1000)) - router.back() - } catch (error) { - console.error("保存失败:", error) - } + // 设备选择回填 + const handleDeviceSelect = (deviceIds: string[]) => { + setSelectedDeviceIds(deviceIds) + // 这里用mockDevices过滤,实际应接口获取 + setSelectedDevices(mockDevices.filter(d => deviceIds.includes(d.id))) + setIsDeviceSelectorOpen(false) + } + + // 删除设备 + const handleDelete = (id: string) => { + const newIds = selectedDeviceIds.filter(did => did !== id) + setSelectedDeviceIds(newIds) + setSelectedDevices(selectedDevices.filter(d => d.id !== id)) } return ( @@ -54,20 +99,115 @@ export default function ScenarioDevicesPage({ params }: { params: { channel: str
- +
+ + +
+ + {/* PC端表格 */} +
+ + + + + + + + + + + + {selectedDevices.length === 0 ? ( + + + + ) : ( + selectedDevices.map(device => ( + + + + + + + + )) + )} + +
设备IMEI/备注/手机/设备ID客服微信号在线加友状态操作
暂无已选设备
+ {device.imei} + {device.remark ?
{device.remark}
: null} +
+ {device.wechatAccounts.length === 0 ? ( + - + ) : ( +
+ {device.wechatAccounts.map((wx, idx) => ( +
+ {wx.avatar && avatar} + {wx.nickname} + {wx.wechatId} +
+ ))} +
+ )} +
+ {device.online ? "是" : "否"} + + {device.friendStatus} + + +
+
+ + {/* 移动端卡片式渲染 */} +
+ {selectedDevices.length === 0 ? ( +
暂无已选设备
+ ) : ( + selectedDevices.map(device => ( +
+
{device.imei}
+ {device.remark &&
{device.remark}
} +
+ 客服微信号: + {device.wechatAccounts.length === 0 ? ( + - + ) : ( +
+ {device.wechatAccounts.map((wx, idx) => ( +
+ {wx.avatar && avatar} + {wx.nickname} + {wx.wechatId} +
+ ))} +
+ )} +
+
+ {device.online ? "在线" : "离线"} + {device.friendStatus} + +
+
+ )) + )} +
-
diff --git a/Cunkebao/app/scenarios/new/steps/BasicSettings.tsx b/Cunkebao/app/scenarios/new/steps/BasicSettings.tsx index 4ca99a3f..752280d7 100644 --- a/Cunkebao/app/scenarios/new/steps/BasicSettings.tsx +++ b/Cunkebao/app/scenarios/new/steps/BasicSettings.tsx @@ -21,6 +21,7 @@ import { } from "@/components/ui/dialog" import { toast } from "@/components/ui/use-toast" import { useSearchParams } from "next/navigation" +import { useRouter } from "next/navigation" interface BasicSettingsProps { formData: any @@ -48,7 +49,7 @@ interface PosterSectionProps { onUpload: () => void onSelect: (material: Material) => void uploading: boolean - fileInputRef: React.RefObject + fileInputRef: React.RefObject onFileChange: (event: React.ChangeEvent) => void onPreview: (url: string) => void onRemove: (id: string) => void @@ -58,7 +59,7 @@ interface OrderSectionProps { materials: Material[] onUpload: () => void uploading: boolean - fileInputRef: React.RefObject + fileInputRef: React.RefObject onFileChange: (event: React.ChangeEvent) => void } @@ -66,7 +67,7 @@ interface DouyinSectionProps { materials: Material[] onUpload: () => void uploading: boolean - fileInputRef: React.RefObject + fileInputRef: React.RefObject onFileChange: (event: React.ChangeEvent) => void } @@ -330,6 +331,7 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [importedTags, setImportedTags] = useState< Array<{ + name: string phone: string wechat: string source?: string @@ -352,25 +354,29 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc const [selectedPhoneTags, setSelectedPhoneTags] = useState(formData.phoneTags || []) const [phoneCallType, setPhoneCallType] = useState(formData.phoneCallType || "both") - const fileInputRef = useRef(null) + 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 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 miniAppFileInputRef = useRef(null) const [linkCover, setLinkCover] = useState(formData.linkCover || "") const [uploadingLinkCover, setUploadingLinkCover] = useState(false) - const linkFileInputRef = useRef(null) + const linkFileInputRef = useRef(null) + + const [uploadingOrderTable, setUploadingOrderTable] = useState(false) + const [uploadedOrderTableFile, setUploadedOrderTableFile] = useState(formData.orderTableFileName || "") + const orderTableFileInputRef = useRef(null) const searchParams = useSearchParams() const type = searchParams.get("type") @@ -532,8 +538,9 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc const content = e.target?.result as string const rows = content.split("\n").filter((row) => row.trim()) const tags = rows.slice(1).map((row) => { - const [phone, wechat, source, orderAmount, orderDate] = row.split(",") + const [name, phone, wechat, source, orderAmount, orderDate] = row.split(",") return { + name: name.trim(), phone: phone.trim(), wechat: wechat.trim(), source: source?.trim(), @@ -552,7 +559,7 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc } const handleDownloadTemplate = () => { - const template = "电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03" + const template = "姓名,电话号码,微信号,来源,订单金额,下单日期\n张三,13800138000,wxid_123,抖音,99.00,2024-03-03" const blob = new Blob([template], { type: "text/csv" }) const url = window.URL.createObjectURL(blob) const a = document.createElement("a") @@ -607,6 +614,7 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc const newPoster = { id: `custom_${Date.now()}`, name: result.data.name || '自定义海报', + type: 'poster', preview: result.data.url, } setMaterials(prev => [newPoster, ...prev]) @@ -748,7 +756,43 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc } } + const handleUploadOrderTable = () => { + orderTableFileInputRef.current?.click() + } + + const handleOrderTableFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + setUploadingOrderTable(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) { + setUploadedOrderTableFile(file.name) + onChange({ ...formData, orderTableFile: result.data.url, orderTableFileName: file.name }) + toast({ title: '上传成功', description: '订单表格文件已上传' }) + } else { + toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' }) + } + } catch (e: any) { + toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' }) + } finally { + setUploadingOrderTable(false) + if (orderTableFileInputRef.current) orderTableFileInputRef.current.value = '' + } + } + const renderSceneExtra = () => { + console.log('currentScenario:', currentScenario?.name); switch (currentScenario?.name) { case "海报获客": return ( @@ -844,6 +888,44 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc
) + const [saving, setSaving] = useState(false) + + const handleSave = async () => { + if (saving) return // 防止重复点击 + setSaving(true) + try { + // ...原有代码... + 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/${formData.sceneId}`) + } else { + toast({ + title: "创建失败", + description: res.msg || "创建计划失败,请重试", + variant: "destructive", + }) + } + } catch (error: any) { + toast({ + title: "创建失败", + description: error?.message || "创建计划失败,请重试", + variant: "destructive", + }) + } finally { + setSaving(false) + } + } + return ( @@ -1141,56 +1223,34 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc ))} - {String(currentScenario?.id) === "order" && ( + {currentScenario?.name === "订单获客" && (
- -
+ +
+
+
-
+ {uploadedOrderTableFile && ( +
已上传:{uploadedOrderTableFile}
+ )} +
支持 CSV、Excel 格式,上传后将文件保存到服务器
- - {importedTags.length > 0 && ( -
-

已导入 {importedTags.length} 条数据

-
- - - - 电话号码 - 微信号 - 来源 - 订单金额 - - - - {importedTags.slice(0, 5).map((tag, index) => ( - - {tag.phone} - {tag.wechat} - {tag.source} - {tag.orderAmount} - - ))} - {importedTags.length > 5 && ( - - - 还有 {importedTags.length - 5} 条数据未显示 - - - )} - -
-
-
- )}
)} {String(formData.scenario) === "weixinqun" && ( @@ -1284,8 +1344,8 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc />
-
@@ -1405,56 +1465,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc - - - - - 导入订单标签 - -
-
- -
-
- - - - 电话号码 - 微信号 - 来源 - 订单金额 - 下单日期 - - - - {importedTags.map((tag, index) => ( - - {tag.phone} - {tag.wechat} - {tag.source} - {tag.orderAmount} - {tag.orderDate} - - ))} - -
-
-
- - - - -
-
) } diff --git a/Cunkebao/app/workspace/moments-sync/[id]/page.tsx b/Cunkebao/app/workspace/moments-sync/[id]/page.tsx index badd0812..a2db43a1 100644 --- a/Cunkebao/app/workspace/moments-sync/[id]/page.tsx +++ b/Cunkebao/app/workspace/moments-sync/[id]/page.tsx @@ -26,16 +26,7 @@ interface TaskDetail { startTime: string endTime: string enabled: boolean - devices: { - id: string - name: string - avatar: string - }[] - contentLibraries: { - id: string - name: string - count: number - }[] + config: any lastSyncTime: string createTime: string creator: string @@ -79,7 +70,7 @@ export default function MomentsSyncDetailPage({ params }: { params: { id: string const fetchTaskDetail = async () => { setIsLoading(true) try { - const response = await api.get(`/v1/workbench/moments-records?workbenchId=${params.id}`) + const response = await api.get(`/v1/workbench/detail?id=${params.id}`) if (response.code === 200 && response.data) { setTaskDetail(response.data) @@ -260,33 +251,6 @@ export default function MomentsSyncDetailPage({ params }: { params: { id: string

任务详情

-
- - - - - - - - - 编辑 - - - - 复制 - - - - 删除 - - - -
@@ -296,8 +260,8 @@ export default function MomentsSyncDetailPage({ params }: { params: { id: string

{taskDetail.name}

- - {taskDetail.status === "running" ? "进行中" : "已暂停"} + + {taskDetail.status == 1 ? "进行中" : "已暂停"}
@@ -323,28 +287,24 @@ export default function MomentsSyncDetailPage({ params }: { params: { id: string
账号类型
-
{taskDetail.accountType === 1 ? "业务号" : "人设号"}
-
-
-
同步类型
-
{taskDetail.syncType === 1 ? "循环同步" : "实时更新"}
+
{taskDetail.config.accountType === 1 ? "业务号" : "人设号"}
同步间隔
-
{taskDetail.syncInterval} 分钟
+
{taskDetail.config.syncInterval} 分钟
每日同步数量
-
{taskDetail.syncCount} 条
+
{taskDetail.config.syncCount} 条
允许发布时间段
-
{taskDetail.startTime} - {taskDetail.endTime}
+
{taskDetail.config.startTime} - {taskDetail.config.endTime}
内容库
- {taskDetail.contentLibraries.map((lib) => ( + {taskDetail.config.contentLibraries.map((lib) => ( {lib.name} @@ -357,24 +317,24 @@ export default function MomentsSyncDetailPage({ params }: { params: { id: string - {taskDetail.devices.length === 0 ? ( + {taskDetail.config.deviceList.length === 0 ? (
暂无关联设备
) : (
- {taskDetail.devices.map((device) => ( + {taskDetail.config.deviceList.map((device) => (
{device.avatar ? ( - {device.name} + {device.nickname} ) : (
- {device.name.charAt(0)} + {device.nickname}
)}
-
{device.name}
-
ID: {device.id}
+
{device.nickname}
+
{device.alias || device.wechatId}
))} diff --git a/Server/application/cunkebao/config/route.php b/Server/application/cunkebao/config/route.php index ff4709e6..fc6f5588 100644 --- a/Server/application/cunkebao/config/route.php +++ b/Server/application/cunkebao/config/route.php @@ -40,7 +40,7 @@ Route::group('v1/', function () { 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\PlanSceneV1Controller@index'); - Route::get('copy', 'app\cunkebao\controller\plan\PlanSceneV1Controller@copy'); + Route::get('copy', 'app\cunkebao\controller\plan\GetCreateAddFriendPlanV1Controller@copy'); Route::delete('delete', 'app\cunkebao\controller\plan\PlanSceneV1Controller@delete'); Route::post('updateStatus', 'app\cunkebao\controller\plan\PlanSceneV1Controller@updateStatus'); Route::get('detail', 'app\cunkebao\controller\plan\GetAddFriendPlanDetailV1Controller@index'); diff --git a/Server/application/cunkebao/controller/WorkbenchController.php b/Server/application/cunkebao/controller/WorkbenchController.php index 86d80095..67eb0b9b 100644 --- a/Server/application/cunkebao/controller/WorkbenchController.php +++ b/Server/application/cunkebao/controller/WorkbenchController.php @@ -2,6 +2,8 @@ namespace app\cunkebao\controller; +use app\common\model\Device as DeviceModel; +use app\common\model\DeviceWechatLogin as DeviceWechatLoginModel; use app\cunkebao\model\Workbench; use app\cunkebao\model\WorkbenchAutoLike; use app\cunkebao\model\WorkbenchMomentsSync; @@ -236,7 +238,12 @@ class WorkbenchController extends Controller $item->config = $item->momentsSync; $item->config->devices = json_decode($item->config->devices, true); $item->config->contentLibraries = json_decode($item->config->contentLibraries, true); - + //同步记录 + $sendNum = Db::name('workbench_moments_sync_item')->where(['workbenchId' => $item->id])->count(); + $item->syncCount = $sendNum; + $lastTime = Db::name('workbench_moments_sync_item')->where(['workbenchId' => $item->id])->order('id DESC')->value('createTime'); + $item->lastSyncTime = !empty($lastTime) ? date('Y-m-d H:i',$lastTime) : '--'; + // 获取内容库名称 if (!empty($item->config->contentLibraries)) { $libraryNames = ContentLibrary::where('id', 'in', $item->config->contentLibraries) @@ -426,6 +433,42 @@ class WorkbenchController extends Controller $workbench->config = $workbench->momentsSync; $workbench->config->devices = json_decode($workbench->config->devices, true); $workbench->config->contentLibraries = json_decode($workbench->config->contentLibraries, true); + + //同步记录 + $sendNum = Db::name('workbench_moments_sync_item')->where(['workbenchId' => $workbench->id])->count(); + $workbench->syncCount = $sendNum; + $lastTime = Db::name('workbench_moments_sync_item')->where(['workbenchId' => $workbench->id])->order('id DESC')->value('createTime'); + $workbench->lastSyncTime = !empty($lastTime) ? date('Y-m-d H:i',$lastTime) : '--'; + + // 获取内容库名称 + if (!empty($workbench->config->contentLibraries)) { + $libraryNames = ContentLibrary::where('id', 'in', $workbench->config->contentLibraries) + ->select(); + $workbench->config->contentLibraries = $libraryNames; + } else { + $workbench->config->contentLibraryNames = []; + } + + if(!empty($workbench->config->devices)){ + $deviceList = DeviceModel::alias('d') + ->field([ + 'd.id', 'd.imei', 'd.memo', 'd.alive', + 'l.wechatId', + 'a.nickname', 'a.alias', 'a.avatar','a.alias' + ]) + ->leftJoin('device_wechat_login l', 'd.id = l.deviceId and l.alive =' . DeviceWechatLoginModel::ALIVE_WECHAT_ACTIVE . ' and l.companyId = d.companyId') + ->leftJoin('wechat_account a', 'l.wechatId = a.wechatId') + ->whereIn('d.id',$workbench->config->devices) + ->order('d.id desc') + ->select(); + $workbench->config->deviceList = $deviceList; + }else{ + $workbench->config->deviceList = []; + } + + + + unset($workbench->momentsSync,$workbench->moments_sync); } break; diff --git a/Server/application/cunkebao/controller/plan/PlanSceneV1Controller.php b/Server/application/cunkebao/controller/plan/PlanSceneV1Controller.php index 3d958815..f43428e3 100644 --- a/Server/application/cunkebao/controller/plan/PlanSceneV1Controller.php +++ b/Server/application/cunkebao/controller/plan/PlanSceneV1Controller.php @@ -54,18 +54,18 @@ class PlanSceneV1Controller extends BaseController $val['acquiredCount'] = Db::name('task_customer')->where('task_id',$val['id'])->count(); $val['addedCount'] = Db::name('task_customer')->where('task_id',$val['id'])->whereIn('status',[1,2,3,4])->count(); $val['passCount'] = Db::name('task_customer')->where('task_id',$val['id'])->where('status',4)->count(); - $val['passRate'] = 0; if(!empty($val['passCount']) && !empty($val['addedCount'])){ - $passRate = ($val['addedCount'] / $val['passCount']) * 100; + $passRate = ($val['passCount'] / $val['addedCount']) * 100; $val['passRate'] = number_format($passRate,2); } + $lastTime = Db::name('task_customer')->where(['task_id'=>$val['id']])->max('updated_at'); + $val['lastUpdated'] = !empty($lastTime) ? date('Y-m-d H:i', $lastTime) : '--'; + + } unset($val); - - - return ResponseHelper::success([ 'total' => $total, 'list' => $list @@ -75,41 +75,6 @@ class PlanSceneV1Controller extends BaseController } } - /** - * 拷贝计划任务 - * - * @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); - } - } /** * 删除计划任务 @@ -163,4 +128,251 @@ class PlanSceneV1Controller extends BaseController return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500); } } + + /** + * 获取获客计划设备列表 + * + * @return \think\response\Json + */ + public function getPlanDevices() + { + try { + $params = $this->request->param(); + $planId = isset($params['planId']) ? intval($params['planId']) : 0; + $page = isset($params['page']) ? intval($params['page']) : 1; + $limit = isset($params['limit']) ? intval($params['limit']) : 10; + $deviceStatus = isset($params['deviceStatus']) ? $params['deviceStatus'] : ''; + $searchKeyword = isset($params['searchKeyword']) ? trim($params['searchKeyword']) : ''; + + // 验证计划ID + if ($planId <= 0) { + return ResponseHelper::error('计划ID不能为空', 400); + } + + // 验证计划是否存在且用户有权限 + $plan = Db::name('customer_acquisition_task') + ->where([ + 'id' => $planId, + 'deleteTime' => 0, + 'companyId' => $this->getUserInfo('companyId') + ]) + ->find(); + + if (!$plan) { + return ResponseHelper::error('计划不存在或无权限访问', 404); + } + + // 如果是管理员,需要验证用户权限 + if ($this->getUserInfo('isAdmin')) { + $userPlan = Db::name('customer_acquisition_task') + ->where([ + 'id' => $planId, + 'userId' => $this->getUserInfo('id') + ]) + ->find(); + + if (!$userPlan) { + return ResponseHelper::error('您没有权限访问该计划', 403); + } + } + + // 构建查询条件 + $where = [ + 'pt.plan_id' => $planId, + 'd.deleteTime' => 0, + 'd.companyId' => $this->getUserInfo('companyId') + ]; + + // 设备状态筛选 + if (!empty($deviceStatus)) { + $where['d.alive'] = $deviceStatus; + } + + // 搜索关键词 + $searchWhere = []; + if (!empty($searchKeyword)) { + $searchWhere[] = ['d.imei', 'like', "%{$searchKeyword}%"]; + $searchWhere[] = ['d.memo', 'like', "%{$searchKeyword}%"]; + } + + // 查询设备总数 + $totalQuery = Db::name('plan_task_device')->alias('pt') + ->join('device d', 'pt.device_id = d.id') + ->where($where); + + if (!empty($searchWhere)) { + $totalQuery->where(function ($query) use ($searchWhere) { + foreach ($searchWhere as $condition) { + $query->whereOr($condition[0], $condition[1], $condition[2]); + } + }); + } + + $total = $totalQuery->count(); + + // 查询设备列表 + $listQuery = Db::name('plan_task_device')->alias('pt') + ->join('device d', 'pt.device_id = d.id') + ->field([ + 'd.id', + 'd.imei', + 'd.memo', + 'd.alive', + 'd.extra', + 'd.createTime', + 'd.updateTime', + 'pt.status as plan_device_status', + 'pt.createTime as assign_time' + ]) + ->where($where) + ->order('pt.createTime', 'desc'); + + if (!empty($searchWhere)) { + $listQuery->where(function ($query) use ($searchWhere) { + foreach ($searchWhere as $condition) { + $query->whereOr($condition[0], $condition[1], $condition[2]); + } + }); + } + + $list = $listQuery->page($page, $limit)->select(); + + // 处理设备数据 + foreach ($list as &$device) { + // 格式化时间 + $device['createTime'] = date('Y-m-d H:i:s', $device['createTime']); + $device['updateTime'] = date('Y-m-d H:i:s', $device['updateTime']); + $device['assign_time'] = date('Y-m-d H:i:s', $device['assign_time']); + + // 解析设备额外信息 + if (!empty($device['extra'])) { + $extra = json_decode($device['extra'], true); + $device['battery'] = isset($extra['battery']) ? intval($extra['battery']) : 0; + $device['device_info'] = $extra; + } else { + $device['battery'] = 0; + $device['device_info'] = []; + } + + // 设备状态文本 + $device['alive_text'] = $this->getDeviceStatusText($device['alive']); + $device['plan_device_status_text'] = $this->getPlanDeviceStatusText($device['plan_device_status']); + + // 获取设备当前微信登录信息 + $wechatLogin = Db::name('device_wechat_login') + ->where([ + 'deviceId' => $device['id'], + 'companyId' => $this->getUserInfo('companyId'), + 'alive' => 1 + ]) + ->order('createTime', 'desc') + ->find(); + + $device['current_wechat'] = $wechatLogin ? [ + 'wechatId' => $wechatLogin['wechatId'], + 'nickname' => $wechatLogin['nickname'] ?? '', + 'loginTime' => date('Y-m-d H:i:s', $wechatLogin['createTime']) + ] : null; + + // 获取设备在该计划中的任务统计 + $device['task_stats'] = $this->getDeviceTaskStats($device['id'], $planId); + + // 移除原始extra字段 + unset($device['extra']); + } + unset($device); + + return ResponseHelper::success([ + 'total' => $total, + 'list' => $list, + 'plan_info' => [ + 'id' => $plan['id'], + 'name' => $plan['name'], + 'status' => $plan['status'] + ] + ], '获取计划设备列表成功'); + + } catch (\Exception $e) { + return ResponseHelper::error('系统错误: ' . $e->getMessage(), 500); + } + } + + /** + * 获取设备状态文本 + * + * @param int $status + * @return string + */ + private function getDeviceStatusText($status) + { + $statusMap = [ + 0 => '离线', + 1 => '在线', + 2 => '忙碌', + 3 => '故障' + ]; + return isset($statusMap[$status]) ? $statusMap[$status] : '未知'; + } + + /** + * 获取计划设备状态文本 + * + * @param int $status + * @return string + */ + private function getPlanDeviceStatusText($status) + { + $statusMap = [ + 0 => '待分配', + 1 => '已分配', + 2 => '执行中', + 3 => '已完成', + 4 => '已暂停', + 5 => '已取消' + ]; + return isset($statusMap[$status]) ? $statusMap[$status] : '未知'; + } + + /** + * 获取设备在指定计划中的任务统计 + * + * @param int $deviceId + * @param int $planId + * @return array + */ + private function getDeviceTaskStats($deviceId, $planId) + { + // 获取该设备在计划中的任务总数 + $totalTasks = Db::name('task_customer') + ->where([ + 'task_id' => $planId, + 'device_id' => $deviceId + ]) + ->count(); + + // 获取已完成的任务数 + $completedTasks = Db::name('task_customer') + ->where([ + 'task_id' => $planId, + 'device_id' => $deviceId, + 'status' => 4 + ]) + ->count(); + + // 获取进行中的任务数 + $processingTasks = Db::name('task_customer') + ->where([ + 'task_id' => $planId, + 'device_id' => $deviceId, + 'status' => ['in', [1, 2, 3]] + ]) + ->count(); + + return [ + 'total_tasks' => $totalTasks, + 'completed_tasks' => $completedTasks, + 'processing_tasks' => $processingTasks, + 'completion_rate' => $totalTasks > 0 ? round(($completedTasks / $totalTasks) * 100, 2) : 0 + ]; + } } \ 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 efa53fa9..1d3ab042 100644 --- a/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php +++ b/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php @@ -121,6 +121,7 @@ class PostCreateAddFriendPlanV1Controller extends Controller try { + Db::startTrans(); // 插入数据 $planId = Db::name('customer_acquisition_task')->insertGetId($data); @@ -220,7 +221,7 @@ class PostCreateAddFriendPlanV1Controller extends Controller $existingPhones = []; if (!empty($phones)) { $existing = Db::name('task_customer') - ->where('task_id', $params['planId']) + ->where('task_id', $planId) ->where('phone', 'in', $phones) ->field('phone') ->select(); @@ -233,7 +234,7 @@ class PostCreateAddFriendPlanV1Controller extends Controller $phone = !empty($row['phone']) ? $row['phone'] : $row['wechat']; if (!empty($phone) && !in_array($phone, $existingPhones)) { $newData[] = [ - 'task_id' => $params['planId'], + 'task_id' => $planId, 'name' => $row['name'] ?? '', 'source' => $row['source'] ?? '', 'phone' => $phone, @@ -254,6 +255,7 @@ class PostCreateAddFriendPlanV1Controller extends Controller } } + Db::commit(); return ResponseHelper::success(['planId' => $planId], '添加计划任务成功'); diff --git a/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php b/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php index bc17565b..2db45e6b 100644 --- a/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php +++ b/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php @@ -153,14 +153,16 @@ class Adapter implements WeChatServiceInterface public function handleCustomerTaskWithStatusIsNew(int $current_worker_id, int $process_count_for_status_0) { - $tasks = Db::name('task_customer') - ->where('status', 0) - ->whereRaw("id % $process_count_for_status_0 = {$current_worker_id}") - ->limit(50) + + $tasks = Db::name('customer_acquisition_task')->alias('task') + ->join('task_customer customer','task.id=customer.task_id') + ->where(['task.status' => 1,'customer.status'=>0,'task.deleteTime' => 0]) + ->whereRaw("customer.id % $process_count_for_status_0 = {$current_worker_id}") ->order('id DESC') ->select(); + if ($tasks) { foreach ($tasks as $task) {