流量分发列表 + 添加 + 修改状态功能提交

This commit is contained in:
wong
2025-06-16 15:42:36 +08:00
parent 9e89e3db9c
commit 51beeee212
14 changed files with 1423 additions and 645 deletions

View File

@@ -23,9 +23,8 @@ export default function NewPlan() {
const [currentStep, setCurrentStep] = useState(1)
const [formData, setFormData] = useState({
planName: "",
scenario: "",
posters: [],
device: "",
device: [],
remarkType: "phone",
greeting: "你好,请通过",
addInterval: 1,
@@ -43,7 +42,6 @@ export default function NewPlan() {
.then(res => {
if (res.code === 200 && Array.isArray(res.data)) {
setScenes(res.data)
setFormData(prev => ({ ...prev, scenario: prev.scenario || (res.data[0]?.id || "") }))
}
})
.finally(() => setLoadingScenes(false))
@@ -57,19 +55,31 @@ export default function NewPlan() {
// 处理保存
const handleSave = async () => {
try {
// 这里应该是实际的API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
toast({
title: "创建成功",
description: "获客计划已创建",
})
// router.push("/plans")
router.push("/scenarios")
} catch (error) {
// 先赋值再去除多余字段
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")
} else {
toast({
title: "创建失败",
description: res.msg || "创建计划失败,请重试",
variant: "destructive",
})
}
} catch (error: any) {
toast({
title: "创建失败",
description: "创建计划失败,请重试",
description: error?.message || "创建计划失败,请重试",
variant: "destructive",
})
}
@@ -113,7 +123,7 @@ export default function NewPlan() {
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between h-14 px-4">
<div className="flex items-center">
<Button variant="ghost" size="icon" onClick={() => router.push("/plans")}>
<Button variant="ghost" size="icon" onClick={() => router.push("/scenarios")}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="ml-2 text-lg font-medium"></h1>

View File

@@ -2,7 +2,7 @@
import type React from "react"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -19,6 +19,8 @@ import {
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog"
import { toast } from "@/components/ui/use-toast"
import { useSearchParams } from "next/navigation"
interface BasicSettingsProps {
formData: any
@@ -98,24 +100,24 @@ const generatePosterMaterials = (): Material[] => {
// 颜色池分为更浅的未选中和深色的选中
const tagColorPoolLight = [
"bg-blue-50 text-blue-600",
"bg-green-50 text-green-600",
"bg-purple-50 text-purple-600",
"bg-red-50 text-red-600",
"bg-orange-50 text-orange-600",
"bg-yellow-50 text-yellow-600",
"bg-gray-50 text-gray-600",
"bg-pink-50 text-pink-600",
"bg-blue-100 text-blue-600",
"bg-green-100 text-green-600",
"bg-purple-100 text-purple-600",
"bg-red-100 text-red-600",
"bg-orange-100 text-orange-600",
"bg-yellow-100 text-yellow-600",
"bg-gray-100 text-gray-600",
"bg-pink-100 text-pink-600",
];
const tagColorPoolDark = [
"bg-blue-500 text-white",
"bg-green-500 text-white",
"bg-purple-500 text-white",
"bg-red-500 text-white",
"bg-orange-500 text-white",
"bg-yellow-500 text-white",
"bg-gray-500 text-white",
"bg-pink-500 text-white",
"bg-blue-100 text-blue-600",
"bg-green-100 text-green-600",
"bg-purple-100 text-purple-600",
"bg-red-100 text-red-600",
"bg-orange-100 text-orange-600",
"bg-yellow-100 text-yellow-600",
"bg-gray-100 text-gray-600",
"bg-pink-100 text-pink-600",
];
function getTagColorIdx(tag: string) {
let hash = 0;
@@ -125,6 +127,141 @@ function getTagColorIdx(tag: string) {
return Math.abs(hash) % tagColorPoolLight.length;
}
// Section组件示例
const PosterSection = ({ materials, selectedMaterials, onUpload, onSelect, uploading, fileInputRef, onFileChange, onPreview, onRemove }) => (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={onUpload} disabled={uploading} className="w-10 h-10 p-0 flex items-center justify-center rounded-xl">
<Plus className="h-5 w-5" />
<input
type="file"
ref={fileInputRef}
onChange={onFileChange}
className="hidden"
accept="image/*"
/>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
{materials.map((material) => (
<div
key={material.id}
className={`relative cursor-pointer rounded-lg overflow-hidden group ${
selectedMaterials.find((m) => m.id === material.id)
? "ring-2 ring-blue-600"
: "hover:ring-2 hover:ring-blue-600"
}`}
onClick={() => onSelect(material)}
>
<img
src={material.preview || "/placeholder.svg"}
alt={material.name}
className="w-full aspect-[9/16] object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
onPreview(material.preview)
}}
>
<Maximize2 className="h-4 w-4 text-white" />
</Button>
</div>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{material.name}</div>
</div>
</div>
))}
</div>
{selectedMaterials.length > 0 && (
<div className="mt-4">
<Label></Label>
<div className="relative w-full max-w-[120px]">
<img
src={selectedMaterials[0].preview || "/placeholder.svg"}
alt={selectedMaterials[0].name}
className="w-full aspect-[9/16] object-cover rounded-lg cursor-pointer"
onClick={() => onPreview(selectedMaterials[0].preview)}
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => onRemove(selectedMaterials[0].id)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
const OrderSection = ({ materials, onUpload, uploading, fileInputRef, onFileChange }) => (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={onUpload} disabled={uploading} className="w-10 h-10 p-0 flex items-center justify-center rounded-xl">
<Plus className="h-5 w-5" />
<input
type="file"
ref={fileInputRef}
onChange={onFileChange}
className="hidden"
accept="image/*"
/>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
{materials.map((item) => (
<div key={item.id} className="relative cursor-pointer rounded-lg overflow-hidden group">
<img src={item.preview || "/placeholder.svg"} alt={item.name} className="w-full aspect-[9/16] object-cover" />
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{item.name}</div>
</div>
</div>
))}
</div>
</div>
)
const DouyinSection = ({ materials, onUpload, uploading, fileInputRef, onFileChange }) => (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={onUpload} disabled={uploading} className="w-10 h-10 p-0 flex items-center justify-center rounded-xl">
<Plus className="h-5 w-5" />
<input
type="file"
ref={fileInputRef}
onChange={onFileChange}
className="hidden"
accept="image/*"
/>
</Button>
</div>
<div className="grid grid-cols-3 gap-4">
{materials.map((item) => (
<div key={item.id} className="relative cursor-pointer rounded-lg overflow-hidden group">
<img src={item.preview || "/placeholder.svg"} alt={item.name} className="w-full aspect-[9/16] object-cover" />
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{item.name}</div>
</div>
</div>
))}
</div>
</div>
)
const PlaceholderSection = ({ title }) => (
<div className="p-8 text-center text-gray-400 border rounded-lg mt-4">{title}</div>
)
export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSettingsProps) {
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false)
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false)
@@ -133,7 +270,7 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
const [isPhoneSettingsOpen, setIsPhoneSettingsOpen] = useState(false)
const [previewImage, setPreviewImage] = useState("")
const [accounts] = useState<Account[]>(generateRandomAccounts(50))
const [materials] = useState<Material[]>(generatePosterMaterials())
const [materials, setMaterials] = useState<Material[]>(generatePosterMaterials())
const [selectedAccounts, setSelectedAccounts] = useState<Account[]>(
formData.accounts?.length > 0 ? formData.accounts : [],
)
@@ -154,9 +291,7 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
const [selectedScenarioTags, setSelectedScenarioTags] = useState<string[]>(formData.scenarioTags || [])
const [customTagInput, setCustomTagInput] = useState("")
const [customTags, setCustomTags] = useState<Array<{ id: string; name: string; color: string }>>(
formData.customTags || [],
)
const [customTags, setCustomTags] = useState<string[]>(formData.customTags || [])
// 初始化电话获客设置
const [phoneSettings, setPhoneSettings] = useState({
@@ -168,7 +303,55 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
const [selectedPhoneTags, setSelectedPhoneTags] = useState<string[]>(formData.phoneTags || [])
const [phoneCallType, setPhoneCallType] = useState(formData.phoneCallType || "both")
// 处理标签选择 (现在处理的是字符串标签)
const fileInputRef = useRef<HTMLInputElement>(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 [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 [linkCover, setLinkCover] = useState(formData.linkCover || "")
const [uploadingLinkCover, setUploadingLinkCover] = useState(false)
const linkFileInputRef = useRef<HTMLInputElement>(null)
const searchParams = useSearchParams()
const type = searchParams.get("type")
// 类型映射表
const typeMap: Record<string, string> = {
haibao: "poster",
douyin: "douyin",
kuaishou: "kuaishou",
xiaohongshu: "xiaohongshu",
weibo: "weibo",
phone: "phone",
gongzhonghao: "gongzhonghao",
weixinqun: "weixinqun",
payment: "payment",
api: "api",
order: "order"
}
const realType = typeMap[type] || type
const filteredScenarios = scenarios.filter(scene => scene.type === realType)
// 只在有唯一匹配时自动选中,否则不自动选中
useEffect(() => {
if (filteredScenarios.length === 1 && formData.sceneId !== filteredScenarios[0].id) {
onChange({ sceneId: filteredScenarios[0].id })
}
}, [filteredScenarios, formData.sceneId, onChange])
// 展示所有场景
const displayedScenarios = scenarios
const handleTagToggle = (tag: string) => {
const newTags = selectedPhoneTags.includes(tag)
? selectedPhoneTags.filter((t) => t !== tag)
@@ -178,13 +361,11 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
onChange({ ...formData, phoneTags: newTags })
}
// 处理通话类型选择
const handleCallTypeChange = (type: string) => {
setPhoneCallType(type)
onChange({ ...formData, phoneCallType: type })
}
// 初始化时,如果没有选择场景,默认选择海报获客
useEffect(() => {
if (!formData.scenario) {
onChange({ ...formData, scenario: "haibao" })
@@ -201,7 +382,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
}, [formData, onChange])
const handleScenarioSelect = (scenarioId: string) => {
// 如果选择了电话获客,更新计划名称
if (scenarioId === "phone") {
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
onChange({ ...formData, scenario: scenarioId, planName: `电话获客${today}` })
@@ -210,7 +390,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
}
}
// 处理场景标签选择 (现在处理的是字符串标签)
const handleScenarioTagToggle = (tag: string) => {
const newTags = selectedScenarioTags.includes(tag)
? selectedScenarioTags.filter((t) => t !== tag)
@@ -220,41 +399,22 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
onChange({ ...formData, scenarioTags: newTags })
}
// 添加自定义标签
const handleAddCustomTag = () => {
if (!customTagInput.trim()) return
const colors = [
"bg-blue-100 text-blue-800",
"bg-green-100 text-green-800",
"bg-purple-100 text-purple-800",
"bg-red-100 text-red-800",
"bg-orange-100 text-orange-800",
"bg-yellow-100 text-yellow-800",
"bg-gray-100 text-gray-800",
"bg-pink-100 text-pink-800",
]
const newTag = {
id: `custom-${Date.now()}`,
name: customTagInput.trim(),
color: colors[Math.floor(Math.random() * colors.length)],
}
const newTag = customTagInput.trim()
if (customTags.includes(newTag)) return
const updatedCustomTags = [...customTags, newTag]
setCustomTags(updatedCustomTags)
setCustomTagInput("")
onChange({ ...formData, customTags: updatedCustomTags })
}
// 删除自定义标签
const handleRemoveCustomTag = (tagId: string) => {
const updatedCustomTags = customTags.filter((tag) => tag.id !== tagId)
const handleRemoveCustomTag = (tag: string) => {
const updatedCustomTags = customTags.filter((t) => t !== tag)
setCustomTags(updatedCustomTags)
onChange({ ...formData, customTags: updatedCustomTags })
// 同时从选中标签中移除
const updatedSelectedTags = selectedScenarioTags.filter((id) => id !== tagId)
const updatedSelectedTags = selectedScenarioTags.filter((t) => t !== tag)
setSelectedScenarioTags(updatedSelectedTags)
onChange({ ...formData, scenarioTags: updatedSelectedTags, customTags: updatedCustomTags })
}
@@ -271,7 +431,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
onChange({ ...formData, materials: updatedMaterials })
setIsMaterialDialogOpen(false)
// 更新计划名称
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
onChange({ ...formData, planName: `海报${today}`, materials: updatedMaterials })
}
@@ -293,9 +452,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
setIsPreviewOpen(true)
}
// 只显示前三个场景,其他的需要点击展开
const displayedScenarios = showAllScenarios ? scenarios : scenarios.slice(0, 3)
const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
@@ -337,27 +493,296 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
window.URL.revokeObjectURL(url)
}
// 处理电话获客设置更新
const handlePhoneSettingsUpdate = () => {
onChange({ ...formData, phoneSettings })
setIsPhoneSettingsOpen(false)
}
const currentScenario = scenarios.find((s: any) => s.id === formData.scenario);
const handleUploadPoster = () => {
fileInputRef.current?.click()
}
const handlePosterFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast({ title: '请选择图片文件', variant: 'destructive' })
return
}
setUploadingPoster(true)
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) {
const newPoster = {
id: `custom_${Date.now()}`,
name: result.data.name || '自定义海报',
preview: result.data.url,
}
setMaterials(prev => [newPoster, ...prev])
toast({ title: '上传成功', description: '海报已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingPoster(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handleUploadOrder = () => { orderFileInputRef.current?.click() }
const handleUploadDouyin = () => { douyinFileInputRef.current?.click() }
const handleOrderFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingOrder(true)
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) {
const newItem = { id: `order_${Date.now()}`, name: result.data.name || '自定义订单', preview: result.data.url }
setOrderMaterials(prev => [newItem, ...prev])
toast({ title: '上传成功', description: '订单模板已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingOrder(false)
if (orderFileInputRef.current) orderFileInputRef.current.value = ''
}
}
const handleDouyinFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingDouyin(true)
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) {
const newItem = { id: `douyin_${Date.now()}`, name: result.data.name || '自定义抖音内容', preview: result.data.url }
setDouyinMaterials(prev => [newItem, ...prev])
toast({ title: '上传成功', description: '抖音内容已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingDouyin(false)
if (douyinFileInputRef.current) douyinFileInputRef.current.value = ''
}
}
// 上传小程序封面
const handleUploadMiniAppCover = () => {
miniAppFileInputRef.current?.click()
}
const handleMiniAppFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingMiniAppCover(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) {
setMiniAppCover(result.data.url)
onChange({ ...formData, miniAppCover: result.data.url })
toast({ title: '上传成功', description: '小程序封面已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingMiniAppCover(false)
if (miniAppFileInputRef.current) miniAppFileInputRef.current.value = ''
}
}
// 上传链接封面
const handleUploadLinkCover = () => {
linkFileInputRef.current?.click()
}
const handleLinkFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploadingLinkCover(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) {
setLinkCover(result.data.url)
onChange({ ...formData, linkCover: result.data.url })
toast({ title: '上传成功', description: '链接封面已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingLinkCover(false)
if (linkFileInputRef.current) linkFileInputRef.current.value = ''
}
}
const renderSceneExtra = () => {
switch (currentScenario?.name) {
case "海报获客":
return (
<PosterSection
materials={materials}
selectedMaterials={selectedMaterials}
onUpload={handleUploadPoster}
onSelect={handleMaterialSelect}
uploading={uploadingPoster}
fileInputRef={fileInputRef}
onFileChange={handlePosterFileChange}
onPreview={handlePreviewImage}
onRemove={handleRemoveMaterial}
/>
)
case "订单获客":
return (
<OrderSection
materials={orderMaterials}
onUpload={handleUploadOrder}
uploading={uploadingOrder}
fileInputRef={orderFileInputRef}
onFileChange={handleOrderFileChange}
/>
)
case "抖音获客":
return (
<DouyinSection
materials={douyinMaterials}
onUpload={handleUploadDouyin}
uploading={uploadingDouyin}
fileInputRef={douyinFileInputRef}
onFileChange={handleDouyinFileChange}
/>
)
case "小红书获客":
return <PlaceholderSection title="小红书内容选择" />
case "电话获客":
return <PlaceholderSection title="电话获客专属设置" />
case "公众号获客":
return <PlaceholderSection title="公众号相关设置" />
case "微信群获客":
return <PlaceholderSection title="微信群管理设置" />
case "付款码获客":
return <PlaceholderSection title="付款码上传与选择" />
case "API获客":
return <PlaceholderSection title="API对接说明或配置" />
case "小程序获客":
return <MiniAppSection />
case "链接获客":
return <LinkSection />
default:
return null
}
}
// 新增小程序和链接场景的功能区
const MiniAppSection = () => (
<div>
<Label></Label>
<div className="flex items-center gap-4 mt-2">
<Button variant="outline" onClick={handleUploadMiniAppCover} disabled={uploadingMiniAppCover}>
<input
type="file"
ref={miniAppFileInputRef}
onChange={handleMiniAppFileChange}
className="hidden"
accept="image/*"
/>
</Button>
{miniAppCover && <img src={miniAppCover} alt="小程序封面" className="h-16 rounded" />}
</div>
</div>
)
const LinkSection = () => (
<div>
<Label></Label>
<div className="flex items-center gap-4 mt-2">
<Button variant="outline" onClick={handleUploadLinkCover} disabled={uploadingLinkCover}>
<input
type="file"
ref={linkFileInputRef}
onChange={handleLinkFileChange}
className="hidden"
accept="image/*"
/>
</Button>
{linkCover && <img src={linkCover} alt="链接封面" className="h-16 rounded" />}
</div>
</div>
)
return (
<TooltipProvider>
<Card className="p-6">
<div className="space-y-6">
<div>
<Label className="text-base mb-4 block"></Label>
<div className="grid grid-cols-3 gap-2">
<div className="mt-2 grid grid-cols-3 gap-3">
{displayedScenarios.map((scenario) => (
<button
key={scenario.id}
className={`p-2 rounded-lg text-center transition-all ${
formData.scenario === scenario.id
? "bg-blue-100 text-blue-600 font-medium"
: "bg-gray-50 text-gray-600 hover:bg-gray-100"
}`}
type="button"
className={
"h-10 rounded-lg text-base transition-all w-full " +
(formData.sceneId === scenario.id
? "bg-blue-100 font-bold"
: "bg-gray-50 text-gray-800 font-medium hover:bg-blue-50")
}
style={formData.sceneId === scenario.id ? { color: "#1677ff" } : {}}
onClick={() => handleScenarioSelect(scenario.id)}
>
{scenario.name.replace("获客", "")}
@@ -372,24 +797,24 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
<div>
<Label htmlFor="planName"></Label>
<Label htmlFor="name" className="text-sm text-gray-600 mb-2 block">
</Label>
<Input
id="planName"
value={formData.planName}
onChange={(e) => onChange({ ...formData, planName: e.target.value })}
id="name"
value={formData.name}
onChange={(e) => onChange({ ...formData, name: e.target.value })}
placeholder="请输入计划名称"
className="mt-2"
className="w-full"
/>
</div>
{/* 场景标签选择 */}
{formData.scenario && (
<div className="mt-6">
<Label className="text-base mb-3 block">
{scenarios.find((s) => s.id === formData.scenario)?.name}
</Label>
{/* 预设标签 */}
<div className="flex flex-wrap gap-2 mb-4">
{(scenarios.find((s) => s.id === formData.scenario)?.scenarioTags || []).map((tag: string) => {
const idx = getTagColorIdx(tag);
@@ -410,29 +835,28 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
})}
</div>
{/* 自定义标签 */}
{customTags.length > 0 && (
<div className="mb-4">
<Label className="text-sm text-gray-600 mb-2 block"></Label>
<div className="flex flex-wrap gap-2">
{customTags.map((tag) => (
<div
key={tag.id}
key={tag}
className={`px-3 py-2 rounded-full text-sm cursor-pointer transition-all relative group ${
selectedScenarioTags.includes(tag.id)
? tag.color + " ring-2 ring-blue-400"
: tag.color + " hover:ring-1 hover:ring-gray-300"
selectedScenarioTags.includes(tag)
? "bg-blue-500 text-white ring-2 ring-blue-400"
: "bg-blue-50 text-blue-600 hover:ring-1 hover:ring-gray-300"
}`}
onClick={() => handleScenarioTagToggle(tag.id)}
onClick={() => handleScenarioTagToggle(tag)}
>
{tag.name}
{tag}
<Button
variant="ghost"
size="sm"
className="absolute -top-1 -right-1 h-5 w-5 p-0 opacity-0 group-hover:opacity-100 bg-red-500 hover:bg-red-600 text-white rounded-full"
onClick={(e) => {
e.stopPropagation()
handleRemoveCustomTag(tag.id)
handleRemoveCustomTag(tag)
}}
>
<X className="h-3 w-3" />
@@ -443,7 +867,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
)}
{/* 添加自定义标签 */}
<div className="flex gap-2">
<Input
value={customTagInput}
@@ -517,7 +940,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
)}
{/* 电话获客特殊设置 */}
{formData.scenario === "phone" && (
<Card className="p-4 border-blue-100 bg-blue-50/50 mt-4">
<div className="flex items-center justify-between mb-3">
@@ -581,7 +1003,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
{formData.scenario === "phone" && (
<>
{/* 添加电话通话类型选择 */}
<div className="mt-6">
<Label className="text-base mb-2 block"></Label>
<div className="grid grid-cols-2 gap-2 mt-2">
@@ -610,7 +1031,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
</div>
{/* 添加标签功能 - 使用从 scenarios 中获取的标签数据 */}
<div className="mt-6">
<Label className="text-base mb-2 block"></Label>
<div className="flex flex-wrap gap-2 mt-2">
@@ -636,77 +1056,11 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</>
)}
{scenarios.find((s: any) => s.id === formData.scenario)?.type === "material" && (
{((currentScenario?.type === "material" || currentScenario?.name === "海报获客" || currentScenario?.id === 1) && (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={() => setIsMaterialDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 海报展示区域 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{materials.map((material) => (
<div
key={material.id}
className={`relative cursor-pointer rounded-lg overflow-hidden group ${
selectedMaterials.find((m) => m.id === material.id)
? "ring-2 ring-blue-600"
: "hover:ring-2 hover:ring-blue-600"
}`}
onClick={() => handleMaterialSelect(material)}
>
<img
src={material.preview || "/placeholder.svg"}
alt={material.name}
className="w-full aspect-[9/16] object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
handlePreviewImage(material.preview)
}}
>
<Maximize2 className="h-4 w-4 text-white" />
</Button>
</div>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{material.name}</div>
</div>
</div>
))}
</div>
{selectedMaterials.length > 0 && (
<div className="mt-4">
<Label></Label>
<div className="mt-2">
<div className="relative w-full max-w-[200px]">
<img
src={selectedMaterials[0].preview || "/placeholder.svg"}
alt={selectedMaterials[0].name}
className="w-full aspect-[9/16] object-cover rounded-lg cursor-pointer"
onClick={() => handlePreviewImage(selectedMaterials[0].preview)}
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => handleRemoveMaterial(selectedMaterials[0].id)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{renderSceneExtra()}
</div>
)}
))}
{scenarios.find((s: any) => s.id === formData.scenario)?.id === "order" && (
<div>
@@ -857,7 +1211,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</div>
</Card>
{/* 账号选择对话框 */}
<Dialog open={isAccountDialogOpen} onOpenChange={setIsAccountDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -883,7 +1236,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</DialogContent>
</Dialog>
{/* 二维码对话框 */}
<Dialog open={isQRCodeOpen} onOpenChange={setIsQRCodeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -898,7 +1250,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</DialogContent>
</Dialog>
{/* 图片预览对话框 */}
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
@@ -910,7 +1261,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</DialogContent>
</Dialog>
{/* 电话获客设置对话框 */}
<Dialog open={isPhoneSettingsOpen} onOpenChange={setIsPhoneSettingsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -977,7 +1327,6 @@ export function BasicSettings({ formData, onChange, onNext, scenarios }: BasicSe
</DialogContent>
</Dialog>
{/* 订单导入对话框 */}
<Dialog open={isImportDialogOpen} onOpenChange={setIsImportDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useRef } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -18,6 +18,7 @@ import {
X,
Upload,
Clock,
UploadCloud,
} from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -26,7 +27,7 @@ import { toast } from "@/components/ui/use-toast"
interface MessageContent {
id: string
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group"
content: string
content: string | { url: string, name: string }[]
sendInterval?: number
intervalUnit?: "seconds" | "minutes"
scheduledTime?: {
@@ -40,6 +41,7 @@ interface MessageContent {
coverImage?: string
groupId?: string
linkUrl?: string
cover?: string
}
interface DayPlan {
@@ -90,6 +92,12 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false)
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false)
const [selectedGroupId, setSelectedGroupId] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [uploadTarget, setUploadTarget] = useState<{dayIndex: number, messageIndex: number, type: string} | null>(null)
const coverInputRef = useRef<HTMLInputElement>(null)
const [uploadingCover, setUploadingCover] = useState(false)
const [coverTarget, setCoverTarget] = useState<{dayIndex: number, messageIndex: number} | null>(null)
// 添加新消息
const handleAddMessage = (dayIndex: number, type = "text") => {
@@ -184,11 +192,90 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
// 处理文件上传
const handleFileUpload = (dayIndex: number, messageIndex: number, type: "image" | "video" | "file") => {
// 模拟文件上传
toast({
title: "上传成功",
description: `${type === "image" ? "图片" : type === "video" ? "视频" : "文件"}上传成功`,
})
setUploadTarget({ dayIndex, messageIndex, type })
fileInputRef.current?.setAttribute('accept', type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : '*')
fileInputRef.current?.click()
}
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !uploadTarget) return
setUploading(true)
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) {
if (uploadTarget.type === 'file') {
// 多文件,存对象
const prevFiles = Array.isArray(dayPlans[uploadTarget.dayIndex].messages[uploadTarget.messageIndex].content)
? dayPlans[uploadTarget.dayIndex].messages[uploadTarget.messageIndex].content
: []
handleUpdateMessage(uploadTarget.dayIndex, uploadTarget.messageIndex, { content: [...prevFiles, { url: result.data.url, name: result.data.name || result.data.url.split('/').pop() }] })
} else {
handleUpdateMessage(uploadTarget.dayIndex, uploadTarget.messageIndex, { content: result.data.url })
}
toast({ title: '上传成功', description: `${uploadTarget.type === 'image' ? '图片' : uploadTarget.type === 'video' ? '视频' : '文件'}上传成功` })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploading(false)
setUploadTarget(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handleUploadCover = (dayIndex: number, messageIndex: number) => {
setCoverTarget({ dayIndex, messageIndex })
coverInputRef.current?.click()
}
const handleCoverFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
dayIndex?: number,
messageIndex?: number
) => {
const file = event.target.files?.[0]
if (!file || !coverTarget) return
setUploadingCover(true)
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) {
handleUpdateMessage(coverTarget.dayIndex, coverTarget.messageIndex, { cover: result.data.url })
toast({ title: '上传成功', description: '封面已添加' })
} else {
toast({ title: '上传失败', description: result.msg || '请重试', variant: 'destructive' })
}
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || '请重试', variant: 'destructive' })
} finally {
setUploadingCover(false)
setCoverTarget(null)
if (coverInputRef.current) coverInputRef.current.value = ''
}
}
const handleRemoveCover = (dayIndex: number, messageIndex: number) => {
handleUpdateMessage(dayIndex, messageIndex, { cover: "" })
}
return (
@@ -353,39 +440,6 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
placeholder="请输入小程序路径"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => handleUpdateMessage(dayIndex, messageIndex, { coverImage: undefined })}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button
variant="outline"
className="w-full h-[120px]"
onClick={() => handleFileUpload(dayIndex, messageIndex, "image")}
>
<Upload className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
@@ -421,39 +475,6 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
placeholder="请输入链接地址"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => handleUpdateMessage(dayIndex, messageIndex, { coverImage: undefined })}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button
variant="outline"
className="w-full h-[120px]"
onClick={() => handleFileUpload(dayIndex, messageIndex, "image")}
>
<Upload className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
@@ -478,10 +499,72 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
variant="outline"
className="w-full h-[120px]"
onClick={() => handleFileUpload(dayIndex, messageIndex, message.type as any)}
disabled={uploading}
>
<Upload className="h-4 w-4 mr-2" />
{message.type === "image" ? "图片" : message.type === "video" ? "视频" : "文件"}
{uploading && uploadTarget && uploadTarget.dayIndex === dayIndex && uploadTarget.messageIndex === messageIndex ? '上传中...' : `上传${message.type === "image" ? "图片" : message.type === "video" ? "视频" : "文件"}`}
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
/>
{/* 文件预览 */}
{message.type === 'image' && message.content && (
<div className="mt-4">
<img src={message.content} alt="图片预览" className="max-h-32 mx-auto rounded-lg border" />
</div>
)}
{message.type === 'video' && message.content && (
<div className="mt-4">
<video src={message.content} controls className="max-h-32 mx-auto rounded-lg border" />
</div>
)}
{message.type === 'file' && Array.isArray(message.content) && message.content.length > 0 && (
<ul className="mt-4 space-y-2 text-left">
{message.content.map((fileObj: {url: string, name: string}, idx: number) => (
<li key={fileObj.url} className="flex items-center gap-2">
<a href={fileObj.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline break-all flex-1">{fileObj.name || fileObj.url.split('/').pop()}</a>
<Button size="icon" variant="ghost" onClick={() => {
const newFiles = message.content.filter((_: any, i: number) => i !== idx)
handleUpdateMessage(dayIndex, messageIndex, { content: newFiles })
}}><X className="h-4 w-4" /></Button>
</li>
))}
</ul>
)}
</div>
)}
{(message.type === "miniprogram" || message.type === "link") && (
<div className="mt-4">
<Label></Label>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.cover ? (
<div className="flex flex-col items-center">
<img src={message.cover} alt="封面" className="h-24 rounded mb-2" />
<Button size="sm" onClick={() => handleRemoveCover(dayIndex, messageIndex)}></Button>
</div>
) : (
<Button
variant="outline"
className="w-full h-[100px] flex flex-col items-center justify-center"
onClick={() => handleUploadCover(dayIndex, messageIndex)}
disabled={uploadingCover}
>
<UploadCloud className="h-8 w-8 mb-2" />
</Button>
)}
<input
type="file"
ref={coverInputRef}
onChange={(e) => handleCoverFileChange(e, dayIndex, messageIndex)}
className="hidden"
accept="image/*"
/>
</div>
</div>
)}
</div>

View File

@@ -1,209 +0,0 @@
"use client"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { useState } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Plus, Pencil, Trash2, Link2 } from "lucide-react"
import { toast } from "@/components/ui/use-toast"
interface Channel {
id: string
name: string
type: "team" | "other"
link?: string
}
interface TrafficChannelSettingsProps {
formData: any
onChange: (data: any) => void
onNext: () => void
onPrev: () => void
}
function isValidUrl(string: string) {
try {
new URL(string)
return true
} catch (_) {
return false
}
}
export function TrafficChannelSettings({ formData, onChange, onNext, onPrev }: TrafficChannelSettingsProps) {
const [channels, setChannels] = useState<Channel[]>(formData.channels || [])
const [isAddChannelOpen, setIsAddChannelOpen] = useState(false)
const [editingChannel, setEditingChannel] = useState<Channel | null>(null)
const [newChannel, setNewChannel] = useState<Partial<Channel>>({
name: "",
type: "team",
link: "",
})
const handleAddChannel = () => {
if (!newChannel.name) return
if (newChannel.link && !isValidUrl(newChannel.link)) {
toast({
title: "错误",
description: "请输入有效的URL",
variant: "destructive",
})
return
}
if (editingChannel) {
setChannels(
channels.map((channel) => (channel.id === editingChannel.id ? { ...channel, ...newChannel } : channel)),
)
} else {
setChannels([
...channels,
{
id: Date.now().toString(),
name: newChannel.name,
type: newChannel.type || "team",
link: newChannel.link,
} as Channel,
])
}
setIsAddChannelOpen(false)
setNewChannel({ name: "", type: "team", link: "" })
setEditingChannel(null)
onChange({ ...formData, channels })
}
const handleEditChannel = (channel: Channel) => {
setEditingChannel(channel)
setNewChannel(channel)
setIsAddChannelOpen(true)
}
const handleDeleteChannel = (channelId: string) => {
setChannels(channels.filter((channel) => channel.id !== channelId))
onChange({ ...formData, channels: channels.filter((channel) => channel.id !== channelId) })
}
return (
<Card className="p-6">
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">流量通道设置</h2>
<Button onClick={() => setIsAddChannelOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
添加通道
</Button>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>通道名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>链接</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channels.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-gray-500">
暂无数据
</TableCell>
</TableRow>
) : (
channels.map((channel) => (
<TableRow key={channel.id}>
<TableCell>{channel.name}</TableCell>
<TableCell>{channel.type === "team" ? "打粉团队" : "其他"}</TableCell>
<TableCell>
{channel.link && (
<div className="flex items-center space-x-2">
<Link2 className="h-4 w-4" />
<span className="text-blue-600">{channel.link}</span>
</div>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm" onClick={() => handleEditChannel(channel)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteChannel(channel.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onPrev}>
上一步
</Button>
<Button onClick={onNext}>完成</Button>
</div>
</div>
<Dialog open={isAddChannelOpen} onOpenChange={setIsAddChannelOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingChannel ? "编辑通道" : "添加通道"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>通道名称</Label>
<Input
value={newChannel.name}
onChange={(e) => setNewChannel({ ...newChannel, name: e.target.value })}
placeholder="请输入通道名称"
/>
</div>
<div className="space-y-2">
<Label>通道类型</Label>
<Select
value={newChannel.type}
onValueChange={(value) => setNewChannel({ ...newChannel, type: value as "team" | "other" })}
>
<SelectTrigger>
<SelectValue placeholder="选择通道类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="team">打粉团队</SelectItem>
<SelectItem value="other">其他</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>通道链接(选填)</Label>
<Input
value={newChannel.link}
onChange={(e) => setNewChannel({ ...newChannel, link: e.target.value })}
placeholder="请输入通道链接"
/>
{newChannel.link && !isValidUrl(newChannel.link) && (
<p className="text-sm text-red-500">请输入有效的URL</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddChannelOpen(false)}>
取消
</Button>
<Button onClick={handleAddChannel}>{editingChannel ? "保存" : "添加"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}