Merge branch 'develop' into yongpxu-dev

# Conflicts:
#	Cunkebao/app/content/[id]/materials/new/page.tsx   resolved by develop version
#	Cunkebao/app/content/[id]/materials/page.tsx   resolved by develop version
#	Cunkebao/app/workspace/moments-sync/[id]/page.tsx   resolved by develop version
This commit is contained in:
笔记本里的永平
2025-07-09 18:11:13 +08:00
21 changed files with 2571 additions and 442 deletions

View File

@@ -15,6 +15,7 @@ import { showToast } from "@/lib/toast"
import Image from "next/image"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { useRef } from "react"
interface ApiResponse<T = any> {
code: number
@@ -77,6 +78,7 @@ export default function EditMaterialPage({ params }: { params: Promise<{ id: str
const [iconUrl, setIconUrl] = useState<string>("")
const [videoUrl, setVideoUrl] = useState<string>("")
const [comment, setComment] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
// 获取素材详情
useEffect(() => {
@@ -153,22 +155,56 @@ export default function EditMaterialPage({ params }: { params: Promise<{ id: str
fetchMaterialDetail()
}, [resolvedParams.materialId, router])
// 模拟上传图片
// 替换handleUploadImage为
const handleUploadImage = () => {
// 这里应该是真实的图片上传逻辑
// 为了演示这里模拟添加一些示例图片URL
const mockImageUrls = [
"https://picsum.photos/id/237/200/300",
"https://picsum.photos/id/238/200/300",
"https://picsum.photos/id/239/200/300"
]
const randomIndex = Math.floor(Math.random() * mockImageUrls.length)
const newImage = mockImageUrls[randomIndex]
if (!images.includes(newImage)) {
setImages([...images, newImage])
setPreviewUrls([...previewUrls, newImage])
if (images.length >= 9) {
showToast("最多只能上传9张图片", "error")
return
}
fileInputRef.current?.click()
}
// 新增真实上传逻辑
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (images.length >= 9) {
showToast("最多只能上传9张图片", "error")
return
}
const file = event.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showToast("请选择图片文件", "error")
return
}
showToast("正在上传图片...", "loading")
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) {
setImages((prev) => [...prev, result.data.url])
setPreviewUrls((prev) => [...prev, result.data.url])
showToast("图片上传成功", "success")
} else {
showToast(result.msg || "图片上传失败", "error")
}
} catch (error: any) {
showToast(error?.message || "图片上传失败", "error")
} finally {
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
@@ -450,12 +486,20 @@ export default function EditMaterialPage({ params }: { params: Promise<{ id: str
variant="outline"
onClick={handleUploadImage}
className="w-full py-8 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
disabled={images.length >= 9}
>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400" />
<span></span>
<span className="text-xs text-gray-500 mt-1"> JPGPNG </span>
<span className="text-xs text-gray-500 mt-1">{`已上传${images.length}最多可上传9张`}</span>
</Button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept="image/*"
/>
{previewUrls.length > 0 && (
<div className="mt-2">
<Label className="font-bold mb-2"></Label>

View File

@@ -2,59 +2,166 @@
import type React from "react"
import { useState } from "react"
import { useState, useRef } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, Plus, X } from "lucide-react"
import { ChevronLeft, Plus, X, Image as ImageIcon, UploadCloud, Link, Video, FileText, Layers, CalendarDays, ChevronDown } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { toast } from "@/components/ui/use-toast"
import Image from "next/image"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { api } from "@/lib/api"
import { showToast } from "@/lib/toast"
interface ApiResponse<T = any> {
code: number
msg: string
data: T
}
// 素材类型枚举
const MATERIAL_TYPES = [
{ id: 1, name: "图片", icon: ImageIcon },
{ id: 2, name: "链接", icon: Link },
{ id: 3, name: "视频", icon: Video },
{ id: 4, name: "文本", icon: FileText },
{ id: 5, name: "小程序", icon: Layers }
]
export default function NewMaterialPage({ params }: { params: { id: string } }) {
const router = useRouter()
const [content, setContent] = useState("")
const [newTag, setNewTag] = useState("")
const [tags, setTags] = useState<string[]>([])
const [images, setImages] = useState<string[]>([])
const [previewUrls, setPreviewUrls] = useState<string[]>([])
const [materialType, setMaterialType] = useState<number>(1)
const [url, setUrl] = useState<string>("")
const [desc, setDesc] = useState<string>("")
const [image, setImage] = useState<string>("")
const [videoUrl, setVideoUrl] = useState<string>("")
const [publishTime, setPublishTime] = useState("")
const [comment, setComment] = useState("")
const [loading, setLoading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleAddTag = () => {
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag])
setNewTag("")
}
// 图片上传
const handleUploadImage = () => {
fileInputRef.current?.click()
}
const handleRemoveTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!content) {
toast({
title: "错误",
description: "请输入素材内容",
variant: "destructive",
})
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (images.length >= 9) {
showToast("最多只能上传9张图片", "error")
return
}
const file = event.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showToast("请选择图片文件", "error")
return
}
const loadingToast = showToast("正在上传图片...", "loading", true)
setLoading(true)
const formData = new FormData()
formData.append("file", file)
try {
// 模拟保存新素材
await new Promise((resolve) => setTimeout(resolve, 1000))
toast({
title: "成功",
description: "新素材已创建",
})
const token = localStorage.getItem('token');
const headers: HeadersInit = {
// 浏览器会自动为 FormData 设置 Content-Type 为 multipart/form-data无需手动设置
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST',
headers: headers,
body: formData,
});
const result: ApiResponse = await response.json();
if (result.code === 200 && result.data?.url) {
setImages((prev) => [...prev, result.data.url]);
setPreviewUrls((prev) => [...prev, result.data.url]);
showToast("图片上传成功", "success");
} else {
showToast(result.msg || "图片上传失败", "error");
}
} catch (error: any) {
showToast(error?.message || "图片上传失败", "error")
} finally {
loadingToast.remove && loadingToast.remove()
setLoading(false)
// 清空文件输入框,以便再次上传同一文件
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}
const handleRemoveImage = (indexToRemove: number) => {
setImages(images.filter((_, index) => index !== indexToRemove))
setPreviewUrls(previewUrls.filter((_, index) => index !== indexToRemove))
}
// 创建素材
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// 校验
if (!content) {
showToast("请输入内容", "error")
return
}
// if (!comment) {
// showToast("请输入评论内容", "error")
// return
// }
if (materialType === 1 && images.length === 0) {
showToast("请上传图片", "error")
return
} else if (materialType === 2 && (!url || !desc)) {
showToast("请输入描述和链接地址", "error")
return
} else if (materialType === 3 && (!url && !videoUrl)) {
showToast("请填写视频链接或上传视频", "error")
return
}
setLoading(true)
const loadingToast = showToast("正在创建素材...", "loading", true)
try {
const payload: any = {
libraryId: params.id,
type: materialType,
content: content,
comment: comment,
sendTime: publishTime,
}
if (materialType === 1) {
payload.resUrls = images
} else if (materialType === 2) {
payload.urls = [{ desc, image, url }]
} else if (materialType === 3) {
payload.urls = videoUrl ? [videoUrl] : []
}
const response = await api.post<ApiResponse>('/v1/content/library/create-item', payload)
if (response.code === 200) {
showToast("创建成功", "success")
router.push(`/content/${params.id}/materials`)
} catch (error) {
console.error("Failed to create new material:", error)
toast({
title: "错误",
description: "创建素材失败",
variant: "destructive",
})
} else {
showToast(response.msg || "创建失败", "error")
}
} catch (error: any) {
showToast(error?.message || "创建素材失败", "error")
} finally {
loadingToast.remove && loadingToast.remove()
setLoading(false)
}
}
@@ -70,57 +177,273 @@ export default function NewMaterialPage({ params }: { params: { id: string } })
</div>
</div>
</header>
<div className="p-4">
<Card className="p-4">
<form onSubmit={handleSubmit} className="space-y-4">
<Card className="p-8 rounded-3xl shadow-xl bg-white max-w-lg mx-auto">
<form onSubmit={handleSubmit} className="space-y-8">
{/* 基础信息分组 */}
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
<div className="mb-4">
<Label className="font-bold flex items-center mb-2"></Label>
<div className="relative">
<Input
id="publish-time"
type="datetime-local"
step="60"
value={publishTime}
onChange={(e) => setPublishTime(e.target.value)}
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
placeholder="请选择发布时间"
style={{ width: 'auto' }}
/>
<CalendarDays className="absolute right-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
</div>
</div>
<div>
<Label htmlFor="content"></Label>
<Label className="font-bold flex items-center mb-2">
<span className="text-red-500 mr-1">*</span>
</Label>
<div className="relative">
<select
style={{ border: '1px solid #e0e0e0' }}
className="appearance-none w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 pr-10 text-base bg-white placeholder:text-gray-300"
value={materialType}
onChange={e => setMaterialType(Number(e.target.value))}
>
{MATERIAL_TYPES.map(type => (
<option key={type.id} value={type.id}>{type.name}</option>
))}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
</div>
</div>
</div>
<div className="border-b border-gray-100 my-4" />
{/* 内容信息分组(所有类型都展示内容和评论) */}
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
<Label htmlFor="content" className="font-bold flex items-center mb-2">
<span className="text-red-500 mr-1">*</span>
</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="请输入素材内容"
className="mt-1"
rows={5}
placeholder="请输入内容"
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[120px] bg-gray-50 placeholder:text-gray-300"
rows={10}
/>
</div>
<div>
<Label htmlFor="tags"></Label>
<div className="flex items-center mt-1">
<Input
id="tags"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="输入标签"
className="flex-1"
<div className="mt-4">
<Label htmlFor="comment" className="font-bold mb-2"></Label>
<Textarea
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="请输入评论内容"
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[80px] bg-gray-50 placeholder:text-gray-300"
rows={4}
/>
<Button type="button" onClick={handleAddTag} className="ml-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{tags.map((tag, index) => (
<Badge key={index} variant="secondary" className="flex items-center">
{tag}
</div>
{(materialType === 2 || materialType === 3) && (
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
{materialType === 2 && (
<div className="mb-4">
<Label htmlFor="desc" className="font-bold flex items-center mb-2">
<span className="text-red-500 mr-1">*</span>
</Label>
<Input
id="desc"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="请输入描述"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"/>
{/* 封面图上传 */}
<div className="mt-4">
<Label className="font-bold mb-2"></Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
className="rounded-2xl border-dashed border-2 border-blue-300 bg-white hover:bg-blue-50 h-28 w-28 flex flex-col items-center justify-center p-0"
onClick={() => {
const mock = [
"https://cdn-icons-png.flaticon.com/512/732/732212.png",
"https://cdn-icons-png.flaticon.com/512/5968/5968764.png",
"https://cdn-icons-png.flaticon.com/512/5968/5968705.png"
];
const random = mock[Math.floor(Math.random() * mock.length)];
setImage(random);
}}
>
{image ? (
<Image src={image} alt="封面图" width={80} height={80} className="object-contain rounded-xl mx-auto my-auto" />
) : (
<>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400 mx-auto" />
<span className="text-sm text-gray-500"></span>
</>
)}
</Button>
{image && (
<Button type="button" variant="destructive" size="sm" className="h-8 px-2 rounded-lg" onClick={() => setImage("")}></Button>
)}
</div>
<div className="text-xs text-gray-400 mt-1"> 80x80 PNG/JPG</div>
</div>
</div>
)}
{materialType === 2 && (
<>
<Label htmlFor="url" className="font-bold flex items-center mb-2">
<span className="text-red-500 mr-1">*</span>
</Label>
<Input
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="请输入链接地址"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
/>
</>
)}
{materialType === 3 && (
<div className="mt-4">
<Label className="font-bold mb-2"></Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
className="rounded-2xl border-dashed border-2 border-blue-300 bg-white hover:bg-blue-50 h-28 w-44 flex flex-col items-center justify-center p-0"
onClick={() => {
const mock = [
"https://www.w3schools.com/html/mov_bbb.mp4",
"https://www.w3schools.com/html/movie.mp4"
];
const random = mock[Math.floor(Math.random() * mock.length)];
setVideoUrl(random);
}}
>
{videoUrl ? (
<video src={videoUrl} controls className="object-contain rounded-xl h-24 w-40 mx-auto my-auto" />
) : (
<>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400 mx-auto" />
<span className="text-sm text-gray-500"></span>
</>
)}
</Button>
{videoUrl && (
<Button type="button" variant="destructive" size="sm" className="h-8 px-2 rounded-lg" onClick={() => setVideoUrl("")}></Button>
)}
</div>
<div className="text-xs text-gray-400 mt-1">MP420MB</div>
</div>
)}
</div>
)}
{/* 素材上传分组(仅图片类型和小程序类型) */}
{(materialType === 1 || materialType === 5) && (
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
{materialType === 1 && (
<>
<Label className="font-bold mb-2"></Label>
<div className="border border-dashed border-gray-300 rounded-2xl p-4 text-center bg-gray-50">
<Button
type="button"
variant="outline"
onClick={handleUploadImage}
className="w-full py-8 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
disabled={loading || images.length >= 9}
>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400" />
<span></span>
<span className="text-xs text-gray-500 mt-1">{`已上传${images.length}最多可上传9张`}</span>
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept="image/*"
/>
</div>
{previewUrls.length > 0 && (
<div className="mt-2">
<Label className="font-bold mb-2"></Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-2">
{previewUrls.map((url, index) => (
<div key={index} className="relative group">
<div className="aspect-square relative rounded-2xl overflow-hidden border border-gray-200">
<Image
src={url}
alt={`图片 ${index + 1}`}
fill
className="object-cover"
/>
</div>
<Button
type="button"
variant="ghost"
variant="destructive"
size="sm"
className="h-4 w-4 ml-1 p-0"
onClick={() => handleRemoveTag(tag)}
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0 rounded-full"
onClick={() => handleRemoveImage(index)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
</div>
))}
</div>
</div>
<Button type="submit" className="w-full">
)}
</>
)}
{materialType === 5 && (
<div className="space-y-6">
<div>
<Label htmlFor="appTitle" className="font-bold mb-2"></Label>
<Input
id="appTitle"
placeholder="请输入小程序名称"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
/>
</div>
<div>
<Label htmlFor="appId" className="font-bold mb-2">AppID</Label>
<Input
id="appId"
placeholder="请输入AppID"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
/>
</div>
<div>
<Label className="font-bold mb-2"></Label>
<div className="border border-dashed border-gray-300 rounded-2xl p-4 text-center bg-gray-50">
<Button
type="button"
variant="outline"
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = handleUploadImage;
input.click();
}}
className="w-full py-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
>
<UploadCloud className="h-6 w-6 mb-2 text-gray-400" />
<span></span>
</Button>
</div>
</div>
</div>
)}
</div>
)}
<Button type="submit" className="w-full h-12 rounded-2xl bg-blue-600 hover:bg-blue-700 text-base font-bold mt-12 shadow" disabled={loading}>
{loading ? "创建中..." : "保存素材"}
</Button>
</form>
</Card>
@@ -128,3 +451,4 @@ export default function NewMaterialPage({ params }: { params: { id: string } })
</div>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart } from "lucide-react"
import { useState, useEffect, useCallback, use } from "react"
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart, RefreshCw, Image as ImageIcon, Edit } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -9,101 +9,334 @@ import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { api } from "@/lib/api"
import { showToast } from "@/lib/toast"
import { Avatar } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
import { format } from "date-fns"
import Image from "next/image"
import { cn } from "@/lib/utils"
interface Material {
id: string
content: string
tags: string[]
aiAnalysis?: string
interface ApiResponse<T = any> {
code: number
msg: string
data: T
}
export default function MaterialsPage({ params }: { params: { id: string } }) {
interface MaterialListResponse {
list: Material[]
total: number
}
interface Material {
id: number
type: string
title: string
content: string
coverImage: string | null
resUrls: string[]
urls: string[]
createTime: string
createMomentTime: number
time: string
wechatId: string
friendId: string | null
wechatChatroomId: number
senderNickname: string
senderAvatar: string // 发布朋友圈用户的头像
location: string | null
lat: string
lng: string
}
const isImageUrl = (url: string) => {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) || url.includes('oss-cn-shenzhen.aliyuncs.com')
}
export default function MaterialsPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params)
const router = useRouter()
const [materials, setMaterials] = useState<Material[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const limit = 20
const [deleteDialogOpen, setDeleteDialogOpen] = useState<number | null>(null)
useEffect(() => {
const fetchMaterials = async () => {
const fetchMaterials = useCallback(async () => {
setIsLoading(true)
try {
// 模拟从API获取素材数据
await new Promise((resolve) => setTimeout(resolve, 500))
const mockMaterials: Material[] = [
{
id: "1",
content: "今天的阳光真好,适合出去走走",
tags: ["日常", "心情"],
},
{
id: "2",
content: "新品上市,限时优惠,快来抢购!",
tags: ["营销", "促销"],
},
{
id: "3",
content: "学习新技能的第一天,感觉很充实",
tags: ["学习", "成长"],
},
]
setMaterials(mockMaterials)
} catch (error) {
const queryParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
libraryId: resolvedParams.id,
...(searchQuery ? { keyword: searchQuery } : {})
})
const response = await api.get<ApiResponse<MaterialListResponse>>(`/v1/content/library/item-list?${queryParams.toString()}`)
if (response.code === 200 && response.data) {
setMaterials(response.data.list)
setTotal(response.data.total)
} else {
showToast(response.msg || "获取素材数据失败", "error")
}
} catch (error: any) {
console.error("Failed to fetch materials:", error)
toast({
title: "错误",
description: "获取素材数据失败",
variant: "destructive",
})
showToast(error?.message || "请检查网络连接", "error")
} finally {
setIsLoading(false)
}
}
}, [page, searchQuery, resolvedParams.id])
useEffect(() => {
fetchMaterials()
}, [])
}, [fetchMaterials])
const handleDownload = () => {
// 实现下载功能
toast({
title: "下载开始",
description: "正在将素材导出为Excel格式",
})
showToast("正在将素材导出为Excel格式", "loading")
}
const handleNewMaterial = () => {
// 实现新建素材功能
router.push(`/content/${params.id}/materials/new`)
router.push(`/content/${resolvedParams.id}/materials/new`)
}
const handleAIAnalysis = async (material: Material) => {
try {
// 模拟AI分析过程
await new Promise((resolve) => setTimeout(resolve, 1000))
const analysis = "这是一条" + material.tags.join("、") + "相关的内容,情感倾向积极。"
setMaterials(materials.map((m) => (m.id === material.id ? { ...m, aiAnalysis: analysis } : m)))
setSelectedMaterial({ ...material, aiAnalysis: analysis })
const analysis = "这是一条" + material.title + "相关的内容,情感倾向积极。"
setSelectedMaterial(material)
showToast("AI分析完成", "success")
} catch (error) {
console.error("AI analysis failed:", error)
toast({
title: "错误",
description: "AI分析失败",
variant: "destructive",
})
showToast("AI分析失败", "error")
}
}
const filteredMaterials = materials.filter(
(material) =>
material.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
material.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase())),
)
const handleSearch = () => {
setPage(1)
fetchMaterials()
}
if (isLoading) {
return <div className="flex justify-center items-center h-screen">...</div>
const handleRefresh = () => {
fetchMaterials()
}
const handleDelete = async (id: number) => {
const loadingToast = showToast("正在删除...", "loading", true)
try {
const response = await api.delete<ApiResponse>(`/v1/content/library/delete-item?id=${id}`)
if (response.code === 200) {
showToast("删除成功", "success")
fetchMaterials()
} else {
showToast(response.msg || "删除失败", "error")
}
} catch (error: any) {
showToast(error?.message || "删除失败", "error")
} finally {
loadingToast.remove && loadingToast.remove()
setDeleteDialogOpen(null)
}
}
// 新增:根据类型渲染内容
const renderMaterialByType = (material: any) => {
const type = Number(material.contentType || material.type);
// 链接类型
if (type === 2 && material.urls && material.urls.length > 0) {
const first = material.urls[0];
return (
<a
href={first.url}
target="_blank"
rel="noopener noreferrer"
className=" items-center bg-white rounded p-2 hover:bg-gray-50 transition group"
style={{ textDecoration: 'none' }}
>
<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 ?? '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">{first.desc ?? '这是一条链接'}</div>
</div>
</div>
</a>
);
}
// 视频类型
if (type === 3 && material.urls && material.urls.length > 0) {
const first = material.urls[0];
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;
}
// 文本类型
if (type === 4 || type === 6) {
return (
<div className="mb-3">
<p className="text-gray-700 whitespace-pre-line">{material.content}</p>
</div>
);
}
// 小程序类型
if (type === 5 && material.urls && material.urls.length > 0) {
const first = material.urls[0];
return (
<div className="mb-3">
<div>{first.appTitle}</div>
<div>AppID{first.appId}</div>
{first.image && (
<div className="mb-2">
<Image src={first.image} alt="小程序封面图" width={80} height={80} className="rounded" />
</div>
)}
</div>
);
}
// 图片类型
if (type === 1) {
return (
<div className="mb-3">
{/* 内容字段(如有) */}
{material.content && (
<div className="mb-2 text-base font-medium text-gray-800 whitespace-pre-line">
{material.content}
</div>
)}
{/* 图片资源 */}
{renderImageResources(material)}
</div>
);
}
// 其它类型
return null;
}
// 处理图片资源
const renderImageResources = (material: Material) => {
const imageUrls = material.resUrls.filter(isImageUrl)
// 如果内容本身是图片,也添加到图片数组中
if (isImageUrl(material.content) && !imageUrls.includes(material.content)) {
imageUrls.unshift(material.content)
}
if (imageUrls.length === 0) return null
// 微信朋友圈风格的图片布局
if (imageUrls.length === 1) {
// 单张图片:大图显示
return (
<div className="relative rounded-md overflow-hidden">
<Image
src={imageUrls[0]}
alt="图片内容"
width={600}
height={400}
className="object-cover w-full h-auto"
/>
</div>
)
} else if (imageUrls.length === 2) {
// 两张图片:横向排列
return (
<div className="grid grid-cols-2 gap-2 mb-3">
{imageUrls.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
<Image
src={url}
alt={`图片 ${idx + 1}`}
fill
className="object-cover"
/>
</div>
))}
</div>
)
} else if (imageUrls.length === 3) {
// 三张图片使用3x3网格的前三个格子
return (
<div className="grid grid-cols-3 gap-2 mb-3">
{imageUrls.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
<Image
src={url}
alt={`图片 ${idx + 1}`}
fill
className="object-cover"
/>
</div>
))}
</div>
)
} else if (imageUrls.length === 4) {
// 四张图片2x2网格
return (
<div className="grid grid-cols-2 gap-2 mb-3">
{imageUrls.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
<Image
src={url}
alt={`图片 ${idx + 1}`}
fill
className="object-cover"
/>
</div>
))}
</div>
)
} else {
// 五张及以上3x3网格
const displayImages = imageUrls.slice(0, 9)
const hasMore = imageUrls.length > 9
return (
<div className="grid grid-cols-3 gap-2 mb-3">
{displayImages.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
<Image
src={url}
alt={`图片 ${idx + 1}`}
fill
className="object-cover"
/>
{idx === 8 && hasMore && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<span className="text-white text-lg font-medium">+{imageUrls.length - 9}</span>
</div>
)}
</div>
))}
</div>
)
}
}
const handleSubmit = async () => {
fetchMaterials()
}
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<div className="flex-1 bg-gray-50 min-h-screen pb-20">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
@@ -113,10 +346,6 @@ export default function MaterialsPage({ params }: { params: { id: string } }) {
<h1 className="text-lg font-medium"></h1>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
Excel
</Button>
<Button onClick={handleNewMaterial}>
<Plus className="h-4 w-4 mr-2" />
@@ -126,36 +355,117 @@ export default function MaterialsPage({ params }: { params: { id: string } }) {
</header>
<div className="p-4">
<Card className="p-4">
<div className="space-y-4">
<div className="relative">
<Card className="p-4 mb-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索素材或标签..."
placeholder="搜索素材..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</Card>
{isLoading ? (
// 加载状态
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<Card key={index} className="p-4">
<div className="flex items-center space-x-3 mb-3">
<div className="w-10 h-10 rounded-full bg-gray-200 animate-pulse"></div>
<div className="space-y-2">
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded"></div>
<div className="h-3 w-16 bg-gray-200 animate-pulse rounded"></div>
</div>
</div>
<div className="my-3 h-0.5 bg-gray-100"></div>
<div className="space-y-2">
{filteredMaterials.map((material) => (
<div key={material.id} className="flex items-center justify-between bg-white p-3 rounded-lg shadow">
<div className="flex-1">
<p className="text-sm text-gray-600 mb-2">{material.content}</p>
<div className="flex flex-wrap gap-2">
{material.tags.map((tag, index) => (
<div className="h-4 w-full bg-gray-200 animate-pulse rounded"></div>
<div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded"></div>
<div className="flex space-x-2 mt-3">
<div className="h-20 w-20 bg-gray-200 animate-pulse rounded"></div>
<div className="h-20 w-20 bg-gray-200 animate-pulse rounded"></div>
</div>
</div>
</Card>
))}
</div>
) : (
// 素材列表
<div className="space-y-4">
{materials.length === 0 ? (
<Card className="p-8 text-center text-gray-500">
</Card>
) : (
materials.map(material => (
<Card key={material.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<Avatar>
<Image
src={material.senderAvatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${material.senderNickname}`}
alt={material.senderNickname}
width={40}
height={40}
className="rounded-full"
/>
</Avatar>
<div>
<div className="font-medium">{material.senderNickname}</div>
<div className="text-sm text-gray-500">
{material.time && format(new Date(material.time), 'yyyy-MM-dd HH:mm')}
</div>
</div>
</div>
<Badge variant="outline" className="bg-blue-50">
ID: {material.id}
</Badge>
</div>
<Separator className="my-3" />
{/* 类型分发内容渲染 */}
{renderMaterialByType(material)}
{/* 非图片资源标签 */}
{material.resUrls.length > 0 && !material.resUrls.some(isImageUrl) && (
<div className="flex flex-wrap gap-2 mb-3">
{material.resUrls.map((url, index) => (
<Badge key={index} variant="secondary">
<Tag className="h-3 w-3 mr-1" />
{tag}
{index + 1}
</Badge>
))}
</div>
</div>
<div className="flex items-center space-x-2">
)}
<div className="flex items-center justify-between mt-4">
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
className="px-3 h-8 text-xs"
onClick={() => router.push(`/content/${resolvedParams.id}/materials/edit/${material.id}`)}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={() => handleAIAnalysis(material)}>
<Button variant="outline" size="sm" className="px-3 h-8 text-xs">
<BarChart className="h-4 w-4 mr-1" />
AI分析
</Button>
@@ -165,20 +475,62 @@ export default function MaterialsPage({ params }: { params: { id: string } }) {
<DialogTitle>AI </DialogTitle>
</DialogHeader>
<div className="mt-4">
<p>{selectedMaterial?.aiAnalysis || "正在分析中..."}</p>
<p>...</p>
</div>
</DialogContent>
</Dialog>
</div>
<Dialog open={deleteDialogOpen === material.id} onOpenChange={(open) => setDeleteDialogOpen(open ? material.id : null)}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm" className="px-3 h-8 text-xs">
<Trash2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="mt-4 mb-4 text-sm text-gray-700"></div>
<div className="flex justify-end space-x-2">
<Button variant="outline" size="sm" onClick={() => setDeleteDialogOpen(null)}></Button>
<Button variant="destructive" size="sm" onClick={() => handleDelete(material.id)}></Button>
</div>
</DialogContent>
</Dialog>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</Card>
))
)}
</div>
))}
</div>
)}
{!isLoading && total > limit && (
<div className="flex justify-center mt-6">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => setPage(prev => Math.max(prev - 1, 1))}
className="mx-1"
>
</Button>
<span className="mx-4 py-2 text-sm text-gray-500">
{page} {Math.ceil(total / limit)}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= Math.ceil(total / limit)}
onClick={() => setPage(prev => prev + 1)}
className="mx-1"
>
</Button>
</div>
</Card>
)}
</div>
</div>
)
}

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,56 +1223,34 @@ 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>
{uploadedOrderTableFile && (
<div className="mt-2 text-xs text-green-600 text-left">{uploadedOrderTableFile}</div>
)}
<div className="text-xs text-gray-500 mt-1"> CSVExcel </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>
)}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
{String(formData.scenario) === "weixinqun" && (
@@ -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

@@ -2,52 +2,243 @@
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft } from "lucide-react"
import { ChevronLeft, MoreVertical, Clock, Edit, Trash2, Copy, RefreshCw, FileText, MessageSquare, History } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Avatar } from "@/components/ui/avatar"
import { Switch } from "@/components/ui/switch"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { api, ApiResponse } from "@/lib/api"
import { showToast } from "@/lib/toast"
interface SyncTask {
// 定义任务详情的接口
interface TaskDetail {
id: string
name: string
status: "running" | "paused"
deviceCount: number
contentLib: string
syncType: number
accountType: number
syncCount: number
syncInterval: number
startTime: string
endTime: string
enabled: boolean
config: any
lastSyncTime: string
createTime: string
creator: string
}
export default function ViewMomentsSyncTask({ params }: { params: { id: string } }) {
// 定义同步历史的接口
interface SyncHistory {
id: string
syncTime: string
content: string
contentType: "text" | "image" | "video"
status: "success" | "failed"
errorMessage?: string
}
// 新增朋友圈发布记录类型
type MomentRecord = {
id: number
workbenchId: number
publishTime: number
contentType: number // 1文本 2视频 3图片
content: string
resUrls: string[]
urls: string[]
operatorName: string
operatorAvatar: string
}
export default function MomentsSyncDetailPage({ params }: { params: { id: string } }) {
const router = useRouter()
const [task, setTask] = useState<SyncTask | null>(null)
const [taskDetail, setTaskDetail] = useState<TaskDetail | null>(null)
const [syncHistory, setSyncHistory] = useState<SyncHistory[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState("overview")
const [showDeleteAlert, setShowDeleteAlert] = useState(false)
const [momentRecords, setMomentRecords] = useState<MomentRecord[]>([])
const [isMomentLoading, setIsMomentLoading] = useState(false)
// 获取任务详情
useEffect(() => {
// Fetch task data from API
// For now, we'll use mock data
setTask({
id: params.id,
name: "同步卡若主号",
deviceCount: 2,
contentLib: "卡若朋友圈",
syncCount: 307,
lastSyncTime: "2025-02-06 13:12:35",
createTime: "2024-11-20 19:04:14",
creator: "karuo",
status: "running",
})
}, [params.id])
const fetchTaskDetail = async () => {
setIsLoading(true)
try {
const response = await api.get<ApiResponse>(`/v1/workbench/detail?id=${params.id}`)
if (response.code === 200 && response.data) {
setTaskDetail(response.data)
// 获取同步历史
if (activeTab === "history") {
fetchSyncHistory()
}
} else {
showToast(response.msg || "获取任务详情失败", "error")
router.push("/workspace/moments-sync")
}
} catch (error: any) {
console.error("获取任务详情失败:", error)
showToast(error?.message || "获取任务详情失败", "error")
router.push("/workspace/moments-sync")
} finally {
setIsLoading(false)
}
}
const toggleTaskStatus = () => {
if (task) {
setTask({ ...task, status: task.status === "running" ? "paused" : "running" })
fetchTaskDetail()
}, [params.id, router])
// 获取同步历史
const fetchSyncHistory = async () => {
try {
const response = await api.get<ApiResponse>(`/v1/workbench/sync/history?id=${params.id}`)
if (response.code === 200 && response.data) {
setSyncHistory(response.data.list || [])
} else {
setSyncHistory([])
}
} catch (error) {
console.error("获取同步历史失败:", error)
setSyncHistory([])
}
}
if (!task) {
return <div>Loading...</div>
// 获取朋友圈发布记录
type MomentsApiResponse = { code: number; msg: string; data: { list: MomentRecord[] } }
const fetchMomentRecords = async () => {
setIsMomentLoading(true)
try {
const response = await api.get<MomentsApiResponse>(`/v1/workbench/moments-records?workbenchId=${params.id}`)
if (response.code === 200 && response.data) {
setMomentRecords(response.data.list || [])
} else {
setMomentRecords([])
}
} catch (error) {
setMomentRecords([])
} finally {
setIsMomentLoading(false)
}
}
// 切换Tab时加载数据
const handleTabChange = (value: string) => {
setActiveTab(value)
if (value === "history" && syncHistory.length === 0) {
fetchSyncHistory()
}
if (value === "moments" && momentRecords.length === 0) {
fetchMomentRecords()
}
}
// 切换任务状态
const toggleTaskStatus = async () => {
if (!taskDetail) return
try {
const newStatus = taskDetail.status === "running" ? "paused" : "running"
const response = await api.post<ApiResponse>('/v1/workbench/update/status', {
id: params.id,
status: newStatus === "running" ? 1 : 0
})
if (response.code === 200) {
setTaskDetail({
...taskDetail,
status: newStatus
})
showToast(`任务已${newStatus === "running" ? "启用" : "暂停"}`, "success")
} else {
showToast(response.msg || "操作失败", "error")
}
} catch (error: any) {
console.error("更新任务状态失败:", error)
showToast(error?.message || "更新任务状态失败", "error")
}
}
// 编辑任务
const handleEdit = () => {
router.push(`/workspace/moments-sync/${params.id}/edit`)
}
// 确认删除
const confirmDelete = () => {
setShowDeleteAlert(true)
}
// 执行删除
const handleDelete = async () => {
try {
const response = await api.post<ApiResponse>('/v1/workbench/delete', {
id: params.id
})
if (response.code === 200) {
showToast("删除成功", "success")
router.push("/workspace/moments-sync")
} else {
showToast(response.msg || "删除失败", "error")
}
} catch (error: any) {
console.error("删除任务失败:", error)
showToast(error?.message || "删除任务失败", "error")
} finally {
setShowDeleteAlert(false)
}
}
// 复制任务
const handleCopy = async () => {
try {
const response = await api.post<ApiResponse>('/v1/workbench/copy', {
id: params.id
})
if (response.code === 200) {
showToast("复制成功,正在跳转到新任务", "success")
// 假设后端返回了新任务的ID
if (response.data?.id) {
router.push(`/workspace/moments-sync/${response.data.id}`)
} else {
router.push("/workspace/moments-sync")
}
} else {
showToast(response.msg || "复制失败", "error")
}
} catch (error: any) {
console.error("复制任务失败:", error)
showToast(error?.message || "复制任务失败", "error")
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-[#F8F9FA] flex justify-center items-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-500">...</p>
</div>
</div>
)
}
if (!taskDetail) {
return (
<div className="min-h-screen bg-[#F8F9FA] flex justify-center items-center">
<div className="text-center">
<p className="text-gray-500 mb-4"></p>
<Button onClick={() => router.push("/workspace/moments-sync")}></Button>
</div>
</div>
)
}
return (
@@ -55,53 +246,222 @@ export default function ViewMomentsSyncTask({ params }: { params: { id: string }
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<Button variant="ghost" size="icon" onClick={() => router.push("/workspace/moments-sync")}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-lg font-medium"></h1>
<h1 className="text-lg font-medium"></h1>
</div>
<Button onClick={() => router.push(`/workspace/moments-sync/${task.id}/edit`)}></Button>
</div>
</header>
<div className="p-4">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<h2 className="text-2xl font-bold">{task.name}</h2>
<Badge variant={task.status === "running" ? "success" : "secondary"}>
{task.status === "running" ? "进行中" : "已暂停"}
</Badge>
</div>
<Switch checked={task.status === "running"} onCheckedChange={toggleTaskStatus} />
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="space-y-2">
<p>{task.deviceCount} </p>
<p>{task.contentLib}</p>
<p>{task.syncCount} </p>
<p>{task.creator}</p>
<Card className="mb-4">
<div className="p-4 border-b">
<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 == 1 ? "success" : "secondary"}>
{taskDetail.status == 1 ? "进行中" : "已暂停"}
</Badge>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="space-y-2">
<p>{task.createTime}</p>
<p>{task.lastSyncTime}</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm text-gray-500">
<div>{taskDetail.createTime}</div>
<div>{taskDetail.creator}</div>
<div>{taskDetail.lastSyncTime}</div>
<div>{taskDetail.syncCount} </div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-lg font-semibold mb-2"></h3>
{/* Add content preview here */}
<p className="text-gray-500"></p>
</div>
</Card>
<Tabs value={activeTab} onValueChange={handleTabChange} className="mb-4">
<TabsList className="grid grid-cols-4">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="devices"></TabsTrigger>
<TabsTrigger value="history"></TabsTrigger>
<TabsTrigger value="moments"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<Card className="p-4">
<div className="space-y-4">
<div>
<div className="font-medium mb-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.config.syncInterval} </div>
</div>
<div>
<div className="font-medium mb-1"></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.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.config.contentLibraries.map((lib) => (
<Badge key={lib.id} variant="outline" className="bg-blue-50">
{lib.name}
</Badge>
))}
</div>
</div>
</div>
</Card>
</TabsContent>
<TabsContent value="devices" className="mt-4">
<Card className="p-4">
{taskDetail.config.deviceList.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<div className="divide-y">
{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.nickname} />
) : (
<div className="bg-blue-100 text-blue-600 h-full w-full flex items-center justify-center">
{device.nickname}
</div>
)}
</Avatar>
<div>
<div className="font-medium">{device.nickname}</div>
<div className="text-xs text-gray-500">{device.alias || device.wechatId}</div>
</div>
</div>
))}
</div>
)}
</Card>
</TabsContent>
<TabsContent value="history" className="mt-4">
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
<Button variant="outline" size="sm" onClick={fetchSyncHistory}>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
</div>
{syncHistory.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<div className="space-y-4">
{syncHistory.map((record) => (
<div key={record.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center">
{record.contentType === "text" && <FileText className="h-4 w-4 mr-2 text-gray-500" />}
{record.contentType === "image" && <img className="h-4 w-4 mr-2" src="/icons/image.svg" alt="图片" />}
{record.contentType === "video" && <img className="h-4 w-4 mr-2" src="/icons/video.svg" alt="视频" />}
<Badge variant={record.status === "success" ? "success" : "destructive"} className="text-xs">
{record.status === "success" ? "成功" : "失败"}
</Badge>
</div>
<div className="text-xs text-gray-500">{record.syncTime}</div>
</div>
<div className="text-sm text-gray-700 line-clamp-2">{record.content}</div>
{record.status === "failed" && record.errorMessage && (
<div className="mt-2 text-xs text-red-500">
: {record.errorMessage}
</div>
)}
</div>
))}
</div>
)}
</Card>
</TabsContent>
<TabsContent value="moments" className="mt-4">
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
<Button variant="outline" size="sm" onClick={fetchMomentRecords} disabled={isMomentLoading}>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
</div>
{isMomentLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : momentRecords.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<div className="space-y-4">
{momentRecords.map((rec) => (
<div key={rec.id} className="border rounded-lg p-3 flex gap-3">
<Avatar className="h-10 w-10">
{rec.operatorAvatar ? (
<img src={rec.operatorAvatar} alt={rec.operatorName} />
) : (
<div className="bg-blue-100 text-blue-600 h-full w-full flex items-center justify-center">
{rec.operatorName?.charAt(0) || "?"}
</div>
)}
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{rec.operatorName}</span>
<span className="text-xs text-gray-400">{rec.publishTime ? new Date(rec.publishTime * 1000).toLocaleString() : "-"}</span>
</div>
<div className="mb-1 text-gray-800 text-sm">
{rec.contentType === 1 && rec.content}
{rec.contentType === 3 && rec.content}
</div>
{/* 图片展示 */}
{rec.contentType === 3 && rec.resUrls && rec.resUrls.length > 0 && (
<div className="flex gap-2 flex-wrap mt-1">
{rec.resUrls.map((url, idx) => (
<img key={idx} src={url} alt="图片" className="h-20 w-20 object-cover rounded" />
))}
</div>
)}
{/* 视频展示 */}
{rec.contentType === 2 && rec.urls && rec.urls.length > 0 && (
<div className="mt-1">
{rec.urls.map((url, idx) => (
<video key={idx} src={url} controls className="h-32 w-48 rounded" />
))}
</div>
)}
</div>
</div>
))}
</div>
)}
</Card>
</TabsContent>
</Tabs>
</div>
{/* 删除确认对话框 */}
<AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}