feat: 本次提交更新内容如下
场景获客列表搞定
This commit is contained in:
@@ -2,162 +2,59 @@
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Plus, X, Image as ImageIcon, UploadCloud, Link, Video, FileText, Layers, CalendarDays, ChevronDown } from "lucide-react"
|
||||
import { ChevronLeft, Plus, X } 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 [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 [newTag, setNewTag] = useState("")
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
|
||||
// 图片上传
|
||||
const handleUploadImage = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 {
|
||||
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 handleAddTag = () => {
|
||||
if (newTag && !tags.includes(newTag)) {
|
||||
setTags([...tags, newTag])
|
||||
setNewTag("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveImage = (indexToRemove: number) => {
|
||||
setImages(images.filter((_, index) => index !== indexToRemove))
|
||||
setPreviewUrls(previewUrls.filter((_, index) => index !== indexToRemove))
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setTags(tags.filter((tag) => tag !== tagToRemove))
|
||||
}
|
||||
|
||||
// 创建素材
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// 校验
|
||||
if (!content) {
|
||||
showToast("请输入内容", "error")
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入素材内容",
|
||||
variant: "destructive",
|
||||
})
|
||||
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")
|
||||
// 模拟保存新素材
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "新素材已创建",
|
||||
})
|
||||
router.push(`/content/${params.id}/materials`)
|
||||
} else {
|
||||
showToast(response.msg || "创建失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || "创建素材失败", "error")
|
||||
} finally {
|
||||
loadingToast.remove && loadingToast.remove()
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error("Failed to create new material:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "创建新素材失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,273 +70,57 @@ export default function NewMaterialPage({ params }: { params: { id: string } })
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-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>
|
||||
<Card className="p-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<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>
|
||||
<Label htmlFor="content">素材内容</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(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-[120px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={10}
|
||||
placeholder="请输入素材内容"
|
||||
className="mt-1"
|
||||
rows={5}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tags">标签</Label>
|
||||
<div className="flex items-center mt-1">
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
id="tags"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="button" onClick={handleAddTag} className="ml-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加
|
||||
</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">支持MP4,建议不超过20MB</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}
|
||||
>
|
||||
<UploadCloud className="h-8 w-8 mb-2 text-gray-400" />
|
||||
<span>点击上传图片</span>
|
||||
<span className="text-xs text-gray-500 mt-1">支持 JPG、PNG 格式</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>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center">
|
||||
{tag}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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)}
|
||||
className="h-4 w-4 ml-1 p-0"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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 type="submit" className="w-full">
|
||||
保存素材
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
@@ -447,4 +128,3 @@ export default function NewMaterialPage({ params }: { params: { id: string } })
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, use } from "react"
|
||||
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart, RefreshCw, Image as ImageIcon, Edit } from "lucide-react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -9,327 +9,101 @@ 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 ApiResponse<T = any> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface MaterialListResponse {
|
||||
list: Material[]
|
||||
total: number
|
||||
}
|
||||
|
||||
interface Material {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
id: 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
|
||||
tags: string[]
|
||||
aiAnalysis?: 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)
|
||||
export default function MaterialsPage({ params }: { params: { id: string } }) {
|
||||
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)
|
||||
|
||||
const fetchMaterials = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
const fetchMaterials = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
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) {
|
||||
// 模拟从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) {
|
||||
console.error("Failed to fetch materials:", error)
|
||||
showToast(error?.message || "请检查网络连接", "error")
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "获取素材数据失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [page, searchQuery, resolvedParams.id])
|
||||
|
||||
useEffect(() => {
|
||||
}
|
||||
fetchMaterials()
|
||||
}, [fetchMaterials])
|
||||
}, [])
|
||||
|
||||
const handleDownload = () => {
|
||||
showToast("正在将素材导出为Excel格式", "loading")
|
||||
// 实现下载功能
|
||||
toast({
|
||||
title: "下载开始",
|
||||
description: "正在将素材导出为Excel格式",
|
||||
})
|
||||
}
|
||||
|
||||
const handleNewMaterial = () => {
|
||||
router.push(`/content/${resolvedParams.id}/materials/new`)
|
||||
// 实现新建素材功能
|
||||
router.push(`/content/${params.id}/materials/new`)
|
||||
}
|
||||
|
||||
const handleAIAnalysis = async (material: Material) => {
|
||||
try {
|
||||
// 模拟AI分析过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
const analysis = "这是一条" + material.title + "相关的内容,情感倾向积极。"
|
||||
setSelectedMaterial(material)
|
||||
showToast("AI分析完成", "success")
|
||||
const analysis = "这是一条" + material.tags.join("、") + "相关的内容,情感倾向积极。"
|
||||
setMaterials(materials.map((m) => (m.id === material.id ? { ...m, aiAnalysis: analysis } : m)))
|
||||
setSelectedMaterial({ ...material, aiAnalysis: analysis })
|
||||
} catch (error) {
|
||||
console.error("AI analysis failed:", error)
|
||||
showToast("AI分析失败", "error")
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "AI分析失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
fetchMaterials()
|
||||
}
|
||||
const filteredMaterials = materials.filter(
|
||||
(material) =>
|
||||
material.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
material.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
)
|
||||
|
||||
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="flex items-center bg-white rounded p-2 hover:bg-gray-50 transition group"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
{first.image && (
|
||||
<div className="flex-shrink-0 w-14 h-14 rounded overflow-hidden mr-3 bg-gray-100">
|
||||
<Image
|
||||
src={first.image}
|
||||
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>
|
||||
</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">
|
||||
<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()
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center items-center h-screen">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-20">
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<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">
|
||||
@@ -339,6 +113,10 @@ export default function MaterialsPage({ params }: { params: Promise<{ 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" />
|
||||
新建素材
|
||||
@@ -348,117 +126,36 @@ export default function MaterialsPage({ params }: { params: Promise<{ id: string
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
className="pl-9"
|
||||
placeholder="搜索素材或标签..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9"
|
||||
/>
|
||||
</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">
|
||||
<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) => (
|
||||
{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) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Tag className="h-3 w-3 mr-1" />
|
||||
资源 {index + 1}
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="px-3 h-8 text-xs">
|
||||
<Button variant="outline" size="sm" onClick={() => handleAIAnalysis(material)}>
|
||||
<BarChart className="h-4 w-4 mr-1" />
|
||||
AI分析
|
||||
</Button>
|
||||
@@ -468,62 +165,20 @@ export default function MaterialsPage({ params }: { params: Promise<{ id: string
|
||||
<DialogTitle>AI 分析结果</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<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>
|
||||
<p>{selectedMaterial?.aiAnalysis || "正在分析中..."}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -287,4 +287,3 @@ export default function ContentLibraryPage({ params }: { params: { id: string }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user