内容库素材列表 + 编辑

This commit is contained in:
wong
2025-05-12 09:32:27 +08:00
parent 50cd82f5d3
commit bb20d5182c
8 changed files with 943 additions and 233 deletions

View File

@@ -0,0 +1,248 @@
"use client"
import type React from "react"
import { useState, useEffect, use } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, Plus, X, Image as ImageIcon, UploadCloud } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/use-toast"
import { api } from "@/lib/api"
import { showToast } from "@/lib/toast"
import Image from "next/image"
interface ApiResponse<T = any> {
code: number
msg: string
data: T
}
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
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 EditMaterialPage({ params }: { params: Promise<{ id: string, materialId: string }> }) {
const resolvedParams = use(params)
const router = useRouter()
const [isLoading, setIsLoading] = useState(true)
const [content, setContent] = useState("")
const [images, setImages] = useState<string[]>([])
const [previewUrls, setPreviewUrls] = useState<string[]>([])
const [originalMaterial, setOriginalMaterial] = useState<Material | null>(null)
// 获取素材详情
useEffect(() => {
const fetchMaterialDetail = async () => {
setIsLoading(true)
try {
const response = await api.get<ApiResponse<Material>>(`/v1/content/library/get-item-detail?id=${resolvedParams.materialId}`)
if (response.code === 200 && response.data) {
const material = response.data
setOriginalMaterial(material)
setContent(material.content)
// 处理图片
const imageUrls: string[] = []
// 检查内容本身是否为图片链接
if (isImageUrl(material.content)) {
if (!imageUrls.includes(material.content)) {
imageUrls.push(material.content)
}
}
// 添加资源URL中的图片
material.resUrls.forEach(url => {
if (isImageUrl(url) && !imageUrls.includes(url)) {
imageUrls.push(url)
}
})
setImages(imageUrls)
setPreviewUrls(imageUrls)
} else {
showToast(response.msg || "获取素材详情失败", "error")
router.back()
}
} catch (error: any) {
console.error("Failed to fetch material detail:", error)
showToast(error?.message || "请检查网络连接", "error")
router.back()
} finally {
setIsLoading(false)
}
}
fetchMaterialDetail()
}, [resolvedParams.materialId, router])
// 模拟上传图片
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])
}
}
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 && images.length === 0) {
showToast("请输入素材内容或上传图片", "error")
return
}
const loadingToast = showToast("正在更新素材...", "loading", true)
try {
const response = await api.post<ApiResponse>('/v1/content/library/update-item', {
id: resolvedParams.materialId,
content: content,
resUrls: images
})
if (response.code === 200) {
showToast("素材更新成功", "success")
router.push(`/content/${resolvedParams.id}/materials`)
} else {
showToast(response.msg || "更新失败", "error")
}
} catch (error: any) {
console.error("Failed to update material:", error)
showToast(error?.message || "更新失败", "error")
} finally {
loadingToast.remove && loadingToast.remove()
}
}
if (isLoading) {
return (
<div className="flex-1 bg-gray-50 min-h-screen flex items-center justify-center">
<div className="text-center">...</div>
</div>
)
}
return (
<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">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-lg font-medium"></h1>
</div>
</div>
</header>
<div className="p-4">
<Card className="p-4">
<form onSubmit={handleSubmit} className="space-y-4">
{/* 只有当内容不是图片链接时才显示内容编辑区 */}
{!isImageUrl(content) && (
<div>
<Label htmlFor="content"></Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="请输入素材内容"
className="mt-1"
rows={5}
/>
</div>
)}
<div>
<Label></Label>
<div className="mt-2 border border-dashed border-gray-300 rounded-lg p-4 text-center">
<Button
type="button"
variant="outline"
onClick={handleUploadImage}
className="w-full py-8 flex flex-col items-center justify-center"
>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400" />
<span></span>
<span className="text-xs text-gray-500 mt-1"> JPGPNG </span>
</Button>
</div>
{previewUrls.length > 0 && (
<div className="mt-4">
<Label></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-lg overflow-hidden border border-gray-200">
<Image
src={url}
alt={`图片 ${index + 1}`}
fill
className="object-cover"
/>
</div>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
onClick={() => handleRemoveImage(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
<Button type="submit" className="w-full">
</Button>
</form>
</Card>
</div>
</div>
)
}

View File

@@ -4,38 +4,50 @@ import type React from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, Plus, X } from "lucide-react"
import { ChevronLeft, Plus, X, Image as ImageIcon, UploadCloud } 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"
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 handleAddTag = () => {
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag])
setNewTag("")
// 模拟上传图片
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])
}
}
const handleRemoveTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove))
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) {
if (!content && images.length === 0) {
toast({
title: "错误",
description: "请输入素材内容",
description: "请输入素材内容或上传图片",
variant: "destructive",
})
return
@@ -87,36 +99,48 @@ export default function NewMaterialPage({ params }: { params: { id: string } })
</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"
/>
<Button type="button" onClick={handleAddTag} className="ml-2">
<Plus className="h-4 w-4" />
<Label></Label>
<div className="mt-2 border border-dashed border-gray-300 rounded-lg p-4 text-center">
<Button
type="button"
variant="outline"
onClick={handleUploadImage}
className="w-full py-8 flex flex-col items-center justify-center"
>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400" />
<span></span>
<span className="text-xs text-gray-500 mt-1"> JPGPNG </span>
</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}
<Button
type="button"
variant="ghost"
size="sm"
className="h-4 w-4 ml-1 p-0"
onClick={() => handleRemoveTag(tag)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
{previewUrls.length > 0 && (
<div className="mt-4">
<Label></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-lg overflow-hidden border border-gray-200">
<Image
src={url}
alt={`图片 ${index + 1}`}
fill
className="object-cover"
/>
</div>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
onClick={() => handleRemoveImage(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
<Button type="submit" className="w-full">

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,99 +9,170 @@ 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 Image from "next/image"
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
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')
}
const ContentDisplay = ({ content, resUrls }: { content: string, resUrls: string[] }) => {
if (isImageUrl(content)) {
return (
<div className="relative w-full h-48 mb-2">
<Image
src={content}
alt="素材图片"
fill
className="object-contain rounded-lg"
/>
</div>
)
}
if (resUrls.length > 0 && resUrls.some(isImageUrl)) {
return (
<div className="grid grid-cols-2 gap-2 mb-2">
{resUrls.filter(isImageUrl).map((url, index) => (
<div key={index} className="relative w-full h-32">
<Image
src={url}
alt={`素材图片 ${index + 1}`}
fill
className="object-contain rounded-lg"
/>
</div>
))}
</div>
)
}
return <div className="text-sm text-gray-600 mb-2" style={{ whiteSpace: 'pre-line' }}>{content}</div>
}
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)
const fetchMaterials = useCallback(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) {
console.error("Failed to fetch materials:", error)
showToast(error?.message || "请检查网络连接", "error")
} finally {
setIsLoading(false)
}
}, [page, searchQuery, resolvedParams.id])
useEffect(() => {
const fetchMaterials = 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) {
console.error("Failed to fetch materials:", error)
toast({
title: "错误",
description: "获取素材数据失败",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
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())),
)
if (isLoading) {
return <div className="flex justify-center items-center h-screen">...</div>
const handleSearch = () => {
setPage(1)
fetchMaterials()
}
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 filteredMaterials = materials
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white border-b">
@@ -113,10 +184,12 @@ export default function MaterialsPage({ params }: { params: { id: string } }) {
<h1 className="text-lg font-medium"></h1>
</div>
<div className="flex items-center space-x-2">
{/* 已隐藏下载Excel按钮
<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" />
@@ -128,53 +201,115 @@ export default function MaterialsPage({ params }: { params: { id: string } }) {
<div className="p-4">
<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="搜索素材或标签..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
<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="搜索素材..."
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>
<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) => (
<Badge key={index} variant="secondary">
<Tag className="h-3 w-3 mr-1" />
{tag}
</Badge>
))}
</div>
</div>
<div className="flex items-center space-x-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={() => handleAIAnalysis(material)}>
<BarChart className="h-4 w-4 mr-1" />
AI分析
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>AI </DialogTitle>
</DialogHeader>
<div className="mt-4">
<p>{selectedMaterial?.aiAnalysis || "正在分析中..."}</p>
</div>
</DialogContent>
</Dialog>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
<div className="space-y-4">
{isLoading ? (
<div className="flex justify-center items-center py-12">
<RefreshCw className="h-8 w-8 text-blue-500 animate-spin" />
</div>
) : filteredMaterials.length === 0 ? (
<div className="flex justify-center items-center py-12">
<div className="text-center">
<p className="text-gray-500 mb-4"></p>
<Button onClick={handleNewMaterial} size="sm">
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
))}
) : (
filteredMaterials.map((material) => (
<div
key={material.id}
className="bg-white rounded-2xl shadow-md p-5 flex flex-col gap-3 mb-4 border border-gray-100"
>
{/* 图片/内容区 */}
<ContentDisplay content={material.content} resUrls={material.resUrls} />
{/* 资源标签(非图片) */}
{material.resUrls.length > 0 && !material.resUrls.some(isImageUrl) && (
<div className="flex flex-wrap gap-2 mb-1">
{material.resUrls.map((url, index) => (
<Badge key={index} variant="secondary">
<Tag className="h-3 w-3 mr-1" />
{index + 1}
</Badge>
))}
</div>
)}
{/* 底部信息区 */}
<div className="pt-2 border-t border-gray-100 mt-2 text-xs text-gray-500">
<div className="flex justify-between items-center">
<span>: {material.senderNickname}</span>
<span>: {material.time}</span>
</div>
<div className="flex justify-between items-center mt-2">
<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" className="px-3 h-8 text-xs">
<BarChart className="h-4 w-4 mr-1" />
AI分析
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>AI </DialogTitle>
</DialogHeader>
<div className="mt-4">
<p>...</p>
</div>
</DialogContent>
</Dialog>
</div>
<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>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</Card>

View File

@@ -115,7 +115,7 @@ export default function ContentLibraryPage() {
],
creator: item.creatorName || "系统",
creatorName: item.creatorName,
itemCount: 0,
itemCount: item.itemCount,
lastUpdated: item.updateTime,
enabled: item.isEnabled === 1,
// 新增字段