代码提交

This commit is contained in:
wong
2025-07-06 00:17:24 +08:00
parent e808ee1a90
commit 15b899b0e8
9 changed files with 606 additions and 230 deletions

View File

@@ -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 && (
<div className="mb-3">
<p className="text-gray-700 whitespace-pre-line">{material.content}</p>
</div>
<div className="flex items-center" style={{ border: '1px solid #ededed' }}>
<div className="flex-shrink-0 w-14 h-14 rounded overflow-hidden mr-3 bg-gray-100">
<Image
src={first.image}
src={first.image ?? 'https://api.dicebear.com/7.x/avataaars/svg?seed=123'}
alt="封面图"
width={56}
height={56}
className="object-cover w-full h-full"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-base font-medium truncate group-hover:text-blue-600">{first.desc}</div>
<div className="text-base font-medium truncate">{first.desc ?? '这是一条链接'}</div>
</div>
</div>
</a>
);
@@ -179,6 +185,7 @@ export default function MaterialsPage({ params }: { params: Promise<{ id: string
const videoUrl = typeof first === "string" ? first : (first.url || "");
return videoUrl ? (
<div className="mb-3">
<p className="text-gray-700 whitespace-pre-line">{material.content}</p>
<video src={videoUrl} controls className="rounded w-full max-w-md" />
</div>
) : null;

View File

@@ -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<string[]>([])
const [isDeviceSelectorOpen, setIsDeviceSelectorOpen] = useState(false)
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>(mockDevices.map(d=>d.id))
const [selectedDevices, setSelectedDevices] = useState<Device[]>(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
</header>
<div className="p-4">
<DeviceSelector
title={`${channelName}设备选择`}
selectedDevices={selectedDevices}
onDevicesChange={setSelectedDevices}
multiple={true}
maxSelection={5}
className="mb-4"
<div className="flex justify-end mb-2">
<Button onClick={() => setIsDeviceSelectorOpen(true)}>+ </Button>
<DeviceSelectionDialog
open={isDeviceSelectorOpen}
onOpenChange={setIsDeviceSelectorOpen}
selectedDevices={selectedDeviceIds}
onSelect={handleDeviceSelect}
/>
</div>
{/* PC端表格 */}
<div className="overflow-x-auto bg-white rounded shadow hidden md:block">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-gray-50">
<th className="px-4 py-2 text-left">IMEI///ID</th>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2">线</th>
<th className="px-4 py-2"></th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{selectedDevices.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-400"></td>
</tr>
) : (
selectedDevices.map(device => (
<tr key={device.id} className="border-t">
<td className="px-4 py-2 whitespace-pre-line">
{device.imei}
{device.remark ? <div className="text-xs text-gray-500">{device.remark}</div> : null}
</td>
<td className="px-4 py-2">
{device.wechatAccounts.length === 0 ? (
<span className="text-gray-400">-</span>
) : (
<div className="space-y-1">
{device.wechatAccounts.map((wx, idx) => (
<div key={wx.wechatId+idx} className="flex items-center space-x-2">
{wx.avatar && <img src={wx.avatar} alt="avatar" className="w-7 h-7 rounded object-cover" />}
<span>{wx.nickname}</span>
<span className="text-xs text-gray-500">{wx.wechatId}</span>
</div>
))}
</div>
)}
</td>
<td className="px-4 py-2 text-center">
<Badge variant={device.online ? "success" : "secondary"}>{device.online ? "是" : "否"}</Badge>
</td>
<td className="px-4 py-2 text-center">
<Badge variant={device.friendStatus === "正常" ? "success" : "destructive"}>{device.friendStatus}</Badge>
</td>
<td className="px-4 py-2 text-center">
<Button variant="destructive" size="icon" onClick={() => handleDelete(device.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 移动端卡片式渲染 */}
<div className="space-y-3 md:hidden">
{selectedDevices.length === 0 ? (
<div className="text-center py-8 text-gray-400 bg-white rounded"></div>
) : (
selectedDevices.map(device => (
<div key={device.id} className="bg-white rounded shadow p-3">
<div className="font-medium break-all">{device.imei}</div>
{device.remark && <div className="text-xs text-gray-500 mb-1">{device.remark}</div>}
<div className="mb-1">
<span className="text-gray-500 text-xs"></span>
{device.wechatAccounts.length === 0 ? (
<span className="text-gray-400">-</span>
) : (
<div className="space-y-1">
{device.wechatAccounts.map((wx, idx) => (
<div key={wx.wechatId+idx} className="flex items-center space-x-2">
{wx.avatar && <img src={wx.avatar} alt="avatar" className="w-6 h-6 rounded object-cover" />}
<span className="truncate">{wx.nickname}</span>
<span className="text-xs text-gray-500">{wx.wechatId}</span>
</div>
))}
</div>
)}
</div>
<div className="flex items-center space-x-2 mt-2">
<Badge variant={device.online ? "success" : "secondary"}>{device.online ? "在线" : "离线"}</Badge>
<Badge variant={device.friendStatus === "正常" ? "success" : "destructive"}>{device.friendStatus}</Badge>
<Button variant="destructive" size="icon" onClick={() => handleDelete(device.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))
)}
</div>
<div className="fixed bottom-0 left-0 right-0 bg-white p-4 border-t flex justify-end space-x-2">
<Button variant="outline" onClick={() => router.back()}>
</Button>
<Button onClick={handleSave} disabled={selectedDevices.length === 0}>
<Button onClick={() => {}} disabled={selectedDevices.length === 0}>
({selectedDevices.length})
</Button>
</div>

View File

@@ -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<HTMLInputElement>
fileInputRef: React.RefObject<HTMLInputElement | null>
onFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
onPreview: (url: string) => void
onRemove: (id: string) => void
@@ -58,7 +59,7 @@ interface OrderSectionProps {
materials: Material[]
onUpload: () => void
uploading: boolean
fileInputRef: React.RefObject<HTMLInputElement>
fileInputRef: React.RefObject<HTMLInputElement | null>
onFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
}
@@ -66,7 +67,7 @@ interface DouyinSectionProps {
materials: Material[]
onUpload: () => void
uploading: boolean
fileInputRef: React.RefObject<HTMLInputElement>
fileInputRef: React.RefObject<HTMLInputElement | null>
onFileChange: (event: React.ChangeEvent<HTMLInputElement>) => 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<string[]>(formData.phoneTags || [])
const [phoneCallType, setPhoneCallType] = useState(formData.phoneCallType || "both")
const fileInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement | null>(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 orderFileInputRef = useRef<HTMLInputElement | null>(null)
const douyinFileInputRef = useRef<HTMLInputElement | null>(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 miniAppFileInputRef = useRef<HTMLInputElement | null>(null)
const [linkCover, setLinkCover] = useState(formData.linkCover || "")
const [uploadingLinkCover, setUploadingLinkCover] = useState(false)
const linkFileInputRef = useRef<HTMLInputElement>(null)
const linkFileInputRef = useRef<HTMLInputElement | null>(null)
const [uploadingOrderTable, setUploadingOrderTable] = useState(false)
const [uploadedOrderTableFile, setUploadedOrderTableFile] = useState<string>(formData.orderTableFileName || "")
const orderTableFileInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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
</div>
)
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<ApiResponse>("/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 (
<TooltipProvider>
<Card className="p-6">
@@ -1141,58 +1223,36 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc
</div>
))}
{String(currentScenario?.id) === "order" && (
{currentScenario?.name === "订单获客" && (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<div className="flex gap-2">
<Label></Label>
</div>
<div className="mb-4">
<div className="flex gap-2 items-center">
<Button variant="outline" onClick={handleDownloadTemplate}>
<Download className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => setIsImportDialogOpen(true)}>
<Button onClick={handleUploadOrderTable} disabled={uploadingOrderTable} variant="outline">
<Upload className="h-4 w-4 mr-2" />
<input
type="file"
ref={orderTableFileInputRef}
onChange={handleOrderTableFileChange}
className="hidden"
accept=".csv,.xlsx,.xls"
/>
</Button>
</div>
</div>
{importedTags.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium mb-2"> {importedTags.length} </h4>
<div className="max-h-[300px] overflow-auto border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importedTags.slice(0, 5).map((tag, index) => (
<TableRow key={index}>
<TableCell>{tag.phone}</TableCell>
<TableCell>{tag.wechat}</TableCell>
<TableCell>{tag.source}</TableCell>
<TableCell>{tag.orderAmount}</TableCell>
</TableRow>
))}
{importedTags.length > 5 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-gray-500">
{importedTags.length - 5}
</TableCell>
</TableRow>
{uploadedOrderTableFile && (
<div className="mt-2 text-xs text-green-600 text-left">{uploadedOrderTableFile}</div>
)}
</TableBody>
</Table>
<div className="text-xs text-gray-500 mt-1"> CSVExcel </div>
</div>
</div>
)}
</div>
)}
{String(formData.scenario) === "weixinqun" && (
<>
<div>
@@ -1284,8 +1344,8 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc
/>
</div>
<Button className="w-full h-12 text-base" onClick={onNext}>
<Button className="w-full h-12 text-base" onClick={onNext} disabled={saving}>
{saving ? <span className="flex items-center justify-center"><svg className="animate-spin h-5 w-5 mr-2 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path></svg>...</span> : "下一步"}
</Button>
</div>
</Card>
@@ -1405,56 +1465,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios, loadingSc
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isImportDialogOpen} onOpenChange={setIsImportDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-4">
<Input type="file" accept=".csv" onChange={handleFileImport} className="flex-1" />
</div>
<div className="max-h-[400px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importedTags.map((tag, index) => (
<TableRow key={index}>
<TableCell>{tag.phone}</TableCell>
<TableCell>{tag.wechat}</TableCell>
<TableCell>{tag.source}</TableCell>
<TableCell>{tag.orderAmount}</TableCell>
<TableCell>{tag.orderDate}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsImportDialogOpen(false)}>
</Button>
<Button
onClick={() => {
onChange({ ...formData, importedTags })
setIsImportDialogOpen(false)
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
)
}

View File

@@ -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<ApiResponse>(`/v1/workbench/moments-records?workbenchId=${params.id}`)
const response = await api.get<ApiResponse>(`/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
</Button>
<h1 className="text-lg font-medium"></h1>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={taskDetail.status === "running"}
onCheckedChange={toggleTaskStatus}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<Copy className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={confirmDelete} className="text-red-500 hover:text-red-600">
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
@@ -296,8 +260,8 @@ export default function MomentsSyncDetailPage({ params }: { params: { id: string
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<h2 className="text-xl font-semibold">{taskDetail.name}</h2>
<Badge variant={taskDetail.status === "running" ? "success" : "secondary"}>
{taskDetail.status === "running" ? "进行中" : "已暂停"}
<Badge variant={taskDetail.status == 1 ? "success" : "secondary"}>
{taskDetail.status == 1 ? "进行中" : "已暂停"}
</Badge>
</div>
</div>
@@ -323,28 +287,24 @@ export default function MomentsSyncDetailPage({ params }: { params: { id: string
<div className="space-y-4">
<div>
<div className="font-medium mb-1"></div>
<div className="text-gray-600">{taskDetail.accountType === 1 ? "业务号" : "人设号"}</div>
</div>
<div>
<div className="font-medium mb-1"></div>
<div className="text-gray-600">{taskDetail.syncType === 1 ? "循环同步" : "实时更新"}</div>
<div className="text-gray-600">{taskDetail.config.accountType === 1 ? "业务号" : "人设号"}</div>
</div>
<div>
<div className="font-medium mb-1"></div>
<div className="text-gray-600">{taskDetail.syncInterval} </div>
<div className="text-gray-600">{taskDetail.config.syncInterval} </div>
</div>
<div>
<div className="font-medium mb-1"></div>
<div className="text-gray-600">{taskDetail.syncCount} </div>
<div className="text-gray-600">{taskDetail.config.syncCount} </div>
</div>
<div>
<div className="font-medium mb-1"></div>
<div className="text-gray-600">{taskDetail.startTime} - {taskDetail.endTime}</div>
<div className="text-gray-600">{taskDetail.config.startTime} - {taskDetail.config.endTime}</div>
</div>
<div>
<div className="font-medium mb-1"></div>
<div className="flex flex-wrap gap-2 mt-1">
{taskDetail.contentLibraries.map((lib) => (
{taskDetail.config.contentLibraries.map((lib) => (
<Badge key={lib.id} variant="outline" className="bg-blue-50">
{lib.name}
</Badge>
@@ -357,24 +317,24 @@ export default function MomentsSyncDetailPage({ params }: { params: { id: string
<TabsContent value="devices" className="mt-4">
<Card className="p-4">
{taskDetail.devices.length === 0 ? (
{taskDetail.config.deviceList.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<div className="divide-y">
{taskDetail.devices.map((device) => (
{taskDetail.config.deviceList.map((device) => (
<div key={device.id} className="flex items-center py-3 first:pt-0 last:pb-0">
<Avatar className="h-10 w-10 mr-3">
{device.avatar ? (
<img src={device.avatar} alt={device.name} />
<img src={device.avatar} alt={device.nickname} />
) : (
<div className="bg-blue-100 text-blue-600 h-full w-full flex items-center justify-center">
{device.name.charAt(0)}
{device.nickname}
</div>
)}
</Avatar>
<div>
<div className="font-medium">{device.name}</div>
<div className="text-xs text-gray-500">ID: {device.id}</div>
<div className="font-medium">{device.nickname}</div>
<div className="text-xs text-gray-500">{device.alias || device.wechatId}</div>
</div>
</div>
))}

View File

@@ -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');

View File

@@ -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,6 +238,11 @@ 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)) {
@@ -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;

View File

@@ -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
];
}
}

View File

@@ -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], '添加计划任务成功');

View File

@@ -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) {