plan pages
This commit is contained in:
42
Cunkebao/app/components/ui-templates/card-grid.tsx
Normal file
42
Cunkebao/app/components/ui-templates/card-grid.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
interface CardGridProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
columns?: {
|
||||
sm?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
xl?: number
|
||||
}
|
||||
gap?: "none" | "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应卡片网格组件
|
||||
* 根据屏幕尺寸自动调整卡片布局
|
||||
*/
|
||||
export function CardGrid({ children, className, columns = { sm: 1, md: 2, lg: 3, xl: 4 }, gap = "md" }: CardGridProps) {
|
||||
// 根据gap参数设置间距
|
||||
const gapClasses = {
|
||||
none: "gap-0",
|
||||
sm: "gap-2",
|
||||
md: "gap-4",
|
||||
lg: "gap-6",
|
||||
}
|
||||
|
||||
// 根据columns参数设置网格列数
|
||||
const getGridCols = () => {
|
||||
const cols = []
|
||||
if (columns.sm) cols.push(`grid-cols-${columns.sm}`)
|
||||
if (columns.md) cols.push(`md:grid-cols-${columns.md}`)
|
||||
if (columns.lg) cols.push(`lg:grid-cols-${columns.lg}`)
|
||||
if (columns.xl) cols.push(`xl:grid-cols-${columns.xl}`)
|
||||
return cols.join(" ")
|
||||
}
|
||||
|
||||
return <div className={cn("grid w-full", getGridCols(), gapClasses[gap], className)}>{children}</div>
|
||||
}
|
||||
138
Cunkebao/app/components/ui-templates/cards.tsx
Normal file
138
Cunkebao/app/components/ui-templates/cards.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Clock, TrendingUp, Users } from "lucide-react"
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
description?: string
|
||||
trend?: number
|
||||
trendLabel?: string
|
||||
className?: string
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计数据卡片
|
||||
* 用于展示关键指标数据
|
||||
*/
|
||||
export function StatsCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
trend,
|
||||
trendLabel,
|
||||
className = "",
|
||||
valueClassName = "text-xl font-bold text-blue-600",
|
||||
}: StatsCardProps) {
|
||||
return (
|
||||
<Card className={`p-3 ${className}`}>
|
||||
<div className="text-sm text-gray-500">{title}</div>
|
||||
<div className={valueClassName}>{value}</div>
|
||||
{description && <div className="text-xs text-gray-500 mt-1">{description}</div>}
|
||||
{trend !== undefined && (
|
||||
<div className={`flex items-center text-xs mt-1 ${trend >= 0 ? "text-green-600" : "text-red-600"}`}>
|
||||
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend)}% {trendLabel || ""}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface DistributionPlanCardProps {
|
||||
id: string
|
||||
name: string
|
||||
status: "active" | "paused" | "completed"
|
||||
source: string
|
||||
sourceIcon: string
|
||||
targetGroups: string[]
|
||||
totalUsers: number
|
||||
dailyAverage: number
|
||||
lastUpdated: string
|
||||
createTime: string
|
||||
creator: string
|
||||
onView?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
onToggleStatus?: (id: string, status: "active" | "paused") => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 流量分发计划卡片
|
||||
* 用于展示流量分发计划信息
|
||||
*/
|
||||
export function DistributionPlanCard({
|
||||
id,
|
||||
name,
|
||||
status,
|
||||
source,
|
||||
sourceIcon,
|
||||
targetGroups,
|
||||
totalUsers,
|
||||
dailyAverage,
|
||||
lastUpdated,
|
||||
createTime,
|
||||
creator,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
}: DistributionPlanCardProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xl mr-1">{sourceIcon}</span>
|
||||
<h3 className="font-medium">{name}</h3>
|
||||
<Badge variant={status === "active" ? "success" : status === "completed" ? "outline" : "secondary"}>
|
||||
{status === "active" ? "进行中" : status === "completed" ? "已完成" : "已暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{onToggleStatus && status !== "completed" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleStatus(id, status === "active" ? "paused" : "active")}
|
||||
>
|
||||
{status === "active" ? "暂停" : "启动"}
|
||||
</Button>
|
||||
)}
|
||||
{onView && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onView(id)}>
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
查看
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(id)}>
|
||||
<TrendingUp className="h-4 w-4 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>目标人群:{targetGroups.join(", ")}</div>
|
||||
<div>总流量:{totalUsers} 人</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>日均获取:{dailyAverage} 人</div>
|
||||
<div>创建人:{creator}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 border-t pt-4">
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
上次更新:{lastUpdated}
|
||||
</div>
|
||||
<div>创建时间:{createTime}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
87
Cunkebao/app/components/ui-templates/form-layout.tsx
Normal file
87
Cunkebao/app/components/ui-templates/form-layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
interface FormLayoutProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
layout?: "vertical" | "horizontal" | "responsive"
|
||||
labelWidth?: string
|
||||
gap?: "none" | "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应表单布局组件
|
||||
* 支持垂直、水平和响应式布局
|
||||
*/
|
||||
export function FormLayout({
|
||||
children,
|
||||
className,
|
||||
layout = "responsive",
|
||||
labelWidth = "w-32",
|
||||
gap = "md",
|
||||
}: FormLayoutProps) {
|
||||
// 根据gap参数设置间距
|
||||
const gapClasses = {
|
||||
none: "space-y-0",
|
||||
sm: "space-y-2",
|
||||
md: "space-y-4",
|
||||
lg: "space-y-6",
|
||||
}
|
||||
|
||||
// 根据layout参数设置布局类
|
||||
const getLayoutClasses = () => {
|
||||
switch (layout) {
|
||||
case "horizontal":
|
||||
return "form-horizontal"
|
||||
case "vertical":
|
||||
return "form-vertical"
|
||||
case "responsive":
|
||||
return "form-vertical md:form-horizontal"
|
||||
default:
|
||||
return "form-vertical"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full", getLayoutClasses(), gapClasses[gap], className, {
|
||||
[`[--label-width:${labelWidth}]`]: layout !== "vertical",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormLayout
|
||||
|
||||
interface FormItemProps {
|
||||
children: React.ReactNode
|
||||
label?: React.ReactNode
|
||||
className?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单项组件
|
||||
* 配合FormLayout使用
|
||||
*/
|
||||
export function FormItem({ children, label, className, required, error }: FormItemProps) {
|
||||
return (
|
||||
<div className={cn("form-item", className)}>
|
||||
{label && (
|
||||
<div className="form-label">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="form-control">
|
||||
{children}
|
||||
{error && <div className="text-red-500 text-sm mt-1">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
273
Cunkebao/app/components/ui-templates/forms.tsx
Normal file
273
Cunkebao/app/components/ui-templates/forms.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 表单组件模板
|
||||
*
|
||||
* 包含项目中常用的各种表单组件
|
||||
*/
|
||||
|
||||
import type React from "react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段容器
|
||||
* 用于包装表单控件
|
||||
*/
|
||||
export function FormField({ label, htmlFor, required = false, description, error, children }: FormFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={htmlFor}>
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
{children}
|
||||
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextInputFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本输入字段
|
||||
* 用于文本输入
|
||||
*/
|
||||
export function TextInputField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
type = "text",
|
||||
}: TextInputFieldProps) {
|
||||
return (
|
||||
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
|
||||
<Input id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextareaFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 多行文本输入字段
|
||||
* 用于多行文本输入
|
||||
*/
|
||||
export function TextareaField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
}: TextareaFieldProps) {
|
||||
return (
|
||||
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
|
||||
<Textarea id={id} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
interface SelectFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string }>
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉选择字段
|
||||
* 用于从选项中选择
|
||||
*/
|
||||
export function SelectField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "请选择",
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
}: SelectFieldProps) {
|
||||
return (
|
||||
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
interface RadioFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string; description?: string }>
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 单选按钮组字段
|
||||
* 用于从选项中单选
|
||||
*/
|
||||
export function RadioField({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
}: RadioFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<RadioGroup value={value} onValueChange={onChange} className="space-y-3">
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`${name}-${option.value}`} />
|
||||
<Label htmlFor={`${name}-${option.value}`} className="cursor-pointer">
|
||||
{option.label}
|
||||
</Label>
|
||||
{option.description && <span className="text-xs text-gray-500 ml-2">({option.description})</span>}
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SliderFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
unit?: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 滑块字段
|
||||
* 用于数值范围选择
|
||||
*/
|
||||
export function SliderField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
unit = "",
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
}: SliderFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<span className="text-sm font-medium">
|
||||
{value} {unit}
|
||||
</span>
|
||||
</div>
|
||||
<Slider id={id} min={min} max={max} step={step} value={[value]} onValueChange={(values) => onChange(values[0])} />
|
||||
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SwitchFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 开关字段
|
||||
* 用于布尔值选择
|
||||
*/
|
||||
export function SwitchField({ label, id, checked, onCheckedChange, description, disabled = false }: SwitchFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
Cunkebao/app/components/ui-templates/index.tsx
Normal file
12
Cunkebao/app/components/ui-templates/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 存客宝UI组件模板库
|
||||
*
|
||||
* 这个文件导出所有可重用的UI组件模板,方便在项目中快速引用
|
||||
*/
|
||||
|
||||
export * from "./cards"
|
||||
export * from "./forms"
|
||||
export * from "./layouts"
|
||||
export * from "./tables"
|
||||
export * from "./stats"
|
||||
export * from "./selectors"
|
||||
119
Cunkebao/app/components/ui-templates/layouts.tsx
Normal file
119
Cunkebao/app/components/ui-templates/layouts.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 布局组件模板
|
||||
*
|
||||
* 包含项目中常用的各种布局组件
|
||||
*/
|
||||
|
||||
import type React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
onBack?: () => void
|
||||
actionButton?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面头部组件
|
||||
* 用于页面顶部的标题和操作区
|
||||
*/
|
||||
export function PageHeader({ title, onBack, actionButton }: PageHeaderProps) {
|
||||
return (
|
||||
<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">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-lg font-medium">{title}</h1>
|
||||
</div>
|
||||
{actionButton}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepIndicatorProps {
|
||||
steps: Array<{
|
||||
step: number
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
}>
|
||||
currentStep: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤指示器组件
|
||||
* 用于多步骤流程的导航
|
||||
*/
|
||||
export function StepIndicator({ steps, currentStep }: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map(({ step, title, icon }) => (
|
||||
<div key={step} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
step === currentStep
|
||||
? "bg-blue-600 text-white"
|
||||
: step < currentStep
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{step < currentStep ? "✓" : icon}
|
||||
</div>
|
||||
<span className="text-xs mt-1">{title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gray-200"></div>
|
||||
<div
|
||||
className="absolute top-0 left-0 h-1 bg-blue-600 transition-all"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepNavigationProps {
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
nextLabel?: string
|
||||
backLabel?: string
|
||||
isLastStep?: boolean
|
||||
isNextDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤导航组件
|
||||
* 用于多步骤流程的前进后退按钮
|
||||
*/
|
||||
export function StepNavigation({
|
||||
onBack,
|
||||
onNext,
|
||||
nextLabel = "下一步",
|
||||
backLabel = "上一步",
|
||||
isLastStep = false,
|
||||
isNextDisabled = false,
|
||||
}: StepNavigationProps) {
|
||||
return (
|
||||
<div className="pt-4 flex justify-between">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
{backLabel}
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={isNextDisabled}>
|
||||
{isLastStep ? "完成" : nextLabel}
|
||||
{!isLastStep && <span className="ml-2">→</span>}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
Cunkebao/app/components/ui-templates/page-header.tsx
Normal file
49
Cunkebao/app/components/ui-templates/page-header.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
import { Button } from "@/app/components/ui/button"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: React.ReactNode
|
||||
subtitle?: React.ReactNode
|
||||
backButton?: boolean
|
||||
backUrl?: string
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面标题组件
|
||||
* 支持返回按钮和操作按钮
|
||||
*/
|
||||
export function PageHeader({ title, subtitle, backButton = false, backUrl, actions, className }: PageHeaderProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
if (backUrl) {
|
||||
router.push(backUrl)
|
||||
} else {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{backButton && (
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} className="h-8 w-8">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
{subtitle && <p className="text-muted-foreground mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
Cunkebao/app/components/ui-templates/responsive-table.tsx
Normal file
103
Cunkebao/app/components/ui-templates/responsive-table.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/app/components/ui/table"
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
title: React.ReactNode
|
||||
render?: (value: any, record: T, index: number) => React.ReactNode
|
||||
width?: number | string
|
||||
className?: string
|
||||
responsive?: boolean // 是否在小屏幕上显示
|
||||
}
|
||||
|
||||
interface ResponsiveTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
dataSource: T[]
|
||||
rowKey?: string | ((record: T) => string)
|
||||
className?: string
|
||||
loading?: boolean
|
||||
emptyText?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式表格组件
|
||||
* 在小屏幕上自动隐藏不重要的列
|
||||
*/
|
||||
export function ResponsiveTable<T extends Record<string, any>>({
|
||||
columns,
|
||||
dataSource,
|
||||
rowKey = "id",
|
||||
className,
|
||||
loading = false,
|
||||
emptyText = "暂无数据",
|
||||
}: ResponsiveTableProps<T>) {
|
||||
// 根据屏幕尺寸过滤列
|
||||
const [visibleColumns, setVisibleColumns] = React.useState(columns)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
// 在小屏幕上只显示responsive不为false的列
|
||||
setVisibleColumns(columns.filter((col) => col.responsive !== false))
|
||||
} else {
|
||||
setVisibleColumns(columns)
|
||||
}
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [columns])
|
||||
|
||||
// 获取行的唯一键
|
||||
const getRowKey = (record: T, index: number) => {
|
||||
if (typeof rowKey === "function") {
|
||||
return rowKey(record)
|
||||
}
|
||||
return record[rowKey] || index
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="responsive-table-container">
|
||||
<Table className={cn("w-full", className)}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{visibleColumns.map((column) => (
|
||||
<TableHead key={column.key} className={column.className} style={{ width: column.width }}>
|
||||
{column.title}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="text-center h-24">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : dataSource.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="text-center h-24">
|
||||
{emptyText}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
dataSource.map((record, index) => (
|
||||
<TableRow key={getRowKey(record, index)}>
|
||||
{visibleColumns.map((column) => (
|
||||
<TableCell key={column.key} className={column.className}>
|
||||
{column.render ? column.render(record[column.key], record, index) : record[column.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
Cunkebao/app/components/ui-templates/selectors.tsx
Normal file
130
Cunkebao/app/components/ui-templates/selectors.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface CustomerServiceRepProps {
|
||||
id: string
|
||||
name: string
|
||||
deviceId: string
|
||||
status: "online" | "offline"
|
||||
avatar?: string
|
||||
selected: boolean
|
||||
disabled?: boolean
|
||||
onSelect: (id: string, checked: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服代表选择卡片
|
||||
* 用于选择客服代表
|
||||
*/
|
||||
export function CustomerServiceRepCard({
|
||||
id,
|
||||
name,
|
||||
deviceId,
|
||||
status,
|
||||
avatar,
|
||||
selected,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
}: CustomerServiceRepProps) {
|
||||
return (
|
||||
<Card
|
||||
className={`p-3 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selected ? "border-primary bg-blue-50" : ""
|
||||
} ${disabled ? "opacity-50 pointer-events-none" : ""}`}
|
||||
onClick={() => !disabled && onSelect(id, !selected)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => !disabled && onSelect(id, !selected)}
|
||||
disabled={disabled}
|
||||
id={`rep-${id}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor={`rep-${id}`} className="font-medium truncate cursor-pointer">
|
||||
{name}
|
||||
</label>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
status === "online" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">设备ID: {deviceId}</div>
|
||||
<div className="text-sm text-gray-500">客服ID: {id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface DeviceSelectProps {
|
||||
allDevices: boolean
|
||||
newDevices: boolean
|
||||
targetDevices: string[]
|
||||
onAllDevicesChange: (checked: boolean) => void
|
||||
onNewDevicesChange: (checked: boolean) => void
|
||||
onShowDeviceSelector: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备选择组件
|
||||
* 用于选择设备
|
||||
*/
|
||||
export function DeviceSelectSection({
|
||||
allDevices,
|
||||
newDevices,
|
||||
targetDevices,
|
||||
onAllDevicesChange,
|
||||
onNewDevicesChange,
|
||||
onShowDeviceSelector,
|
||||
}: DeviceSelectProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allDevices"
|
||||
checked={allDevices}
|
||||
onCheckedChange={(checked) => onAllDevicesChange(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="allDevices" className="font-medium">
|
||||
所有设备
|
||||
</Label>
|
||||
<span className="text-xs text-gray-500">(系统自动分配流量到所有在线设备)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="newDevices"
|
||||
checked={newDevices}
|
||||
onCheckedChange={(checked) => onNewDevicesChange(checked === true)}
|
||||
disabled={allDevices}
|
||||
/>
|
||||
<Label htmlFor="newDevices" className="font-medium">
|
||||
新添加设备
|
||||
</Label>
|
||||
<span className="text-xs text-gray-500">(仅针对新添加设备进行流量分发)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-4">
|
||||
<Label className="font-medium">指定设备</Label>
|
||||
<p className="text-xs text-gray-500 mb-2">选择特定的设备进行流量分发</p>
|
||||
|
||||
<button
|
||||
className="w-full flex items-center justify-center space-x-2 border border-gray-300 rounded-md py-2 px-4 hover:bg-gray-50 transition-colors"
|
||||
disabled={allDevices}
|
||||
onClick={onShowDeviceSelector}
|
||||
>
|
||||
<span>选择设备</span>
|
||||
{targetDevices?.length > 0 && <Badge variant="secondary">已选 {targetDevices.length} 台</Badge>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
Cunkebao/app/components/ui-templates/stats.tsx
Normal file
58
Cunkebao/app/components/ui-templates/stats.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 统计组件模板
|
||||
*
|
||||
* 包含项目中常用的各种统计和数据展示组件
|
||||
*/
|
||||
|
||||
import type React from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface StatsSummaryProps {
|
||||
stats: Array<{
|
||||
title: string
|
||||
value: string | number
|
||||
color?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计摘要组件
|
||||
* 用于展示多个统计数据
|
||||
*/
|
||||
export function StatsSummary({ stats }: StatsSummaryProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-3">
|
||||
<div className="text-sm text-gray-500">{stat.title}</div>
|
||||
<div className={`text-xl font-bold ${stat.color || "text-blue-600"}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataCardProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
headerAction?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据卡片组件
|
||||
* 用于包装数据展示内容
|
||||
*/
|
||||
export function DataCard({ title, children, className = "", headerAction }: DataCardProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{headerAction}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
46
Cunkebao/app/components/ui-templates/step-indicator.tsx
Normal file
46
Cunkebao/app/components/ui-templates/step-indicator.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
interface StepIndicatorProps {
|
||||
steps: { id: number; title: string; subtitle?: string }[]
|
||||
currentStep: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepIndicator({ steps, currentStep, className }: StepIndicatorProps) {
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn("flex flex-col items-center", currentStep === step.id ? "text-blue-600" : "text-gray-400")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center text-lg font-medium mb-1",
|
||||
currentStep === step.id
|
||||
? "bg-blue-600 text-white"
|
||||
: currentStep > step.id
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "bg-gray-200 text-gray-500",
|
||||
)}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
<div className="text-xs">{step.title}</div>
|
||||
{step.subtitle && <div className="text-xs font-medium">{step.subtitle}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative h-1 bg-gray-200 mt-2">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
Cunkebao/app/components/ui-templates/tables.tsx
Normal file
198
Cunkebao/app/components/ui-templates/tables.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 表格组件模板
|
||||
*
|
||||
* 包含项目中常用的各种表格组件
|
||||
*/
|
||||
|
||||
import type React from "react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface Column<T> {
|
||||
header: string
|
||||
accessorKey: keyof T | ((row: T) => React.ReactNode)
|
||||
cell?: (row: T) => React.ReactNode
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
keyField: keyof T
|
||||
selectable?: boolean
|
||||
selectedRows?: (string | number)[]
|
||||
onSelectRow?: (id: string | number, checked: boolean) => void
|
||||
onSelectAll?: (checked: boolean) => void
|
||||
onRowClick?: (row: T) => void
|
||||
emptyMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据表格
|
||||
* 用于展示表格数据
|
||||
*/
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
keyField,
|
||||
selectable = false,
|
||||
selectedRows = [],
|
||||
onSelectRow,
|
||||
onSelectAll,
|
||||
onRowClick,
|
||||
emptyMessage = "没有数据",
|
||||
}: DataTableProps<T>) {
|
||||
const handleRowClick = (row: T) => {
|
||||
if (onRowClick) {
|
||||
onRowClick(row)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{selectable && onSelectRow && onSelectAll && (
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && selectedRows.length === data.length}
|
||||
onCheckedChange={(checked) => onSelectAll(checked === true)}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{columns.map((column, index) => (
|
||||
<TableHead key={index}>{column.header}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + (selectable ? 1 : 0)} className="text-center py-6 text-gray-500">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<TableRow
|
||||
key={String(row[keyField])}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{selectable && onSelectRow && (
|
||||
<TableCell className="w-12" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(row[keyField])}
|
||||
onCheckedChange={(checked) => onSelectRow(row[keyField], checked === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.map((column, index) => (
|
||||
<TableCell key={index}>
|
||||
{column.cell
|
||||
? column.cell(row)
|
||||
: typeof column.accessorKey === "function"
|
||||
? column.accessorKey(row)
|
||||
: row[column.accessorKey]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DeviceTableProps {
|
||||
devices: Array<{
|
||||
id: string
|
||||
name: string
|
||||
imei: string
|
||||
status: "online" | "offline"
|
||||
battery?: number
|
||||
wechatId?: string
|
||||
lastActive?: string
|
||||
}>
|
||||
selectedDevices: string[]
|
||||
onSelectDevice: (deviceId: string, checked: boolean) => void
|
||||
onSelectAll: (checked: boolean) => void
|
||||
onDeviceClick: (deviceId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备表格
|
||||
* 用于展示设备列表
|
||||
*/
|
||||
export function DeviceTableTemplate({
|
||||
devices,
|
||||
selectedDevices,
|
||||
onSelectDevice,
|
||||
onSelectAll,
|
||||
onDeviceClick,
|
||||
}: DeviceTableProps) {
|
||||
const columns: Column<(typeof devices)[0]>[] = [
|
||||
{
|
||||
header: "设备名称",
|
||||
accessorKey: "name",
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.name}</div>
|
||||
<div className="text-xs text-gray-500">IMEI: {row.imei}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "状态",
|
||||
accessorKey: "status",
|
||||
cell: (row) => (
|
||||
<Badge variant={row.status === "online" ? "success" : "secondary"}>
|
||||
{row.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "微信号",
|
||||
accessorKey: "wechatId",
|
||||
cell: (row) => row.wechatId || "-",
|
||||
},
|
||||
{
|
||||
header: "电量",
|
||||
accessorKey: "battery",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center">
|
||||
<div className="w-16 h-2 bg-gray-200 rounded-full overflow-hidden mr-2">
|
||||
<div
|
||||
className={`h-full ${(row.battery || 0) > 20 ? "bg-green-500" : "bg-red-500"}`}
|
||||
style={{ width: `${row.battery || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span>{row.battery || 0}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "最后活跃",
|
||||
accessorKey: "lastActive",
|
||||
cell: (row) => row.lastActive || "-",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={devices}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
selectable={true}
|
||||
selectedRows={selectedDevices}
|
||||
onSelectRow={onSelectDevice}
|
||||
onSelectAll={onSelectAll}
|
||||
onRowClick={(row) => onDeviceClick(row.id)}
|
||||
emptyMessage="没有找到设备"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,344 +1,117 @@
|
||||
"use client"
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Settings } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { StepIndicator } from "@/app/components/ui-templates/step-indicator"
|
||||
import { BasicSettings } from "./steps/BasicSettings"
|
||||
import { FriendRequestSettings } from "./steps/FriendRequestSettings"
|
||||
import { MessageSettings } from "./steps/MessageSettings"
|
||||
import { TagSettings } from "./steps/TagSettings"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
// 步骤定义 - 只保留三个步骤
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
{ id: 4, title: "步骤四", subtitle: "流量标签" },
|
||||
]
|
||||
|
||||
// 场景分类规则
|
||||
const scenarioRules = {
|
||||
LIVE: ["直播", "直播间", "主播", "抖音"],
|
||||
COMMENT: ["评论", "互动", "回复", "小红书"],
|
||||
GROUP: ["群", "社群", "群聊", "微信群"],
|
||||
ARTICLE: ["文章", "笔记", "内容", "公众号"],
|
||||
}
|
||||
|
||||
// 根据计划名称和标签自动判断场景
|
||||
const determineScenario = (planName: string, tags: any[]) => {
|
||||
// 优先使用标签进行分类
|
||||
if (tags && tags.length > 0) {
|
||||
const firstTag = tags[0]
|
||||
if (firstTag.name?.includes("直播") || firstTag.name?.includes("抖音")) return "douyin"
|
||||
if (firstTag.name?.includes("评论") || firstTag.name?.includes("小红书")) return "xiaohongshu"
|
||||
if (firstTag.name?.includes("群") || firstTag.name?.includes("微信")) return "weixinqun"
|
||||
if (firstTag.name?.includes("文章") || firstTag.name?.includes("公众号")) return "gongzhonghao"
|
||||
}
|
||||
|
||||
// 如果没有标签,使用计划名称进行分类
|
||||
const planNameLower = planName.toLowerCase()
|
||||
if (planNameLower.includes("直播") || planNameLower.includes("抖音")) return "douyin"
|
||||
if (planNameLower.includes("评论") || planNameLower.includes("小红书")) return "xiaohongshu"
|
||||
if (planNameLower.includes("群") || planNameLower.includes("微信")) return "weixinqun"
|
||||
if (planNameLower.includes("文章") || planNameLower.includes("公众号")) return "gongzhonghao"
|
||||
return "other"
|
||||
}
|
||||
|
||||
export default function NewAcquisitionPlan() {
|
||||
export default function NewPlan() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const type = searchParams.get("type")
|
||||
const source = searchParams.get("source")
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [formData, setFormData] = useState({
|
||||
planName: "",
|
||||
scenario: type === "order" ? "order" : "",
|
||||
accounts: [],
|
||||
materials: [],
|
||||
enabled: true,
|
||||
scenario: "haibao",
|
||||
posters: [],
|
||||
device: "",
|
||||
remarkType: "phone",
|
||||
remarkKeyword: "",
|
||||
greeting: "",
|
||||
addFriendTimeStart: "09:00",
|
||||
addFriendTimeEnd: "18:00",
|
||||
addFriendInterval: 1,
|
||||
maxDailyFriends: 20,
|
||||
messageInterval: 1,
|
||||
messageContent: "",
|
||||
tags: [],
|
||||
selectedDevices: [],
|
||||
messagePlans: [],
|
||||
importedTags: [],
|
||||
sourceWechatId: source || "",
|
||||
teams: [], // 添加 teams 字段
|
||||
greeting: "你好,请通过",
|
||||
addInterval: 1,
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
enabled: true,
|
||||
// 移除tags字段
|
||||
})
|
||||
|
||||
// 如果是从微信号好友转移过来,自动设置计划名称
|
||||
useEffect(() => {
|
||||
if (type === "order" && source) {
|
||||
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
planName: `${source}好友转移${today}`,
|
||||
scenario: "order",
|
||||
}))
|
||||
// 更新表单数据
|
||||
const onChange = (data: any) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }))
|
||||
}
|
||||
|
||||
// 模拟加载好友数据
|
||||
setTimeout(() => {
|
||||
toast({
|
||||
title: "好友数据加载成功",
|
||||
description: `已从微信号 ${source} 导入好友数据`,
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}, [type, source])
|
||||
// 处理保存
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 根据URL参数设置场景类型
|
||||
useEffect(() => {
|
||||
if (type && type !== "order" && !formData.scenario) {
|
||||
const validScenarios = [
|
||||
"douyin",
|
||||
"kuaishou",
|
||||
"xiaohongshu",
|
||||
"weibo",
|
||||
"haibao",
|
||||
"phone",
|
||||
"weixinqun",
|
||||
"gongzhonghao",
|
||||
]
|
||||
if (validScenarios.includes(type)) {
|
||||
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
scenario: type,
|
||||
planName: `${type === "phone" ? "电话获客" : type}${today}`,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [type, formData.scenario])
|
||||
|
||||
const handleSave = () => {
|
||||
// 根据标签和计划名称自动判断场景
|
||||
const scenario = formData.scenario || determineScenario(formData.planName, formData.tags)
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = {
|
||||
sceneId: getSceneIdFromScenario(scenario), // 获取场景ID
|
||||
name: formData.planName,
|
||||
status: formData.enabled ? 1 : 0,
|
||||
reqConf: JSON.stringify({
|
||||
remarkType: formData.remarkType,
|
||||
remarkKeyword: formData.remarkKeyword,
|
||||
greeting: formData.greeting,
|
||||
addFriendTimeStart: formData.addFriendTimeStart,
|
||||
addFriendTimeEnd: formData.addFriendTimeEnd,
|
||||
addFriendInterval: formData.addFriendInterval,
|
||||
maxDailyFriends: formData.maxDailyFriends,
|
||||
selectedDevices: formData.selectedDevices,
|
||||
}),
|
||||
msgConf: JSON.stringify({
|
||||
messageInterval: formData.messageInterval,
|
||||
messagePlans: formData.messagePlans,
|
||||
}),
|
||||
tagConf: JSON.stringify({
|
||||
tags: formData.tags,
|
||||
importedTags: formData.importedTags,
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "获客计划已创建",
|
||||
})
|
||||
}
|
||||
|
||||
// 调用API创建获客计划
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/plan/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.code === 200) {
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "获客计划已创建完成",
|
||||
})
|
||||
// 跳转到首页
|
||||
router.push("/")
|
||||
} else {
|
||||
toast({
|
||||
title: "创建失败",
|
||||
description: data.msg || "服务器错误,请稍后重试",
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("创建获客计划失败:", error);
|
||||
router.push("/plans")
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "创建失败",
|
||||
description: "网络错误,请稍后重试",
|
||||
variant: "destructive"
|
||||
description: "创建计划失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 将场景类型转换为场景ID
|
||||
const getSceneIdFromScenario = (scenario: string): number => {
|
||||
const scenarioMap: Record<string, number> = {
|
||||
'douyin': 1,
|
||||
'xiaohongshu': 2,
|
||||
'weixinqun': 3,
|
||||
'gongzhonghao': 4,
|
||||
'kuaishou': 5,
|
||||
'weibo': 6,
|
||||
'haibao': 7,
|
||||
'phone': 8,
|
||||
'order': 9,
|
||||
'other': 10
|
||||
}
|
||||
return scenarioMap[scenario] || 1 // 默认返回1
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prevStep) => Math.max(prevStep - 1, 1))
|
||||
}
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (isStepValid()) {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave()
|
||||
} else {
|
||||
setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length))
|
||||
}
|
||||
if (currentStep === steps.length) {
|
||||
handleSave()
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isStepValid = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
if (!formData.planName.trim()) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写计划名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 2:
|
||||
// 如果是订单导入场景,跳过好友申请设置验证
|
||||
if (formData.scenario === "order") {
|
||||
return true
|
||||
}
|
||||
// 修改:不再要求必须选择设备
|
||||
if (!formData.greeting?.trim()) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写好友申请信息",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 3:
|
||||
// 如果是订单导入场景,跳过消息设置验证
|
||||
if (formData.scenario === "order") {
|
||||
return true
|
||||
}
|
||||
if (formData.messagePlans?.length === 0) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请设置至少一条消息",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 4:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
// 上一步
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1))
|
||||
}
|
||||
|
||||
// 渲染当前步骤内容
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} />
|
||||
return <BasicSettings formData={formData} onChange={onChange} onNext={handleNext} />
|
||||
case 2:
|
||||
return (
|
||||
<FriendRequestSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
|
||||
)
|
||||
return <FriendRequestSettings formData={formData} onChange={onChange} onNext={handleNext} onPrev={handlePrev} />
|
||||
case 3:
|
||||
return <MessageSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
|
||||
case 4:
|
||||
return <TagSettings formData={formData} onNext={handleSave} onPrev={handlePrev} onChange={setFormData} />
|
||||
return <MessageSettings formData={formData} onChange={onChange} onNext={handleSave} onPrev={handlePrev} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是订单导入场景,直接跳到标签设置步骤
|
||||
useEffect(() => {
|
||||
// 只有在订单场景下,才自动开启步骤1,而不是直接跳到步骤4
|
||||
if (formData.scenario === "order" && currentStep === 1 && formData.planName) {
|
||||
// 保持在步骤1,不再自动跳转到步骤4
|
||||
// 之前的逻辑是直接跳到步骤4:setCurrentStep(4)
|
||||
}
|
||||
}, [formData.scenario, currentStep, formData.planName])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
||||
<div className="w-full bg-white min-h-screen flex flex-col">
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/scenarios")}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<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")}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">新建获客计划</h1>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">{formData.sourceWechatId ? "好友转移" : "新建获客计划"}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* 步骤指示器样式 */}
|
||||
<div className="bg-white border-gray-200">
|
||||
<div className="px-5 py-5">
|
||||
<div className="flex justify-between">
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`rounded-full h-8 w-8 flex items-center justify-center ${
|
||||
currentStep >= step.id ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
<div className="text-xs font-medium mt-2 text-center">{step.subtitle}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-4 px-4">
|
||||
{steps.slice(0, steps.length - 1).map((step, index) => (
|
||||
<div
|
||||
key={`line-${step.id}`}
|
||||
className={`h-1 w-full ${currentStep > step.id ? "bg-blue-600" : "bg-gray-200"}`}
|
||||
style={{
|
||||
width: `${100 / (steps.length - 1)}%`,
|
||||
marginLeft: index === 0 ? "10px" : "0",
|
||||
marginRight: index === steps.length - 2 ? "10px" : "0",
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-6">
|
||||
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4">{renderStepContent()}</div>
|
||||
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,13 @@ 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 { HelpCircle, MessageSquare, AlertCircle, RefreshCw } from "lucide-react"
|
||||
import { HelpCircle, MessageSquare, AlertCircle } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { ChevronsUpDown } from "lucide-react"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { fetchDeviceList } from "@/api/devices"
|
||||
import type { ServerDevice } from "@/types/device"
|
||||
|
||||
interface FriendRequestSettingsProps {
|
||||
formData: any
|
||||
@@ -38,15 +36,20 @@ const remarkTypes = [
|
||||
{ value: "source", label: "来源" },
|
||||
]
|
||||
|
||||
// 模拟设备数据
|
||||
const mockDevices = [
|
||||
{ id: "1", name: "iPhone 13 Pro", status: "online" },
|
||||
{ id: "2", name: "Xiaomi 12", status: "online" },
|
||||
{ id: "3", name: "Huawei P40", status: "offline" },
|
||||
{ id: "4", name: "OPPO Find X3", status: "online" },
|
||||
{ id: "5", name: "Samsung S21", status: "online" },
|
||||
]
|
||||
|
||||
export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: FriendRequestSettingsProps) {
|
||||
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
|
||||
const [hasWarnings, setHasWarnings] = useState(false)
|
||||
const [isDeviceSelectorOpen, setIsDeviceSelectorOpen] = useState(false)
|
||||
const [selectedDevices, setSelectedDevices] = useState<ServerDevice[]>(formData.selectedDevices || [])
|
||||
const [devices, setDevices] = useState<ServerDevice[]>([])
|
||||
const [loadingDevices, setLoadingDevices] = useState(false)
|
||||
const [deviceError, setDeviceError] = useState<string | null>(null)
|
||||
const [searchKeyword, setSearchKeyword] = useState("")
|
||||
const [selectedDevices, setSelectedDevices] = useState<any[]>(formData.selectedDevices || [])
|
||||
|
||||
// 获取场景标题
|
||||
const getScenarioTitle = () => {
|
||||
@@ -64,33 +67,6 @@ export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: Fr
|
||||
}
|
||||
}
|
||||
|
||||
// 加载设备列表
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
setLoadingDevices(true)
|
||||
setDeviceError(null)
|
||||
|
||||
const response = await fetchDeviceList(1, 100, searchKeyword)
|
||||
|
||||
if (response.code === 200 && response.data?.list) {
|
||||
setDevices(response.data.list)
|
||||
} else {
|
||||
setDeviceError(response.msg || "获取设备列表失败")
|
||||
console.error("获取设备列表失败:", response.msg)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("获取设备列表失败:", err)
|
||||
setDeviceError("获取设备列表失败,请稍后重试")
|
||||
} finally {
|
||||
setLoadingDevices(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时加载设备列表
|
||||
useEffect(() => {
|
||||
loadDevices()
|
||||
}, [])
|
||||
|
||||
// 使用useEffect设置默认值
|
||||
useEffect(() => {
|
||||
if (!formData.greeting) {
|
||||
@@ -120,7 +96,7 @@ export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: Fr
|
||||
onNext()
|
||||
}
|
||||
|
||||
const toggleDeviceSelection = (device: ServerDevice) => {
|
||||
const toggleDeviceSelection = (device: any) => {
|
||||
const isSelected = selectedDevices.some((d) => d.id === device.id)
|
||||
let newSelectedDevices
|
||||
|
||||
@@ -134,13 +110,8 @@ export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: Fr
|
||||
onChange({ ...formData, selectedDevices: newSelectedDevices })
|
||||
}
|
||||
|
||||
// 根据关键词搜索设备
|
||||
const handleSearch = () => {
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 bg-gray-50">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-base">选择设备</Label>
|
||||
@@ -157,77 +128,31 @@ export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: Fr
|
||||
{isDeviceSelectorOpen && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg">
|
||||
<div className="p-2">
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
placeholder="搜索设备..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<Button variant="outline" size="icon" onClick={handleSearch}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingDevices ? (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-blue-500 rounded-full border-t-transparent"></div>
|
||||
</div>
|
||||
) : deviceError ? (
|
||||
<div className="text-center text-red-500 py-4">
|
||||
{deviceError}
|
||||
<Button variant="outline" size="sm" onClick={loadDevices} className="ml-2">
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
没有找到设备
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center justify-between p-2 hover:bg-gray-100 cursor-pointer"
|
||||
onClick={() => toggleDeviceSelection(device)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedDevices.some((d) => d.id === device.id)}
|
||||
onCheckedChange={() => toggleDeviceSelection(device)}
|
||||
/>
|
||||
<span>{device.memo}</span>
|
||||
</div>
|
||||
<span className={`text-xs ${device.alive === 1 ? "text-green-500" : "text-gray-400"}`}>
|
||||
{device.alive === 1 ? "在线" : "离线"}
|
||||
</span>
|
||||
<Input placeholder="搜索设备..." className="mb-2" />
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{mockDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center justify-between p-2 hover:bg-gray-100 cursor-pointer"
|
||||
onClick={() => toggleDeviceSelection(device)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedDevices.some((d) => d.id === device.id)}
|
||||
onCheckedChange={() => toggleDeviceSelection(device)}
|
||||
/>
|
||||
<span>{device.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className={`text-xs ${device.status === "online" ? "text-green-500" : "text-gray-400"}`}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedDevices.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedDevices.map((device) => (
|
||||
<div key={device.id} className="flex items-center bg-gray-100 rounded-full px-3 py-1">
|
||||
<span className="text-sm">{device.memo}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 p-0"
|
||||
onClick={() => toggleDeviceSelection(device)}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -314,7 +239,7 @@ export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: Fr
|
||||
</div>
|
||||
|
||||
{hasWarnings && (
|
||||
<Alert variant="destructive" className="bg-amber-50 border-amber-200">
|
||||
<Alert variant="warning" className="bg-amber-50 border-amber-200">
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
<AlertDescription>您有未完成的设置项,建议完善后再进入下一步。</AlertDescription>
|
||||
</Alert>
|
||||
@@ -347,7 +272,6 @@ export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: Fr
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
|
||||
type: "text",
|
||||
content: "",
|
||||
sendInterval: 5,
|
||||
intervalUnit: "minutes",
|
||||
intervalUnit: "seconds", // 默认改为秒
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -103,7 +103,7 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
|
||||
if (dayPlans[dayIndex].day === 0) {
|
||||
// 即时消息使用间隔设置
|
||||
newMessage.sendInterval = 5
|
||||
newMessage.intervalUnit = "minutes"
|
||||
newMessage.intervalUnit = "seconds" // 默认改为秒
|
||||
} else {
|
||||
// 非即时消息使用具体时间设置
|
||||
newMessage.scheduledTime = {
|
||||
@@ -192,7 +192,7 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 bg-gray-50">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">消息设置</h2>
|
||||
@@ -549,7 +549,6 @@ export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageS
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
372
Cunkebao/app/scenarios/[channel]/[id]/page.tsx
Normal file
372
Cunkebao/app/scenarios/[channel]/[id]/page.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ArrowLeft, Edit, Phone, MessageSquare, FileText } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
|
||||
interface ScenarioDetail {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
status: "active" | "inactive" | "draft"
|
||||
createdAt: string
|
||||
description: string
|
||||
devices: {
|
||||
total: number
|
||||
online: number
|
||||
}
|
||||
stats: {
|
||||
total: number
|
||||
today: number
|
||||
conversion: number
|
||||
history: Array<{
|
||||
date: string
|
||||
value: number
|
||||
}>
|
||||
}
|
||||
settings: {
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export default function ScenarioDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { channel, id } = params
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [scenario, setScenario] = useState<ScenarioDetail | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟API请求
|
||||
const fetchScenarioDetail = async () => {
|
||||
setIsLoading(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 生成过去30天的数据
|
||||
const historyData = Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - (29 - i))
|
||||
return {
|
||||
date: date.toISOString().split("T")[0],
|
||||
value: Math.floor(Math.random() * 20) + 1,
|
||||
}
|
||||
})
|
||||
|
||||
const mockScenario: ScenarioDetail = {
|
||||
id: id as string,
|
||||
name: `${channel === "haibao" ? "海报" : channel === "douyin" ? "抖音" : channel === "phone" ? "电话" : "其他"}获客计划`,
|
||||
type: channel as string,
|
||||
status: "active",
|
||||
createdAt: "2023-05-15",
|
||||
description: "这是一个用于获取新客户的场景获客计划",
|
||||
devices: {
|
||||
total: 12,
|
||||
online: 8,
|
||||
},
|
||||
stats: {
|
||||
total: 342,
|
||||
today: 18,
|
||||
conversion: 0.32,
|
||||
history: historyData,
|
||||
},
|
||||
settings: {
|
||||
greeting: "你好,请通过我的好友请求",
|
||||
remarkType: "phone",
|
||||
addFriendInterval: 60,
|
||||
enableMessage: true,
|
||||
message: "您好,很高兴认识您!",
|
||||
delayTime: 5,
|
||||
},
|
||||
}
|
||||
|
||||
setScenario(mockScenario)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
fetchScenarioDetail()
|
||||
}, [id, channel])
|
||||
|
||||
const handleToggleStatus = () => {
|
||||
if (!scenario) return
|
||||
|
||||
const newStatus = scenario.status === "active" ? "inactive" : "active"
|
||||
setScenario({ ...scenario, status: newStatus as "active" | "inactive" | "draft" })
|
||||
|
||||
toast({
|
||||
title: `${newStatus === "active" ? "启用" : "停用"}成功`,
|
||||
description: `场景获客计划已${newStatus === "active" ? "启用" : "停用"}`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/scenarios/${channel}/edit/${id}`)
|
||||
}
|
||||
|
||||
const getScenarioTypeIcon = () => {
|
||||
switch (channel) {
|
||||
case "phone":
|
||||
return <Phone className="h-5 w-5" />
|
||||
case "order":
|
||||
return <FileText className="h-5 w-5" />
|
||||
case "douyin":
|
||||
return <MessageSquare className="h-5 w-5" />
|
||||
default:
|
||||
return <FileText className="h-5 w-5" />
|
||||
}
|
||||
}
|
||||
|
||||
const getScenarioTypeName = () => {
|
||||
switch (channel) {
|
||||
case "haibao":
|
||||
return "海报获客"
|
||||
case "douyin":
|
||||
return "抖音获客"
|
||||
case "phone":
|
||||
return "电话获客"
|
||||
case "xiaohongshu":
|
||||
return "小红书获客"
|
||||
case "order":
|
||||
return "订单获客"
|
||||
case "weixinqun":
|
||||
return "微信群获客"
|
||||
case "gongzhonghao":
|
||||
return "公众号获客"
|
||||
default:
|
||||
return "其他获客"
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">运行中</Badge>
|
||||
case "inactive":
|
||||
return <Badge className="bg-gray-100 text-gray-800 hover:bg-gray-100">已停用</Badge>
|
||||
case "draft":
|
||||
return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">草稿</Badge>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<Skeleton className="h-6 w-40 ml-2" />
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4 space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!scenario) {
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">场景详情</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-col items-center justify-center p-4 h-[80vh]">
|
||||
<p className="text-gray-500 mb-4">未找到场景获客计划</p>
|
||||
<Button onClick={() => router.push("/scenarios")}>返回列表</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">{scenario.name}</h1>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Switch checked={scenario.status === "active"} onCheckedChange={handleToggleStatus} className="mr-2" />
|
||||
<Button size="sm" variant="outline" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{getScenarioTypeIcon()}
|
||||
<span className="ml-1 text-sm text-gray-500">{getScenarioTypeName()}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-gray-500">创建于 {scenario.createdAt}</span>
|
||||
{getStatusBadge(scenario.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">获客数据</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-gray-50 p-2 rounded-lg text-center">
|
||||
<p className="text-xs text-gray-500">总获客</p>
|
||||
<p className="text-lg font-semibold">{scenario.stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded-lg text-center">
|
||||
<p className="text-xs text-gray-500">今日</p>
|
||||
<p className="text-lg font-semibold">{scenario.stats.today}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded-lg text-center">
|
||||
<p className="text-xs text-gray-500">转化率</p>
|
||||
<p className="text-lg font-semibold">{(scenario.stats.conversion * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">获客趋势</CardTitle>
|
||||
<CardDescription className="text-xs">过去30天的获客数量</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48">
|
||||
<ChartContainer
|
||||
config={{
|
||||
value: {
|
||||
label: "获客数量",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
}}
|
||||
className="h-full"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={scenario.stats.history} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Line type="monotone" dataKey="value" stroke="var(--color-value)" name="获客数量" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">设备信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总设备数</span>
|
||||
<span className="font-medium">{scenario.devices.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">在线设备</span>
|
||||
<span className="font-medium">{scenario.devices.online}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">设备在线率</span>
|
||||
<span className="font-medium">
|
||||
{((scenario.devices.online / scenario.devices.total) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-2"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/scenarios/${channel}/devices`)}
|
||||
>
|
||||
查看设备详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">场景设置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
{scenario.settings.greeting && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">招呼语</span>
|
||||
<span className="font-medium">{scenario.settings.greeting}</span>
|
||||
</div>
|
||||
)}
|
||||
{scenario.settings.remarkType && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">备注类型</span>
|
||||
<span className="font-medium">
|
||||
{scenario.settings.remarkType === "phone"
|
||||
? "手机号"
|
||||
: scenario.settings.remarkType === "nickname"
|
||||
? "昵称"
|
||||
: "来源"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{scenario.settings.addFriendInterval && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">添加频率</span>
|
||||
<span className="font-medium">{scenario.settings.addFriendInterval} 秒/次</span>
|
||||
</div>
|
||||
)}
|
||||
{scenario.settings.enableMessage && (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">自动消息</span>
|
||||
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">已启用</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">消息内容</span>
|
||||
<span className="font-medium">{scenario.settings.message}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">发送延迟</span>
|
||||
<span className="font-medium">{scenario.settings.delayTime} 分钟</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Plus } from "lucide-react"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -50,17 +50,13 @@ export default function AcquiredCustomersPage({ params }: { params: { channel: s
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center 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-xl font-semibold text-blue-600">{channelName}已获客</h1>
|
||||
</div>
|
||||
<Button variant="default" onClick={() => router.push(`/scenarios/new`)} className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -116,4 +112,3 @@ export default function AcquiredCustomersPage({ params }: { params: { channel: s
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Plus } from "lucide-react"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -53,17 +53,13 @@ export default function AddedCustomersPage({ params }: { params: { channel: stri
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center 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-xl font-semibold text-blue-600">{channelName}已添加</h1>
|
||||
</div>
|
||||
<Button variant="default" onClick={() => router.push(`/scenarios/new`)} className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -120,4 +116,3 @@ export default function AddedCustomersPage({ params }: { params: { channel: stri
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Copy, Plus, Trash2 } from "lucide-react"
|
||||
import { ChevronLeft, Copy, FileText, Play, Info, Link } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
@@ -17,7 +15,8 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
@@ -32,432 +31,361 @@ const getChannelName = (channel: string) => {
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
createdAt: string
|
||||
lastUsed: string | null
|
||||
status: "active" | "inactive"
|
||||
}
|
||||
|
||||
interface Webhook {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
events: string[]
|
||||
createdAt: string
|
||||
lastTriggered: string | null
|
||||
status: "active" | "inactive"
|
||||
}
|
||||
|
||||
export default function ApiManagementPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const channel = params.channel
|
||||
const channelName = getChannelName(channel)
|
||||
|
||||
// 模拟API密钥数据
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: `${channelName}获客API密钥`,
|
||||
key: `api_${channel}_${Math.random().toString(36).substring(2, 10)}`,
|
||||
createdAt: "2024-03-20 14:30:00",
|
||||
lastUsed: "2024-03-21 09:15:22",
|
||||
status: "active",
|
||||
},
|
||||
])
|
||||
|
||||
// 模拟Webhook数据
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: `${channelName}获客回调`,
|
||||
url: `https://api.example.com/webhooks/${channel}`,
|
||||
events: ["customer.created", "customer.updated", "tag.added"],
|
||||
createdAt: "2024-03-20 14:35:00",
|
||||
lastTriggered: "2024-03-21 09:16:45",
|
||||
status: "active",
|
||||
},
|
||||
])
|
||||
// 示例数据
|
||||
const apiKey = `api_1_b9805j8q`
|
||||
const apiUrl = `https://kzmoqjnwgjc9q2xbj4np.lite.vusercontent.net/api/scenarios/${channel}/1/webhook`
|
||||
const testUrl = `${apiUrl}?name=测试客户&phone=13800138000`
|
||||
|
||||
// 对话框状态
|
||||
const [showNewApiKeyDialog, setShowNewApiKeyDialog] = useState(false)
|
||||
const [showNewWebhookDialog, setShowNewWebhookDialog] = useState(false)
|
||||
const [newApiKeyName, setNewApiKeyName] = useState("")
|
||||
const [newWebhookData, setNewWebhookData] = useState({
|
||||
name: "",
|
||||
url: "",
|
||||
events: ["customer.created", "customer.updated", "tag.added"],
|
||||
})
|
||||
|
||||
// 创建新API密钥
|
||||
const handleCreateApiKey = () => {
|
||||
if (!newApiKeyName.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入API密钥名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newKey: ApiKey = {
|
||||
id: `${Date.now()}`,
|
||||
name: newApiKeyName,
|
||||
key: `api_${channel}_${Math.random().toString(36).substring(2, 15)}`,
|
||||
createdAt: new Date().toLocaleString(),
|
||||
lastUsed: null,
|
||||
status: "active",
|
||||
}
|
||||
|
||||
setApiKeys([...apiKeys, newKey])
|
||||
setNewApiKeyName("")
|
||||
setShowNewApiKeyDialog(false)
|
||||
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "新的API密钥已创建",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 创建新Webhook
|
||||
const handleCreateWebhook = () => {
|
||||
if (!newWebhookData.name.trim() || !newWebhookData.url.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请填写所有必填字段",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newWebhook: Webhook = {
|
||||
id: `${Date.now()}`,
|
||||
name: newWebhookData.name,
|
||||
url: newWebhookData.url,
|
||||
events: newWebhookData.events,
|
||||
createdAt: new Date().toLocaleString(),
|
||||
lastTriggered: null,
|
||||
status: "active",
|
||||
}
|
||||
|
||||
setWebhooks([...webhooks, newWebhook])
|
||||
setNewWebhookData({
|
||||
name: "",
|
||||
url: "",
|
||||
events: ["customer.created", "customer.updated", "tag.added"],
|
||||
})
|
||||
setShowNewWebhookDialog(false)
|
||||
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "新的Webhook已创建",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 删除API密钥
|
||||
const handleDeleteApiKey = (id: string) => {
|
||||
setApiKeys(apiKeys.filter((key) => key.id !== id))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "API密钥已删除",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 删除Webhook
|
||||
const handleDeleteWebhook = (id: string) => {
|
||||
setWebhooks(webhooks.filter((webhook) => webhook.id !== id))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "Webhook已删除",
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 切换API密钥状态
|
||||
const toggleApiKeyStatus = (id: string) => {
|
||||
setApiKeys(
|
||||
apiKeys.map((key) => (key.id === id ? { ...key, status: key.status === "active" ? "inactive" : "active" } : key)),
|
||||
)
|
||||
}
|
||||
|
||||
// 切换Webhook状态
|
||||
const toggleWebhookStatus = (id: string) => {
|
||||
setWebhooks(
|
||||
webhooks.map((webhook) =>
|
||||
webhook.id === id ? { ...webhook, status: webhook.status === "active" ? "inactive" : "active" } : webhook,
|
||||
),
|
||||
)
|
||||
}
|
||||
const [showDocDialog, setShowDocDialog] = useState(false)
|
||||
const [showTestDialog, setShowTestDialog] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex-1 bg-white min-h-screen pb-20">
|
||||
<header className="sticky top-0 z-10 bg-white shadow-sm">
|
||||
<div className="flex items-center p-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600 ml-2">{channelName}获客接口管理</h1>
|
||||
<h1 className="text-lg font-medium ml-2">{channelName}获客接口</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
<Tabs defaultValue="api-keys" className="w-full">
|
||||
<div className="p-4 max-w-md mx-auto space-y-5">
|
||||
{/* 接口位置提示 */}
|
||||
<div className="bg-blue-50 rounded-lg p-3 flex items-start">
|
||||
<Info className="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm text-blue-700">
|
||||
<span className="font-medium">接口位置:</span>场景获客 → {channelName}获客 → 选择计划 → 右上角菜单 →
|
||||
计划接口
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="api" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||
<TabsTrigger value="api-keys">API密钥</TabsTrigger>
|
||||
<TabsTrigger value="webhooks">Webhook</TabsTrigger>
|
||||
<TabsTrigger value="api">接口信息</TabsTrigger>
|
||||
<TabsTrigger value="params">参数说明</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="api-keys" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-medium">API密钥管理</h2>
|
||||
<Button onClick={() => setShowNewApiKeyDialog(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建API密钥
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{apiKeys.map((apiKey) => (
|
||||
<Card key={apiKey.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>{apiKey.name}</CardTitle>
|
||||
<CardDescription>创建于 {apiKey.createdAt}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={apiKey.status === "active"}
|
||||
onCheckedChange={() => toggleApiKeyStatus(apiKey.id)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{apiKey.status === "active" ? "启用" : "禁用"}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleDeleteApiKey(apiKey.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<TabsContent value="api" className="space-y-4">
|
||||
{/* API密钥部分 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-base font-medium">API密钥</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 ml-1">
|
||||
<Info className="h-4 w-4 text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input value={apiKey.key} readOnly className="font-mono text-sm" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">用于接口身份验证</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey.key)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "API密钥已复制到剪贴板",
|
||||
variant: "success",
|
||||
})
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowDocDialog(true)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{apiKey.lastUsed && <p className="text-sm text-gray-500">上次使用: {apiKey.lastUsed}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>查看接口文档</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{apiKeys.length === 0 && (
|
||||
<div className="text-center py-8 bg-white rounded-lg shadow-sm">
|
||||
<p className="text-gray-500">暂无API密钥</p>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowTestDialog(true)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>快速测试</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border-gray-200">
|
||||
<CardContent className="p-3">
|
||||
<div className="relative w-full">
|
||||
<Input value={apiKey} readOnly className="font-mono text-sm pr-10 border-gray-200" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "API密钥已复制到剪贴板",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 接口地址部分 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-base font-medium">接口地址</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 ml-1">
|
||||
<Link className="h-4 w-4 text-gray-400" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">发送POST请求到此地址</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2 border-blue-200 text-blue-600 hover:bg-blue-50"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiUrl)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "接口地址已复制到剪贴板",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border-gray-200">
|
||||
<CardContent className="p-3">
|
||||
<div className="w-full">
|
||||
<div className="font-mono text-xs bg-gray-50 p-2 rounded border border-gray-200 break-all">
|
||||
{apiUrl}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="webhooks" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-medium">Webhook管理</h2>
|
||||
<Button onClick={() => setShowNewWebhookDialog(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建Webhook
|
||||
</Button>
|
||||
<TabsContent value="params" className="space-y-4">
|
||||
{/* 必要参数部分 */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-base font-medium flex items-center">
|
||||
必要参数
|
||||
<span className="text-xs text-red-500 ml-1">*</span>
|
||||
</h2>
|
||||
<Card className="overflow-hidden border-gray-200">
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-blue-600">name</span>
|
||||
<span className="text-sm text-gray-500 ml-2">(姓名)</span>
|
||||
</div>
|
||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">字符串</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-blue-600">phone</span>
|
||||
<span className="text-sm text-gray-500 ml-2">(电话)</span>
|
||||
</div>
|
||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">字符串</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{webhooks.map((webhook) => (
|
||||
<Card key={webhook.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
{/* 可选参数部分 */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-base font-medium">可选参数</h2>
|
||||
<Card className="overflow-hidden border-gray-200">
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{webhook.name}</CardTitle>
|
||||
<CardDescription>创建于 {webhook.createdAt}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={webhook.status === "active"}
|
||||
onCheckedChange={() => toggleWebhookStatus(webhook.id)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{webhook.status === "active" ? "启用" : "禁用"}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleDeleteWebhook(webhook.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="font-medium text-gray-600">source</span>
|
||||
<span className="text-sm text-gray-500 ml-2">(来源)</span>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded">字符串</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input value={webhook.url} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(webhook.url)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "Webhook URL已复制到剪贴板",
|
||||
variant: "success",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">remark</span>
|
||||
<span className="text-sm text-gray-500 ml-2">(备注)</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{webhook.events.map((event) => (
|
||||
<span key={event} className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs">
|
||||
{event}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{webhook.lastTriggered && (
|
||||
<p className="text-sm text-gray-500">上次触发: {webhook.lastTriggered}</p>
|
||||
)}
|
||||
<span className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded">字符串</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">tags</span>
|
||||
<span className="text-sm text-gray-500 ml-2">(标签)</span>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded">数组</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{webhooks.length === 0 && (
|
||||
<div className="text-center py-8 bg-white rounded-lg shadow-sm">
|
||||
<p className="text-gray-500">暂无Webhook</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 示例代码部分 */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-base font-medium">请求示例</h2>
|
||||
<Card className="overflow-hidden border-gray-200">
|
||||
<CardContent className="p-3">
|
||||
<pre className="text-xs bg-gray-50 p-2 rounded border border-gray-200 overflow-x-auto">
|
||||
{`POST ${apiUrl}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer ${apiKey}
|
||||
|
||||
{
|
||||
"name": "张三",
|
||||
"phone": "13800138000",
|
||||
"source": "官网",
|
||||
"remark": "有意向",
|
||||
"tags": ["高意向", "新客户"]
|
||||
}`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 创建API密钥对话框 */}
|
||||
<Dialog open={showNewApiKeyDialog} onOpenChange={setShowNewApiKeyDialog}>
|
||||
{/* 接口文档对话框 */}
|
||||
<Dialog open={showDocDialog} onOpenChange={setShowDocDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新API密钥</DialogTitle>
|
||||
<DialogDescription>创建一个新的API密钥用于访问{channelName}获客接口</DialogDescription>
|
||||
<DialogTitle>接口文档</DialogTitle>
|
||||
<DialogDescription>查看详细的接口文档与集成指南</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-name">API密钥名称</Label>
|
||||
<Input
|
||||
id="api-key-name"
|
||||
placeholder="例如:获客系统集成"
|
||||
value={newApiKeyName}
|
||||
onChange={(e) => setNewApiKeyName(e.target.value)}
|
||||
/>
|
||||
<h3 className="font-medium">接口说明</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
本接口用于向{channelName}获客计划添加新的客户信息。通过HTTP POST请求发送客户数据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">请求格式</h3>
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded border overflow-x-auto">
|
||||
POST {apiUrl}
|
||||
<br />
|
||||
Content-Type: application/json
|
||||
<br />
|
||||
Authorization: Bearer {apiKey}
|
||||
<br />
|
||||
<br />
|
||||
{`{
|
||||
"name": "客户姓名",
|
||||
"phone": "13800138000",
|
||||
"source": "广告投放",
|
||||
"remark": "有意向购买",
|
||||
"tags": ["高意向", "新客户"]
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">响应格式</h3>
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded border overflow-x-auto">
|
||||
{`{
|
||||
"success": true,
|
||||
"message": "客户添加成功",
|
||||
"data": {
|
||||
"id": "cust_123456",
|
||||
"name": "客户姓名",
|
||||
"phone": "13800138000",
|
||||
"created_at": "2024-03-21T09:15:22Z"
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewApiKeyDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreateApiKey}>创建</Button>
|
||||
<Button onClick={() => setShowDocDialog(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 创建Webhook对话框 */}
|
||||
<Dialog open={showNewWebhookDialog} onOpenChange={setShowNewWebhookDialog}>
|
||||
{/* 快速测试对话框 */}
|
||||
<Dialog open={showTestDialog} onOpenChange={setShowTestDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新Webhook</DialogTitle>
|
||||
<DialogDescription>创建一个新的Webhook用于接收{channelName}获客事件通知</DialogDescription>
|
||||
<DialogTitle>快速测试</DialogTitle>
|
||||
<DialogDescription>使用以下URL快速测试接口是否正常工作</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-name">Webhook名称</Label>
|
||||
<Input
|
||||
id="webhook-name"
|
||||
placeholder="例如:CRM系统集成"
|
||||
value={newWebhookData.name}
|
||||
onChange={(e) => setNewWebhookData({ ...newWebhookData, name: e.target.value })}
|
||||
/>
|
||||
<h3 className="font-medium">测试URL</h3>
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded border overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{testUrl}
|
||||
</pre>
|
||||
<p className="text-xs text-gray-500 mt-2">将上面的URL复制到浏览器中打开,测试接口是否返回成功响应。</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-url">Webhook URL</Label>
|
||||
<Input
|
||||
id="webhook-url"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={newWebhookData.url}
|
||||
onChange={(e) => setNewWebhookData({ ...newWebhookData, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>事件类型</Label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{["customer.created", "customer.updated", "tag.added", "tag.removed"].map((event) => (
|
||||
<div key={event} className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={newWebhookData.events.includes(event)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setNewWebhookData({
|
||||
...newWebhookData,
|
||||
events: [...newWebhookData.events, event],
|
||||
})
|
||||
} else {
|
||||
setNewWebhookData({
|
||||
...newWebhookData,
|
||||
events: newWebhookData.events.filter((e) => e !== event),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{event}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(testUrl)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "测试URL已复制到剪贴板",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
复制测试链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewWebhookDialog(false)}>
|
||||
取消
|
||||
<Button variant="outline" onClick={() => setShowTestDialog(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={handleCreateWebhook}>创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { DeviceSelector } from "@/app/components/common/DeviceSelector"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ChevronLeft, Filter, Search, RefreshCw } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { Device } from "@/types/device"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
|
||||
export default function ScenarioDevicesPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
const devicesPerPage = 10
|
||||
const maxDevices = 5
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
@@ -29,96 +17,29 @@ export default function ScenarioDevicesPage({ params }: { params: { channel: str
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
haibao: "海报",
|
||||
phone: "电话",
|
||||
order: "订单",
|
||||
weixinqun: "微信群",
|
||||
gongzhonghao: "公众号",
|
||||
payment: "付款码",
|
||||
api: "API",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟API调用
|
||||
const fetchDevices = async () => {
|
||||
const mockDevices = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `device-${i + 1}`,
|
||||
imei: `sd${123123 + i}`,
|
||||
name: `设备 ${i + 1}`,
|
||||
remark: `${channelName}获客设备 ${i + 1}`,
|
||||
status: Math.random() > 0.2 ? "online" : "offline",
|
||||
battery: Math.floor(Math.random() * 100),
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
friendCount: Math.floor(Math.random() * 1000),
|
||||
todayAdded: Math.floor(Math.random() * 50),
|
||||
messageCount: Math.floor(Math.random() * 200),
|
||||
lastActive: new Date(Date.now() - Math.random() * 86400000).toLocaleString(),
|
||||
addFriendStatus: Math.random() > 0.2 ? "normal" : "abnormal",
|
||||
}))
|
||||
setDevices(mockDevices)
|
||||
}
|
||||
|
||||
fetchDevices()
|
||||
}, [channelName])
|
||||
|
||||
const handleRefresh = () => {
|
||||
toast({
|
||||
title: "刷新成功",
|
||||
description: "设备列表已更新",
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDevices.length === devices.length || selectedDevices.length === maxDevices) {
|
||||
setSelectedDevices([])
|
||||
} else {
|
||||
const newSelection = devices.slice(0, maxDevices).map((d) => d.id)
|
||||
setSelectedDevices(newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
} else {
|
||||
if (selectedDevices.length >= maxDevices) {
|
||||
toast({
|
||||
title: "选择超出限制",
|
||||
description: `最多可选择${maxDevices}个设备`,
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
setSelectedDevices([...selectedDevices, deviceId])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用来保存选中的设备
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "已更新计划设备",
|
||||
})
|
||||
router.back()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "更新设备失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
console.error("保存失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesSearch =
|
||||
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.wechatId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesStatus = statusFilter === "all" || device.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
@@ -127,129 +48,20 @@ export default function ScenarioDevicesPage({ params }: { params: { channel: str
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}设备</h1>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}设备选择</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
<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="搜索设备IMEI/备注"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selectedDevices.length}/{maxDevices} 个设备
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedDevices.length > 0 &&
|
||||
(selectedDevices.length === devices.length || selectedDevices.length === maxDevices)
|
||||
}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm">全选</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paginatedDevices.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">暂无设备</div>
|
||||
) : (
|
||||
paginatedDevices.map((device) => (
|
||||
<Card
|
||||
key={device.id}
|
||||
className={`p-3 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selectedDevices.includes(device.id) ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
onClick={() => handleDeviceSelect(device.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
className="mt-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeviceSelect(device.id)
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium truncate">{device.name}</div>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
|
||||
<div className="text-sm text-gray-500">微信号: {device.wechatId}</div>
|
||||
<div className="flex items-center justify-between mt-1 text-sm">
|
||||
<span className="text-gray-500">好友数: {device.friendCount}</span>
|
||||
<span className="text-gray-500">今日新增: +{device.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredDevices.length > devicesPerPage && (
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {currentPage} / {Math.ceil(filteredDevices.length / devicesPerPage)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
|
||||
}
|
||||
disabled={currentPage === Math.ceil(filteredDevices.length / devicesPerPage)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="p-4">
|
||||
<DeviceSelector
|
||||
title={`${channelName}设备选择`}
|
||||
selectedDevices={selectedDevices}
|
||||
onDevicesChange={setSelectedDevices}
|
||||
multiple={true}
|
||||
maxSelection={5}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white p-4 border-t flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
@@ -263,4 +75,3 @@ export default function ScenarioDevicesPage({ params }: { params: { channel: str
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -238,4 +238,3 @@ export default function EditAcquisitionPlan({ params }: { params: { channel: str
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { ChevronLeft, Copy, Link, HelpCircle } from "lucide-react"
|
||||
import { use, useState } from "react"
|
||||
import { Copy, Link, HelpCircle, Shield, ChevronLeft, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
@@ -26,6 +26,7 @@ const getChannelName = (channel: string) => {
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
haibao: "海报",
|
||||
poster: "海报",
|
||||
phone: "电话",
|
||||
gongzhonghao: "公众号",
|
||||
weixinqun: "微信群",
|
||||
@@ -35,7 +36,6 @@ const getChannelName = (channel: string) => {
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
// 恢复Task接口定义
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
@@ -51,21 +51,6 @@ interface Task {
|
||||
trend: { date: string; customers: number }[]
|
||||
}
|
||||
|
||||
interface PlanItem {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
createTime: number;
|
||||
createTimeFormat: string;
|
||||
deviceCount: number;
|
||||
customerCount: number;
|
||||
addedCount: number;
|
||||
passRate: number;
|
||||
lastExecutionTime: string;
|
||||
nextExecutionTime: string;
|
||||
}
|
||||
|
||||
interface DeviceStats {
|
||||
active: number
|
||||
}
|
||||
@@ -88,50 +73,23 @@ function ApiDocumentationTooltip() {
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChannelPage({ params }: { params: { channel: string } }) {
|
||||
// export default function ChannelPage({ params }: { params: { channel: string } }) {
|
||||
export default function ChannelPage({ params }: { params: Promise<{ channel: string }> }) {
|
||||
const router = useRouter()
|
||||
const channel = params.channel
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
// 从URL query参数获取场景ID
|
||||
const [sceneId, setSceneId] = useState<number | null>(null);
|
||||
// 使用ref追踪sceneId值,避免重复请求
|
||||
const sceneIdRef = useRef<number | null>(null);
|
||||
// 追踪组件是否已挂载
|
||||
const isMounted = useRef(true);
|
||||
|
||||
// 组件卸载时更新挂载状态
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取URL中的查询参数
|
||||
useEffect(() => {
|
||||
// 组件未挂载,不执行操作
|
||||
if (!isMounted.current) return;
|
||||
|
||||
// 从URL获取id参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const idParam = urlParams.get('id');
|
||||
|
||||
if (idParam && !isNaN(Number(idParam))) {
|
||||
setSceneId(Number(idParam));
|
||||
sceneIdRef.current = Number(idParam);
|
||||
} else {
|
||||
// 如果没有传递有效的ID,使用函数获取默认ID
|
||||
const defaultId = getSceneIdFromChannel(channel);
|
||||
setSceneId(defaultId);
|
||||
sceneIdRef.current = defaultId;
|
||||
}
|
||||
}, [channel]);
|
||||
// const unwrappedParams = use(params)
|
||||
const resolvedParams = use(params)
|
||||
// const channel = params.channel
|
||||
// const channelName = getChannelName(params.channel)
|
||||
// const channel = unwrappedParams.channel
|
||||
// const channelName = getChannelName(unwrappedParams.channel)
|
||||
const channel = resolvedParams.channel
|
||||
const channelName = getChannelName(resolvedParams.channel)
|
||||
|
||||
const initialTasks = [
|
||||
const initialTasks: Task[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: `${channelName}直播获客计划`,
|
||||
status: "running" as const,
|
||||
status: "running",
|
||||
stats: {
|
||||
devices: 5,
|
||||
acquired: 31,
|
||||
@@ -148,7 +106,7 @@ export default function ChannelPage({ params }: { params: { channel: string } })
|
||||
{
|
||||
id: "2",
|
||||
name: `${channelName}评论区获客计划`,
|
||||
status: "paused" as const,
|
||||
status: "paused",
|
||||
stats: {
|
||||
devices: 3,
|
||||
acquired: 15,
|
||||
@@ -162,11 +120,9 @@ export default function ChannelPage({ params }: { params: { channel: string } })
|
||||
customers: Math.floor(Math.random() * 20) + 20,
|
||||
})),
|
||||
},
|
||||
] as Task[];
|
||||
]
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tasks, setTasks] = useState<Task[]>(initialTasks)
|
||||
|
||||
const [deviceStats, setDeviceStats] = useState<DeviceStats>({
|
||||
active: 5,
|
||||
@@ -248,146 +204,54 @@ export default function ChannelPage({ params }: { params: { channel: string } })
|
||||
})
|
||||
}
|
||||
|
||||
// 修改API数据处理部分
|
||||
useEffect(() => {
|
||||
// 组件未挂载,不执行操作
|
||||
if (!isMounted.current) return;
|
||||
|
||||
// 防止重复请求:如果sceneId没有变化且已经加载过数据,则不重新请求
|
||||
if (sceneId === sceneIdRef.current && tasks.length > 0 && !loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPlanList = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 如果sceneId还未确定,则等待
|
||||
if (sceneId === null) return;
|
||||
|
||||
// 调用API获取计划列表
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/plan/list?id=${sceneId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code === 1 && data.data && data.data.list) {
|
||||
// 将API返回的数据转换为前端展示格式
|
||||
const transformedTasks = data.data.list.map((item: PlanItem) => {
|
||||
// 确保返回的对象完全符合Task类型
|
||||
const status: "running" | "paused" | "completed" =
|
||||
item.status === 1 ? "running" : "paused";
|
||||
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
status: status,
|
||||
stats: {
|
||||
devices: item.deviceCount,
|
||||
acquired: item.customerCount,
|
||||
added: item.addedCount,
|
||||
},
|
||||
lastUpdated: item.createTimeFormat,
|
||||
executionTime: item.lastExecutionTime || "--",
|
||||
nextExecutionTime: item.nextExecutionTime || "--",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2月${String(i + 1)}日`,
|
||||
customers: Math.floor(Math.random() * 20) + 10, // 模拟数据
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// 使用类型断言解决类型冲突
|
||||
setTasks(transformedTasks as Task[]);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(data.msg || "获取计划列表失败");
|
||||
// 如果API返回错误,使用初始数据
|
||||
setTasks(initialTasks);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("获取计划列表失败:", err);
|
||||
setError("网络错误,无法获取计划列表");
|
||||
// 出错时使用初始数据
|
||||
setTasks(initialTasks);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlanList();
|
||||
}, [sceneId]); // 只依赖sceneId变化触发请求
|
||||
|
||||
// 辅助函数:根据渠道获取场景ID
|
||||
const getSceneIdFromChannel = (channel: string): number => {
|
||||
const channelMap: Record<string, number> = {
|
||||
'douyin': 1,
|
||||
'xiaohongshu': 2,
|
||||
'weixinqun': 3,
|
||||
'gongzhonghao': 4,
|
||||
'kuaishou': 5,
|
||||
'weibo': 6,
|
||||
'haibao': 7,
|
||||
'phone': 8,
|
||||
'api': 9
|
||||
};
|
||||
return channelMap[channel] || 6;
|
||||
};
|
||||
const handleCreateNewPlan = () => {
|
||||
// router.push(`/plans/new?type=${channel}`)
|
||||
router.push(`/scenarios/new?type=${channel}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/scenarios")} className="h-8 w-8">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}获客</h1>
|
||||
</div>
|
||||
<Button onClick={handleCreateNewPlan} size="sm" className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建{channelName}计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
{loading ? (
|
||||
// 添加加载状态
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<div className="text-gray-500">加载计划中...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
// 添加错误提示
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<div className="text-red-500 mb-4">{error}</div>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : tasks.length > 0 ? (
|
||||
tasks.map((task) => (
|
||||
<div key={task.id} className="mb-6">
|
||||
<ScenarioAcquisitionCard
|
||||
task={task}
|
||||
channel={channel}
|
||||
onEdit={() => handleEditPlan(task.id)}
|
||||
onCopy={handleCopyPlan}
|
||||
onDelete={handleDeletePlan}
|
||||
onStatusChange={handleStatusChange}
|
||||
onOpenSettings={handleOpenApiSettings}
|
||||
/>
|
||||
<div className="p-4 md:p-6 lg:p-8 max-w-7xl mx-auto">
|
||||
<div className="space-y-4">
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task) => (
|
||||
<div key={task.id}>
|
||||
<ScenarioAcquisitionCard
|
||||
task={task}
|
||||
channel={channel}
|
||||
onEdit={() => handleEditPlan(task.id)}
|
||||
onCopy={handleCopyPlan}
|
||||
onDelete={handleDeletePlan}
|
||||
onStatusChange={handleStatusChange}
|
||||
onOpenSettings={handleOpenApiSettings}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow-sm md:col-span-2 lg:col-span-3">
|
||||
<div className="text-gray-400 mb-4">暂无获客计划</div>
|
||||
<Button onClick={handleCreateNewPlan} className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建{channelName}计划
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<div className="text-gray-400 mb-4">暂无获客计划</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* API接口设置对话框 */}
|
||||
<Dialog open={showApiDialog} onOpenChange={setShowApiDialog}>
|
||||
@@ -400,81 +264,163 @@ export default function ChannelPage({ params }: { params: { channel: string } })
|
||||
<DialogDescription>使用此接口直接导入客资到该获客计划,支持多种编程语言。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-5 py-4">
|
||||
{/* API密钥部分 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">API密钥</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="api-key" className="text-sm font-medium flex items-center gap-1">
|
||||
API密钥
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-xs">API密钥用于身份验证,请妥善保管,不要在客户端代码中暴露它</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<span className="text-xs text-gray-500">用于接口身份验证</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input id="api-key" value={currentApiSettings.apiKey} readOnly className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(currentApiSettings.apiKey)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "API密钥已复制到剪贴板",
|
||||
variant: "default",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="api-key"
|
||||
value={currentApiSettings.apiKey}
|
||||
readOnly
|
||||
className="pr-10 font-mono text-sm bg-gray-50"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(currentApiSettings.apiKey)
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "API密钥已复制到剪贴板",
|
||||
variant: "default",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址部分 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="webhook-url">接口地址</Label>
|
||||
<Label htmlFor="webhook-url" className="text-sm font-medium">
|
||||
接口地址
|
||||
</Label>
|
||||
<button
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
className="text-xs text-blue-600 hover:underline flex items-center gap-1"
|
||||
onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl, true)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制(含示例参数)
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input id="webhook-url" value={currentApiSettings.webhookUrl} readOnly className="flex-1" />
|
||||
<Button variant="outline" size="icon" onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="webhook-url"
|
||||
value={currentApiSettings.webhookUrl}
|
||||
readOnly
|
||||
className="pr-10 font-mono text-sm bg-gray-50 text-gray-700"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full"
|
||||
onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-2 rounded-md">
|
||||
<p className="text-xs text-blue-700">
|
||||
<span className="font-medium">必要参数:</span>name(姓名)、phone(电话)
|
||||
<br />
|
||||
<span className="font-medium">可选参数:</span>source(来源)、remark(备注)、tags(标签)
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">支持GET/POST请求,必要参数:name(姓名)、phone(电话)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>接口文档</Label>
|
||||
{/* 接口文档部分 */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={() => {
|
||||
window.open(`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}`, "_blank")
|
||||
}}
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
查看详细接口文档与集成指南
|
||||
</Button>
|
||||
<Label className="text-sm font-medium">接口文档</Label>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg border border-gray-100">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center justify-center gap-2 bg-white"
|
||||
onClick={() => {
|
||||
window.open(`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}`, "_blank")
|
||||
}}
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
查看详细接口文档与集成指南
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
window.open(`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}#examples`, "_blank")
|
||||
}}
|
||||
>
|
||||
<span className="text-blue-600">查看代码示例</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
window.open(`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}#integration`, "_blank")
|
||||
}}
|
||||
>
|
||||
<span className="text-blue-600">查看集成指南</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快速测试部分 */}
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">快速测试</Label>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-md border border-gray-100">
|
||||
<p className="text-xs text-gray-600 mb-2">使用以下URL可以快速测试接口是否正常工作:</p>
|
||||
<div className="text-xs font-mono bg-white p-2 rounded border border-gray-200 overflow-x-auto">
|
||||
{`${currentApiSettings.webhookUrl}?name=测试客户&phone=13800138000`}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
<a
|
||||
href={`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}#examples`}
|
||||
target="_blank"
|
||||
className="text-blue-600 hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看Python、Java等多语言示例代码
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowApiDialog(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<DialogFooter className="flex justify-between items-center">
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="inline-flex items-center">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
接口安全加密传输
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => setShowApiDialog(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -233,4 +233,3 @@ export default function ChannelTrafficPage({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus, Filter, Search, RefreshCw, MoreVertical, Clock, Copy, Code } from "lucide-react"
|
||||
import { Plus, Filter, Search, RefreshCw, MoreVertical, Clock, Copy, Code } from 'lucide-react'
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -130,7 +130,7 @@ export default function ApiPage() {
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h1 className="text-xl font-semibold text-violet-600">API获客</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="/scenarios/api/new">
|
||||
<Link href="/scenarios/new">
|
||||
<Button className="bg-violet-600 hover:bg-violet-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建计划
|
||||
@@ -371,4 +371,3 @@ export default function ApiPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
5
Cunkebao/app/scenarios/api/route.ts
Normal file
5
Cunkebao/app/scenarios/api/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return NextResponse.json({ message: "场景获客API" })
|
||||
}
|
||||
@@ -120,4 +120,3 @@ export default function DouyinAcquisitionPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
129
Cunkebao/app/scenarios/haibao/edit/[id]/page.tsx
Normal file
129
Cunkebao/app/scenarios/haibao/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import BasicSettings from "@/app/scenarios/new/steps/BasicSettings"
|
||||
import DeviceSelection from "@/app/scenarios/new/steps/DeviceSelection"
|
||||
import MessageSettings from "@/app/scenarios/new/steps/MessageSettings"
|
||||
import BottomNav from "@/app/components/BottomNav"
|
||||
|
||||
export default function EditScenarioPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState("basic")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
type: "haibao",
|
||||
status: "active",
|
||||
enableMessage: true,
|
||||
message: "",
|
||||
delayTime: 0,
|
||||
selectedDevices: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟从API加载数据
|
||||
setTimeout(() => {
|
||||
setFormData({
|
||||
name: "海报获客方案",
|
||||
description: "通过海报二维码引流获客",
|
||||
type: "haibao",
|
||||
status: "active",
|
||||
enableMessage: true,
|
||||
message: "您好,感谢关注我们的产品!",
|
||||
delayTime: 5,
|
||||
selectedDevices: ["dev1", "dev2"],
|
||||
})
|
||||
setIsLoading(false)
|
||||
}, 500)
|
||||
}, [params.id])
|
||||
|
||||
const updateFormData = (field: string, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "场景配置已更新",
|
||||
})
|
||||
router.push("/scenarios/haibao")
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white min-h-screen pb-16">
|
||||
<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>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-3xl mx-auto">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid grid-cols-3 mb-6">
|
||||
<TabsTrigger value="basic">基础设置</TabsTrigger>
|
||||
<TabsTrigger value="device">设备选择</TabsTrigger>
|
||||
<TabsTrigger value="message">消息设置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Card className="p-4">
|
||||
<TabsContent value="basic">
|
||||
<BasicSettings
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onNext={() => setActiveTab("device")}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="device">
|
||||
<DeviceSelection
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onNext={() => setActiveTab("message")}
|
||||
onBack={() => setActiveTab("basic")}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="message">
|
||||
<MessageSettings
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onNext={handleSave}
|
||||
onBack={() => setActiveTab("device")}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
Cunkebao/app/scenarios/loading.tsx
Normal file
3
Cunkebao/app/scenarios/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
3
Cunkebao/app/scenarios/new/loading.tsx
Normal file
3
Cunkebao/app/scenarios/new/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
118
Cunkebao/app/scenarios/new/page.tsx
Normal file
118
Cunkebao/app/scenarios/new/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Settings } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { StepIndicator } from "@/app/components/ui-templates/step-indicator"
|
||||
import { BasicSettings } from "./steps/BasicSettings"
|
||||
import { FriendRequestSettings } from "./steps/FriendRequestSettings"
|
||||
import { MessageSettings } from "./steps/MessageSettings"
|
||||
|
||||
// 步骤定义 - 只保留三个步骤
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
]
|
||||
|
||||
export default function NewPlan() {
|
||||
const router = useRouter()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [formData, setFormData] = useState({
|
||||
planName: "",
|
||||
scenario: "haibao",
|
||||
posters: [],
|
||||
device: "",
|
||||
remarkType: "phone",
|
||||
greeting: "你好,请通过",
|
||||
addInterval: 1,
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
enabled: true,
|
||||
// 移除tags字段
|
||||
})
|
||||
|
||||
// 更新表单数据
|
||||
const onChange = (data: any) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }))
|
||||
}
|
||||
|
||||
// 处理保存
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "获客计划已创建",
|
||||
})
|
||||
// router.push("/plans")
|
||||
router.push("/scenarios")
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "创建失败",
|
||||
description: "创建计划失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave()
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 上一步
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1))
|
||||
}
|
||||
|
||||
// 渲染当前步骤内容
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <BasicSettings formData={formData} onChange={onChange} onNext={handleNext} />
|
||||
case 2:
|
||||
return <FriendRequestSettings formData={formData} onChange={onChange} onNext={handleNext} onPrev={handlePrev} />
|
||||
case 3:
|
||||
return <MessageSettings formData={formData} onChange={onChange} onNext={handleSave} onPrev={handlePrev} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||
<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")}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">新建获客计划</h1>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="px-4 py-6">
|
||||
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1081
Cunkebao/app/scenarios/new/steps/BasicSettings.tsx
Normal file
1081
Cunkebao/app/scenarios/new/steps/BasicSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
277
Cunkebao/app/scenarios/new/steps/FriendRequestSettings.tsx
Normal file
277
Cunkebao/app/scenarios/new/steps/FriendRequestSettings.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } 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 { HelpCircle, MessageSquare, AlertCircle } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { ChevronsUpDown } from "lucide-react"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
|
||||
interface FriendRequestSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
}
|
||||
|
||||
// 招呼语模板
|
||||
const greetingTemplates = [
|
||||
"你好,请通过",
|
||||
"你好,了解XX,请通过",
|
||||
"你好,我是XX产品的客服请通过",
|
||||
"你好,感谢关注我们的产品",
|
||||
"你好,很高兴为您服务",
|
||||
]
|
||||
|
||||
// 备注类型选项
|
||||
const remarkTypes = [
|
||||
{ value: "phone", label: "手机号" },
|
||||
{ value: "nickname", label: "昵称" },
|
||||
{ value: "source", label: "来源" },
|
||||
]
|
||||
|
||||
// 模拟设备数据
|
||||
const mockDevices = [
|
||||
{ id: "1", name: "iPhone 13 Pro", status: "online" },
|
||||
{ id: "2", name: "Xiaomi 12", status: "online" },
|
||||
{ id: "3", name: "Huawei P40", status: "offline" },
|
||||
{ id: "4", name: "OPPO Find X3", status: "online" },
|
||||
{ id: "5", name: "Samsung S21", status: "online" },
|
||||
]
|
||||
|
||||
export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: FriendRequestSettingsProps) {
|
||||
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
|
||||
const [hasWarnings, setHasWarnings] = useState(false)
|
||||
const [isDeviceSelectorOpen, setIsDeviceSelectorOpen] = useState(false)
|
||||
const [selectedDevices, setSelectedDevices] = useState<any[]>(formData.selectedDevices || [])
|
||||
|
||||
// 获取场景标题
|
||||
const getScenarioTitle = () => {
|
||||
switch (formData.scenario) {
|
||||
case "douyin":
|
||||
return "抖音直播"
|
||||
case "xiaohongshu":
|
||||
return "小红书"
|
||||
case "weixinqun":
|
||||
return "微信群"
|
||||
case "gongzhonghao":
|
||||
return "公众号"
|
||||
default:
|
||||
return formData.planName || "获客计划"
|
||||
}
|
||||
}
|
||||
|
||||
// 使用useEffect设置默认值
|
||||
useEffect(() => {
|
||||
if (!formData.greeting) {
|
||||
onChange({
|
||||
...formData,
|
||||
greeting: "你好,请通过",
|
||||
remarkType: "phone", // 默认选择手机号
|
||||
remarkFormat: `手机号+${getScenarioTitle()}`, // 默认备注格式
|
||||
addFriendInterval: 1,
|
||||
})
|
||||
}
|
||||
}, [formData, formData.greeting, onChange])
|
||||
|
||||
// 检查是否有未完成的必填项
|
||||
useEffect(() => {
|
||||
const hasIncompleteFields = !formData.greeting?.trim()
|
||||
setHasWarnings(hasIncompleteFields)
|
||||
}, [formData])
|
||||
|
||||
const handleTemplateSelect = (template: string) => {
|
||||
onChange({ ...formData, greeting: template })
|
||||
setIsTemplateDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
// 即使有警告也允许进入下一步,但会显示提示
|
||||
onNext()
|
||||
}
|
||||
|
||||
const toggleDeviceSelection = (device: any) => {
|
||||
const isSelected = selectedDevices.some((d) => d.id === device.id)
|
||||
let newSelectedDevices
|
||||
|
||||
if (isSelected) {
|
||||
newSelectedDevices = selectedDevices.filter((d) => d.id !== device.id)
|
||||
} else {
|
||||
newSelectedDevices = [...selectedDevices, device]
|
||||
}
|
||||
|
||||
setSelectedDevices(newSelectedDevices)
|
||||
onChange({ ...formData, selectedDevices: newSelectedDevices })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-base">选择设备</Label>
|
||||
<div className="relative mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setIsDeviceSelectorOpen(!isDeviceSelectorOpen)}
|
||||
>
|
||||
{selectedDevices.length ? `已选择 ${selectedDevices.length} 个设备` : "选择设备"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
{isDeviceSelectorOpen && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg">
|
||||
<div className="p-2">
|
||||
<Input placeholder="搜索设备..." className="mb-2" />
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{mockDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center justify-between p-2 hover:bg-gray-100 cursor-pointer"
|
||||
onClick={() => toggleDeviceSelection(device)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedDevices.some((d) => d.id === device.id)}
|
||||
onCheckedChange={() => toggleDeviceSelection(device)}
|
||||
/>
|
||||
<span>{device.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs ${device.status === "online" ? "text-green-500" : "text-gray-400"}`}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="text-base">好友备注</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>设置添加好友时的备注格式</p>
|
||||
<p className="mt-1">备注格式预览:</p>
|
||||
<p>{formData.remarkType === "phone" && `138****1234+${getScenarioTitle()}`}</p>
|
||||
<p>{formData.remarkType === "nickname" && `小红书用户2851+${getScenarioTitle()}`}</p>
|
||||
<p>{formData.remarkType === "source" && `抖音直播+${getScenarioTitle()}`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select
|
||||
value={formData.remarkType || "phone"}
|
||||
onValueChange={(value) => onChange({ ...formData, remarkType: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full mt-2">
|
||||
<SelectValue placeholder="选择备注类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remarkTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base">招呼语</Label>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsTemplateDialogOpen(true)} className="text-blue-500">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
参考模板
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.greeting}
|
||||
onChange={(e) => onChange({ ...formData, greeting: e.target.value })}
|
||||
placeholder="请输入招呼语"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">添加间隔</Label>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.addFriendInterval || 1}
|
||||
onChange={(e) => onChange({ ...formData, addFriendInterval: Number(e.target.value) })}
|
||||
className="w-32"
|
||||
/>
|
||||
<span>分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">允许加人的时间段</Label>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeStart || "09:00"}
|
||||
onChange={(e) => onChange({ ...formData, addFriendTimeStart: e.target.value })}
|
||||
className="w-32"
|
||||
/>
|
||||
<span>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeEnd || "18:00"}
|
||||
onChange={(e) => onChange({ ...formData, addFriendTimeEnd: e.target.value })}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasWarnings && (
|
||||
<Alert variant="warning" className="bg-amber-50 border-amber-200">
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
<AlertDescription>您有未完成的设置项,建议完善后再进入下一步。</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={handleNext}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>招呼语模板</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{greetingTemplates.map((template, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-3 px-4"
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
554
Cunkebao/app/scenarios/new/steps/MessageSettings.tsx
Normal file
554
Cunkebao/app/scenarios/new/steps/MessageSettings.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
"use client"
|
||||
|
||||
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 { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
MessageSquare,
|
||||
ImageIcon,
|
||||
Video,
|
||||
FileText,
|
||||
Link2,
|
||||
Users,
|
||||
AppWindowIcon as Window,
|
||||
Plus,
|
||||
X,
|
||||
Upload,
|
||||
Clock,
|
||||
} from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface MessageContent {
|
||||
id: string
|
||||
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group"
|
||||
content: string
|
||||
sendInterval?: number
|
||||
intervalUnit?: "seconds" | "minutes"
|
||||
scheduledTime?: {
|
||||
hour: number
|
||||
minute: number
|
||||
second: number
|
||||
}
|
||||
title?: string
|
||||
description?: string
|
||||
address?: string
|
||||
coverImage?: string
|
||||
groupId?: string
|
||||
linkUrl?: string
|
||||
}
|
||||
|
||||
interface DayPlan {
|
||||
day: number
|
||||
messages: MessageContent[]
|
||||
}
|
||||
|
||||
interface MessageSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
}
|
||||
|
||||
// 消息类型配置
|
||||
const messageTypes = [
|
||||
{ id: "text", icon: MessageSquare, label: "文本" },
|
||||
{ id: "image", icon: ImageIcon, label: "图片" },
|
||||
{ id: "video", icon: Video, label: "视频" },
|
||||
{ id: "file", icon: FileText, label: "文件" },
|
||||
{ id: "miniprogram", icon: Window, label: "小程序" },
|
||||
{ id: "link", icon: Link2, label: "链接" },
|
||||
{ id: "group", icon: Users, label: "邀请入群" },
|
||||
]
|
||||
|
||||
// 模拟群组数据
|
||||
const mockGroups = [
|
||||
{ id: "1", name: "产品交流群1", memberCount: 156 },
|
||||
{ id: "2", name: "产品交流群2", memberCount: 234 },
|
||||
{ id: "3", name: "产品交流群3", memberCount: 89 },
|
||||
]
|
||||
|
||||
export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageSettingsProps) {
|
||||
const [dayPlans, setDayPlans] = useState<DayPlan[]>([
|
||||
{
|
||||
day: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "text",
|
||||
content: "",
|
||||
sendInterval: 5,
|
||||
intervalUnit: "seconds", // 默认改为秒
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false)
|
||||
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false)
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("")
|
||||
|
||||
// 添加新消息
|
||||
const handleAddMessage = (dayIndex: number, type = "text") => {
|
||||
const updatedPlans = [...dayPlans]
|
||||
const newMessage: MessageContent = {
|
||||
id: Date.now().toString(),
|
||||
type: type as MessageContent["type"],
|
||||
content: "",
|
||||
}
|
||||
|
||||
if (dayPlans[dayIndex].day === 0) {
|
||||
// 即时消息使用间隔设置
|
||||
newMessage.sendInterval = 5
|
||||
newMessage.intervalUnit = "seconds" // 默认改为秒
|
||||
} else {
|
||||
// 非即时消息使用具体时间设置
|
||||
newMessage.scheduledTime = {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}
|
||||
}
|
||||
|
||||
updatedPlans[dayIndex].messages.push(newMessage)
|
||||
setDayPlans(updatedPlans)
|
||||
onChange({ ...formData, messagePlans: updatedPlans })
|
||||
}
|
||||
|
||||
// 更新消息内容
|
||||
const handleUpdateMessage = (dayIndex: number, messageIndex: number, updates: Partial<MessageContent>) => {
|
||||
const updatedPlans = [...dayPlans]
|
||||
updatedPlans[dayIndex].messages[messageIndex] = {
|
||||
...updatedPlans[dayIndex].messages[messageIndex],
|
||||
...updates,
|
||||
}
|
||||
setDayPlans(updatedPlans)
|
||||
onChange({ ...formData, messagePlans: updatedPlans })
|
||||
}
|
||||
|
||||
// 删除消息
|
||||
const handleRemoveMessage = (dayIndex: number, messageIndex: number) => {
|
||||
const updatedPlans = [...dayPlans]
|
||||
updatedPlans[dayIndex].messages.splice(messageIndex, 1)
|
||||
setDayPlans(updatedPlans)
|
||||
onChange({ ...formData, messagePlans: updatedPlans })
|
||||
}
|
||||
|
||||
// 切换时间单位
|
||||
const toggleIntervalUnit = (dayIndex: number, messageIndex: number) => {
|
||||
const message = dayPlans[dayIndex].messages[messageIndex]
|
||||
const newUnit = message.intervalUnit === "minutes" ? "seconds" : "minutes"
|
||||
handleUpdateMessage(dayIndex, messageIndex, { intervalUnit: newUnit })
|
||||
}
|
||||
|
||||
// 添加新的天数计划
|
||||
const handleAddDayPlan = () => {
|
||||
const newDay = dayPlans.length
|
||||
setDayPlans([
|
||||
...dayPlans,
|
||||
{
|
||||
day: newDay,
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "text",
|
||||
content: "",
|
||||
scheduledTime: {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
setIsAddDayPlanOpen(false)
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `已添加第${newDay}天的消息计划`,
|
||||
})
|
||||
}
|
||||
|
||||
// 选择群组
|
||||
const handleSelectGroup = (groupId: string) => {
|
||||
setSelectedGroupId(groupId)
|
||||
setIsGroupSelectOpen(false)
|
||||
toast({
|
||||
title: "选择成功",
|
||||
description: `已选择群组:${mockGroups.find((g) => g.id === groupId)?.name}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileUpload = (dayIndex: number, messageIndex: number, type: "image" | "video" | "file") => {
|
||||
// 模拟文件上传
|
||||
toast({
|
||||
title: "上传成功",
|
||||
description: `${type === "image" ? "图片" : type === "video" ? "视频" : "文件"}上传成功`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">消息设置</h2>
|
||||
<Button variant="outline" size="icon" onClick={() => setIsAddDayPlanOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="0" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
{dayPlans.map((plan) => (
|
||||
<TabsTrigger key={plan.day} value={plan.day.toString()} className="flex-1">
|
||||
{plan.day === 0 ? "即时消息" : `第${plan.day}天`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{dayPlans.map((plan, dayIndex) => (
|
||||
<TabsContent key={plan.day} value={plan.day.toString()}>
|
||||
<div className="space-y-4">
|
||||
{plan.messages.map((message, messageIndex) => (
|
||||
<div key={message.id} className="space-y-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{plan.day === 0 ? (
|
||||
<>
|
||||
<Label>间隔</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={message.sendInterval}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, { sendInterval: Number(e.target.value) })
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleIntervalUnit(dayIndex, messageIndex)}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{message.intervalUnit === "minutes" ? "分钟" : "秒"}</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label>发送时间</Label>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={message.scheduledTime?.hour || 0}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || { hour: 0, minute: 0, second: 0 }),
|
||||
hour: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-16"
|
||||
/>
|
||||
<span>:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={message.scheduledTime?.minute || 0}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || { hour: 0, minute: 0, second: 0 }),
|
||||
minute: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-16"
|
||||
/>
|
||||
<span>:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={message.scheduledTime?.second || 0}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || { hour: 0, minute: 0, second: 0 }),
|
||||
second: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-16"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveMessage(dayIndex, messageIndex)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 bg-white p-2 rounded-lg">
|
||||
{messageTypes.map((type) => (
|
||||
<Button
|
||||
key={type.id}
|
||||
variant={message.type === type.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleUpdateMessage(dayIndex, messageIndex, { type: type.id as any })}
|
||||
className="flex flex-col items-center p-2 h-auto"
|
||||
>
|
||||
<type.icon className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message.type === "text" && (
|
||||
<Textarea
|
||||
value={message.content}
|
||||
onChange={(e) => handleUpdateMessage(dayIndex, messageIndex, { content: e.target.value })}
|
||||
placeholder="请输入消息内容"
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.type === "miniprogram" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
标题<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) => handleUpdateMessage(dayIndex, messageIndex, { title: e.target.value })}
|
||||
placeholder="请输入小程序标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, { description: e.target.value })
|
||||
}
|
||||
placeholder="请输入小程序描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
链接<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={message.address}
|
||||
onChange={(e) => handleUpdateMessage(dayIndex, messageIndex, { address: e.target.value })}
|
||||
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>
|
||||
)}
|
||||
|
||||
{message.type === "link" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
标题<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) => handleUpdateMessage(dayIndex, messageIndex, { title: e.target.value })}
|
||||
placeholder="请输入链接标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, { description: e.target.value })
|
||||
}
|
||||
placeholder="请输入链接描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
链接<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={message.linkUrl}
|
||||
onChange={(e) => handleUpdateMessage(dayIndex, messageIndex, { linkUrl: e.target.value })}
|
||||
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>
|
||||
)}
|
||||
|
||||
{message.type === "group" && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
选择群聊<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setIsGroupSelectOpen(true)}
|
||||
>
|
||||
{selectedGroupId ? mockGroups.find((g) => g.id === selectedGroupId)?.name : "选择邀请入的群"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(message.type === "image" || message.type === "video" || message.type === "file") && (
|
||||
<div className="border-2 border-dashed rounded-lg p-4 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-[120px]"
|
||||
onClick={() => handleFileUpload(dayIndex, messageIndex, message.type as any)}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上传{message.type === "image" ? "图片" : message.type === "video" ? "视频" : "文件"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button variant="outline" onClick={() => handleAddMessage(dayIndex)} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加消息
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={onNext}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加天数计划弹窗 */}
|
||||
<Dialog open={isAddDayPlanOpen} onOpenChange={setIsAddDayPlanOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加消息计划</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-gray-500 mb-4">选择要添加的消息计划类型</p>
|
||||
<Button onClick={handleAddDayPlan} className="w-full">
|
||||
添加第 {dayPlans.length} 天计划
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 选择群聊弹窗 */}
|
||||
<Dialog open={isGroupSelectOpen} onOpenChange={setIsGroupSelectOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择群聊</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
{mockGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={`p-4 rounded-lg cursor-pointer hover:bg-gray-100 ${
|
||||
selectedGroupId === group.id ? "bg-blue-50 border border-blue-200" : ""
|
||||
}`}
|
||||
onClick={() => handleSelectGroup(group.id)}
|
||||
>
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-sm text-gray-500">成员数:{group.memberCount}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsGroupSelectOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setIsGroupSelectOpen(false)}>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -207,4 +207,3 @@ export function TrafficChannelSettings({ formData, onChange, onNext, onPrev }: T
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,335 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import type React from "react"
|
||||
import { TrendingUp, Users, ChevronLeft, Bot, Sparkles, Plus, Phone } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { fetchScenes, transformSceneItem } from "@/api/scenarios"
|
||||
|
||||
interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
stats: {
|
||||
daily: number
|
||||
growth: number
|
||||
}
|
||||
link?: string
|
||||
plans?: Plan[]
|
||||
}
|
||||
|
||||
interface Plan {
|
||||
id: string
|
||||
name: string
|
||||
isNew?: boolean
|
||||
status: "active" | "paused" | "completed"
|
||||
acquisitionCount: number
|
||||
}
|
||||
|
||||
// AI场景列表(服务端暂未提供)
|
||||
const aiScenarios = [
|
||||
{
|
||||
id: "ai-friend",
|
||||
name: "AI智能加友",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-azCH8EgGfidWXOqiM2D1jLH0VFRUtW.png",
|
||||
description: "智能分析目标用户画像,自动筛选优质客户",
|
||||
stats: {
|
||||
daily: 245,
|
||||
growth: 18.5,
|
||||
},
|
||||
link: "/scenarios/ai-friend",
|
||||
plans: [
|
||||
{
|
||||
id: "ai-plan-1",
|
||||
name: "AI智能筛选计划",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 78,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ai-group",
|
||||
name: "AI群引流",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-azCH8EgGfidWXOqiM2D1jLH0VFRUtW.png",
|
||||
description: "智能群聊互动,提高群活跃度和转化率",
|
||||
stats: {
|
||||
daily: 178,
|
||||
growth: 15.2,
|
||||
},
|
||||
link: "/scenarios/ai-group",
|
||||
plans: [
|
||||
{
|
||||
id: "ai-plan-2",
|
||||
name: "AI群聊互动计划",
|
||||
status: "active",
|
||||
acquisitionCount: 56,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ai-conversion",
|
||||
name: "AI场景转化",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-m4ENUaZon82EPFHod2dP1dajlrRdVG.png",
|
||||
description: "多场景智能营销,提升获客转化效果",
|
||||
stats: {
|
||||
daily: 134,
|
||||
growth: 12.8,
|
||||
},
|
||||
link: "/scenarios/ai-conversion",
|
||||
plans: [
|
||||
{
|
||||
id: "ai-plan-3",
|
||||
name: "AI多场景营销",
|
||||
isNew: true,
|
||||
status: "active",
|
||||
acquisitionCount: 43,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
import { Plus, TrendingUp } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
export default function ScenariosPage() {
|
||||
const router = useRouter()
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// 使用ref跟踪组件挂载状态
|
||||
const isMounted = useRef(true);
|
||||
// 使用ref跟踪是否已经加载过数据
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
// 组件卸载时更新挂载状态
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
// 场景数据
|
||||
const scenarios = [
|
||||
{
|
||||
id: "poster",
|
||||
name: "海报获客",
|
||||
icon: "🖼️",
|
||||
count: 167,
|
||||
growth: "+10.2%",
|
||||
path: "/scenarios/poster",
|
||||
},
|
||||
{
|
||||
id: "order",
|
||||
name: "订单获客",
|
||||
icon: "📋",
|
||||
count: 112,
|
||||
growth: "+7.8%",
|
||||
path: "/scenarios/order",
|
||||
},
|
||||
{
|
||||
id: "douyin",
|
||||
name: "抖音获客",
|
||||
icon: "📱",
|
||||
count: 156,
|
||||
growth: "+12.5%",
|
||||
path: "/scenarios/douyin",
|
||||
},
|
||||
{
|
||||
id: "xiaohongshu",
|
||||
name: "小红书获客",
|
||||
icon: "📕",
|
||||
count: 89,
|
||||
growth: "+8.3%",
|
||||
path: "/scenarios/xiaohongshu",
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
name: "电话获客",
|
||||
icon: "📞",
|
||||
count: 42,
|
||||
growth: "+15.8%",
|
||||
path: "/scenarios/phone",
|
||||
},
|
||||
{
|
||||
id: "gongzhonghao",
|
||||
name: "公众号获客",
|
||||
icon: "📢",
|
||||
count: 234,
|
||||
growth: "+15.7%",
|
||||
path: "/scenarios/gongzhonghao",
|
||||
},
|
||||
{
|
||||
id: "weixinqun",
|
||||
name: "微信群获客",
|
||||
icon: "👥",
|
||||
count: 145,
|
||||
growth: "+11.2%",
|
||||
path: "/scenarios/weixinqun",
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
name: "付款码获客",
|
||||
icon: "💳",
|
||||
count: 78,
|
||||
growth: "+9.5%",
|
||||
path: "/scenarios/payment",
|
||||
},
|
||||
{
|
||||
id: "api",
|
||||
name: "API获客",
|
||||
icon: "🔌",
|
||||
count: 198,
|
||||
growth: "+14.3%",
|
||||
path: "/scenarios/api",
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
// 组件未挂载,不执行操作
|
||||
if (!isMounted.current) return;
|
||||
|
||||
// 如果已经加载过数据,不再重复请求
|
||||
if (hasLoadedRef.current && channels.length > 0) return;
|
||||
|
||||
const loadScenes = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetchScenes({ limit: 50 })
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 转换场景数据为前端展示格式
|
||||
const transformedScenes = response.data.map((scene) => {
|
||||
const transformedScene = transformSceneItem(scene)
|
||||
|
||||
// 添加link属性(用于导航)
|
||||
return {
|
||||
...transformedScene,
|
||||
link: `/scenarios/${scene.id}`
|
||||
}
|
||||
})
|
||||
|
||||
// 只有在组件仍然挂载的情况下才更新状态
|
||||
if (isMounted.current) {
|
||||
setChannels(transformedScenes)
|
||||
// 标记已加载过数据
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
} else {
|
||||
if (isMounted.current) {
|
||||
setError(response.msg || "获取场景列表失败")
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch scenes:", err)
|
||||
if (isMounted.current) {
|
||||
setError("获取场景列表失败")
|
||||
}
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadScenes()
|
||||
}, [])
|
||||
|
||||
const handleChannelClick = (channelId: string, event: React.MouseEvent) => {
|
||||
router.push(`/scenarios/${channelId}`)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-100 text-green-700"
|
||||
case "paused":
|
||||
return "bg-amber-100 text-amber-700"
|
||||
case "completed":
|
||||
return "bg-blue-100 text-blue-700"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700"
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "执行中"
|
||||
case "paused":
|
||||
return "已暂停"
|
||||
case "completed":
|
||||
return "已完成"
|
||||
default:
|
||||
return "未知状态"
|
||||
}
|
||||
}
|
||||
|
||||
// 展示场景骨架屏
|
||||
const renderSkeletons = () => {
|
||||
return Array(8)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className="flex flex-col">
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<Skeleton className="w-12 h-12 rounded-xl" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<div className="flex items-center space-x-1">
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
// AI智能获客
|
||||
const aiScenarios = [
|
||||
{
|
||||
id: "ai-friend",
|
||||
name: "AI智能加友",
|
||||
icon: "🤖",
|
||||
count: 245,
|
||||
growth: "+18.5%",
|
||||
description: "智能分析目标用户画像,自动筛选优质客户",
|
||||
path: "/scenarios/ai-friend",
|
||||
},
|
||||
{
|
||||
id: "ai-group",
|
||||
name: "AI群引流",
|
||||
icon: "🤖",
|
||||
count: 178,
|
||||
growth: "+15.2%",
|
||||
description: "智能群管理,提高群活跃度,增强获客效果",
|
||||
path: "/scenarios/ai-group",
|
||||
},
|
||||
{
|
||||
id: "ai-conversion",
|
||||
name: "AI场景转化",
|
||||
icon: "🤖",
|
||||
count: 134,
|
||||
growth: "+12.8%",
|
||||
description: "多场景智能营销,提升获客转化率",
|
||||
path: "/scenarios/ai-conversion",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="w-full mx-auto bg-white min-h-screen lg:max-w-7xl xl:max-w-[1200px]">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex justify-between items-center p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h1 className="text-xl font-semibold text-blue-600">场景获客</h1>
|
||||
</div>
|
||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h1 className="text-xl font-semibold">场景获客</h1>
|
||||
{/* <Button onClick={() => router.push("/plans/new")} size="sm"> */}
|
||||
<Button onClick={() => router.push("/scenarios/new")} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建计划
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => router.push("/plans/new")}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建计划
|
||||
</Button>
|
||||
<div className="flex-1 p-4 pb-20">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{scenarios.map((scenario) => (
|
||||
<Card
|
||||
key={scenario.id}
|
||||
className="overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => router.push(scenario.path)}
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col items-center">
|
||||
<div className="text-3xl mb-2">{scenario.icon}</div>
|
||||
<h3 className="text-blue-600 font-medium text-center">{scenario.name}</h3>
|
||||
<div className="flex items-center mt-2 text-gray-500 text-sm">
|
||||
<span>今日: </span>
|
||||
<span className="font-medium ml-1">{scenario.count}</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-1 text-green-500 text-xs">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
<span>{scenario.growth}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<h2 className="text-lg font-medium">AI智能获客</h2>
|
||||
<Badge variant="outline" className="ml-2 bg-blue-50 text-blue-600">
|
||||
Beta
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-md">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => window.location.reload()}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{aiScenarios.map((scenario) => (
|
||||
<Card
|
||||
key={scenario.id}
|
||||
className="overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => router.push(scenario.path)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traditional channels */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{loading ? (
|
||||
renderSkeletons()
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<div key={channel.id} className="flex flex-col">
|
||||
<Card
|
||||
className={`p-4 hover:shadow-lg transition-all cursor-pointer`}
|
||||
onClick={() => router.push(channel.link || "")}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-white flex items-center justify-center shadow-sm">
|
||||
<img
|
||||
src={channel.icon || "/placeholder.svg"}
|
||||
alt={channel.name}
|
||||
className="w-8 h-8 object-contain"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时,使用默认图标
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = "/assets/icons/poster-icon.svg"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-blue-600">{channel.name}</h3>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Users className="w-3 h-3 text-gray-400" />
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-xs text-gray-500">今日:</span>
|
||||
<span className="text-base font-medium ml-1">{channel.stats.daily}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-green-500 text-xs">
|
||||
<TrendingUp className="w-3 h-3 mr-1" />
|
||||
<span>{channel.stats.growth > 0 ? "+" : ""}{channel.stats.growth}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI scenarios */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Bot className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-lg font-medium">AI智能获客</h2>
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs rounded-full">Beta</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{aiScenarios.map((scenario) => (
|
||||
<div key={scenario.id} className="flex flex-col">
|
||||
<Card
|
||||
className={`p-4 hover:shadow-lg transition-all bg-gradient-to-br from-blue-50/50 to-white border-2 border-blue-100`}
|
||||
onClick={() => router.push(scenario.link || "")}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center shadow-sm">
|
||||
<Sparkles className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-blue-600">{scenario.name}</h3>
|
||||
<p className="text-xs text-gray-500 text-center line-clamp-2">{scenario.description}</p>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Users className="w-3 h-3 text-gray-400" />
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-xs text-gray-500">今日:</span>
|
||||
<span className="text-base font-medium ml-1">{scenario.stats.daily}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-green-500 text-xs">
|
||||
<TrendingUp className="w-3 h-3 mr-1" />
|
||||
<span>+{scenario.stats.growth}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<CardContent className="p-4 flex flex-col items-center">
|
||||
<div className="text-3xl mb-2">{scenario.icon}</div>
|
||||
<h3 className="text-blue-600 font-medium text-center">{scenario.name}</h3>
|
||||
<p className="text-xs text-gray-500 text-center mt-1 line-clamp-2">{scenario.description}</p>
|
||||
<div className="flex items-center mt-2 text-gray-500 text-sm">
|
||||
<span>今日: </span>
|
||||
<span className="font-medium ml-1">{scenario.count}</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-1 text-green-500 text-xs">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
<span>{scenario.growth}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
377
Cunkebao/app/scenarios/payment/[id]/page.tsx
Normal file
377
Cunkebao/app/scenarios/payment/[id]/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
import { ChevronLeft, Download, Share2, QrCode, Copy, BarChart4, Users } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
export default function PaymentCodeDetailPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState("detail")
|
||||
|
||||
// 模拟数据
|
||||
const paymentCode = {
|
||||
id: params.id,
|
||||
name: "社群会员付款码",
|
||||
amount: "19.9",
|
||||
description: "加入VIP社群会员",
|
||||
status: "active",
|
||||
qrCodeUrl: "/placeholder.svg?height=300&width=300&query=QR%20Code",
|
||||
paymentMethod: "wechat",
|
||||
createTime: "2023-10-26 11:00:00",
|
||||
totalPayments: 156,
|
||||
totalAmount: 3104.4,
|
||||
distribution: {
|
||||
enabled: true,
|
||||
type: "percentage",
|
||||
value: "10%",
|
||||
totalDistributed: 310.44,
|
||||
},
|
||||
redPacket: {
|
||||
enabled: true,
|
||||
amount: "5",
|
||||
totalSent: 156,
|
||||
totalAmount: 780,
|
||||
},
|
||||
autoWelcome: {
|
||||
enabled: true,
|
||||
message: "感谢您的支付,欢迎加入我们的VIP社群!",
|
||||
},
|
||||
tags: ["付款客户", "社群会员", "已成交"],
|
||||
}
|
||||
|
||||
const recentPayments = [
|
||||
{ id: 1, user: "用户1", amount: "19.9", time: "2023-10-26 15:30:00", status: "success" },
|
||||
{ id: 2, user: "用户2", amount: "19.9", time: "2023-10-26 14:45:00", status: "success" },
|
||||
{ id: 3, user: "用户3", amount: "19.9", time: "2023-10-26 13:20:00", status: "success" },
|
||||
{ id: 4, user: "用户4", amount: "19.9", time: "2023-10-26 12:10:00", status: "success" },
|
||||
{ id: 5, user: "用户5", amount: "19.9", time: "2023-10-26 11:05:00", status: "success" },
|
||||
]
|
||||
|
||||
const handleStatusChange = (checked: boolean) => {
|
||||
toast({
|
||||
title: checked ? "付款码已启用" : "付款码已停用",
|
||||
description: checked ? "客户现在可以通过此付款码支付" : "客户将无法通过此付款码支付",
|
||||
})
|
||||
}
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast({
|
||||
title: "复制成功",
|
||||
description: "内容已复制到剪贴板",
|
||||
})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
toast({
|
||||
title: "下载成功",
|
||||
description: "付款码已保存到相册",
|
||||
})
|
||||
}
|
||||
|
||||
const handleShare = () => {
|
||||
toast({
|
||||
title: "分享成功",
|
||||
description: "付款码已分享",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<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.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">付款码详情</h1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Switch checked={paymentCode.status === "active"} onCheckedChange={handleStatusChange} />
|
||||
<span className="ml-2 text-sm">{paymentCode.status === "active" ? "启用中" : "已停用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex-1 px-4 pb-20">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid grid-cols-3 mb-4">
|
||||
<TabsTrigger value="detail" className="text-xs">
|
||||
<QrCode className="h-4 w-4 mr-1" />
|
||||
付款码
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="payments" className="text-xs">
|
||||
<BarChart4 className="h-4 w-4 mr-1" />
|
||||
支付记录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="text-xs">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
高级设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 付款码详情 */}
|
||||
<TabsContent value="detail" className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 flex flex-col items-center">
|
||||
<h2 className="font-medium text-center mb-2">{paymentCode.name}</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">{paymentCode.description}</p>
|
||||
|
||||
<div className="relative w-64 h-64 mb-4">
|
||||
<Image
|
||||
src={paymentCode.qrCodeUrl || "/placeholder.svg"}
|
||||
alt="付款码"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<Badge variant="outline" className="text-green-500 bg-green-50">
|
||||
¥{paymentCode.amount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center space-x-4 w-full">
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
下载
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShare}>
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(`付款码:${paymentCode.name},金额:${paymentCode.amount}元`)}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">付款码信息</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">付款码ID</span>
|
||||
<span className="font-medium">{paymentCode.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">支付方式</span>
|
||||
<span className="font-medium">
|
||||
{paymentCode.paymentMethod === "wechat"
|
||||
? "微信支付"
|
||||
: paymentCode.paymentMethod === "alipay"
|
||||
? "支付宝"
|
||||
: "微信和支付宝"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">创建时间</span>
|
||||
<span className="font-medium">{paymentCode.createTime}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">支付笔数</span>
|
||||
<span className="font-medium">{paymentCode.totalPayments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">支付金额</span>
|
||||
<span className="font-medium">¥{paymentCode.totalAmount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 支付记录 */}
|
||||
<TabsContent value="payments" className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium">最近支付记录</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-blue-500"
|
||||
onClick={() => router.push(`/scenarios/payment/${params.id}/payments`)}
|
||||
>
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{recentPayments.map((payment) => (
|
||||
<div key={payment.id} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||
<div>
|
||||
<p className="font-medium">{payment.user}</p>
|
||||
<p className="text-xs text-gray-500">{payment.time}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">¥{payment.amount}</p>
|
||||
<Badge variant="outline" className="text-green-500 bg-green-50 text-xs">
|
||||
支付成功
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">支付统计</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">总支付笔数</span>
|
||||
<span className="font-medium">{paymentCode.totalPayments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">总支付金额</span>
|
||||
<span className="font-medium">¥{paymentCode.totalAmount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">平均客单价</span>
|
||||
<span className="font-medium">
|
||||
¥{(paymentCode.totalAmount / paymentCode.totalPayments).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">新增客户数</span>
|
||||
<span className="font-medium">{Math.floor(paymentCode.totalPayments * 0.9)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 高级设置 */}
|
||||
<TabsContent value="settings" className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">分销返利设置</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-gray-500">启用分销返利</span>
|
||||
<Switch checked={paymentCode.distribution.enabled} />
|
||||
</div>
|
||||
|
||||
{paymentCode.distribution.enabled && (
|
||||
<div className="space-y-3 pl-4 border-l-2 border-blue-100">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">返利类型</span>
|
||||
<span className="font-medium">
|
||||
{paymentCode.distribution.type === "percentage" ? "比例返利" : "固定金额"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">返利值</span>
|
||||
<span className="font-medium">{paymentCode.distribution.value}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">已发放金额</span>
|
||||
<span className="font-medium">¥{paymentCode.distribution.totalDistributed}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">红包奖励设置</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-gray-500">启用红包奖励</span>
|
||||
<Switch checked={paymentCode.redPacket.enabled} />
|
||||
</div>
|
||||
|
||||
{paymentCode.redPacket.enabled && (
|
||||
<div className="space-y-3 pl-4 border-l-2 border-blue-100">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">红包金额</span>
|
||||
<span className="font-medium">¥{paymentCode.redPacket.amount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">已发放数量</span>
|
||||
<span className="font-medium">{paymentCode.redPacket.totalSent}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">已发放金额</span>
|
||||
<span className="font-medium">¥{paymentCode.redPacket.totalAmount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">自动欢迎语设置</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-gray-500">启用自动欢迎语</span>
|
||||
<Switch checked={paymentCode.autoWelcome.enabled} />
|
||||
</div>
|
||||
|
||||
{paymentCode.autoWelcome.enabled && (
|
||||
<div className="pl-4 border-l-2 border-blue-100">
|
||||
<div className="py-2">
|
||||
<span className="text-gray-500 block mb-2">欢迎语内容</span>
|
||||
<div className="bg-gray-50 p-3 rounded-md text-sm">{paymentCode.autoWelcome.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">客户标签设置</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{paymentCode.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="rounded-full">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="rounded-full text-blue-500">
|
||||
+ 添加标签
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
if (confirm("确定要删除此付款码吗?删除后将无法恢复。")) {
|
||||
toast({
|
||||
title: "付款码已删除",
|
||||
description: "付款码已成功删除",
|
||||
})
|
||||
router.push("/scenarios/payment")
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除付款码
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
Cunkebao/app/scenarios/payment/[id]/payments/loading.tsx
Normal file
3
Cunkebao/app/scenarios/payment/[id]/payments/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
263
Cunkebao/app/scenarios/payment/[id]/payments/page.tsx
Normal file
263
Cunkebao/app/scenarios/payment/[id]/payments/page.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Download, Filter, Search } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { DatePickerWithRange } from "@/components/ui/date-picker"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
export default function PaymentRecordsPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// 模拟数据
|
||||
const paymentRecords = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
transactionId: `WX${Date.now()}${i}`,
|
||||
user: `用户${i + 1}`,
|
||||
platformUserId: `oq_${Math.random().toString(36).substr(2, 9)}`,
|
||||
amount: "19.9",
|
||||
paymentTime: new Date(Date.now() - i * 3600000).toLocaleString(),
|
||||
status: i % 10 === 0 ? "refunded" : "success",
|
||||
paymentMethod: "wechat",
|
||||
customerTags: ["付款客户", "社群会员"],
|
||||
}))
|
||||
|
||||
const itemsPerPage = 10
|
||||
const totalPages = Math.ceil(paymentRecords.length / itemsPerPage)
|
||||
const startIndex = (currentPage - 1) * itemsPerPage
|
||||
const endIndex = startIndex + itemsPerPage
|
||||
const currentRecords = paymentRecords.slice(startIndex, endIndex)
|
||||
|
||||
const handleExport = () => {
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: "支付记录已导出为Excel文件",
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return (
|
||||
<Badge variant="outline" className="text-green-500 bg-green-50">
|
||||
支付成功
|
||||
</Badge>
|
||||
)
|
||||
case "refunded":
|
||||
return (
|
||||
<Badge variant="outline" className="text-orange-500 bg-orange-50">
|
||||
已退款
|
||||
</Badge>
|
||||
)
|
||||
case "failed":
|
||||
return (
|
||||
<Badge variant="outline" className="text-red-500 bg-red-50">
|
||||
支付失败
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<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.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">支付记录</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowFilters(!showFilters)}>
|
||||
<Filter className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleExport}>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索用户名、交易ID"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">支付状态</label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="success">支付成功</SelectItem>
|
||||
<SelectItem value="refunded">已退款</SelectItem>
|
||||
<SelectItem value="failed">支付失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">支付时间</label>
|
||||
<DatePickerWithRange />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setStatusFilter("all")
|
||||
setSearchTerm("")
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button className="flex-1">应用筛选</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="px-4 pb-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">总支付笔数</p>
|
||||
<p className="text-xl font-semibold">{paymentRecords.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">总支付金额</p>
|
||||
<p className="text-xl font-semibold">¥{(paymentRecords.length * 19.9).toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">成功率</p>
|
||||
<p className="text-xl font-semibold">90%</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 记录列表 - 移动端优化 */}
|
||||
<div className="flex-1 px-4 pb-20">
|
||||
<div className="space-y-3">
|
||||
{currentRecords.map((record) => (
|
||||
<Card key={record.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p className="font-medium">{record.user}</p>
|
||||
<p className="text-xs text-gray-500">交易ID: {record.transactionId}</p>
|
||||
</div>
|
||||
{getStatusBadge(record.status)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">支付金额</span>
|
||||
<span className="font-medium">¥{record.amount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">支付时间</span>
|
||||
<span>{record.paymentTime}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">支付方式</span>
|
||||
<span>{record.paymentMethod === "wechat" ? "微信支付" : "支付宝"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{record.customerTags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-blue-500"
|
||||
onClick={() => router.push(`/wechat-accounts/${record.platformUserId}`)}
|
||||
>
|
||||
查看客户详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-6">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const pageNumber = i + 1
|
||||
return (
|
||||
<PaginationItem key={pageNumber}>
|
||||
<PaginationLink onClick={() => setCurrentPage(pageNumber)} isActive={currentPage === pageNumber}>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
307
Cunkebao/app/scenarios/payment/new/page.tsx
Normal file
307
Cunkebao/app/scenarios/payment/new/page.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Info } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
export default function NewPaymentCodePage() {
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
amount: "",
|
||||
description: "",
|
||||
paymentMethod: "wechat",
|
||||
amountType: "fixed",
|
||||
enableDistribution: false,
|
||||
distributionType: "percentage",
|
||||
distributionValue: "",
|
||||
enableRedPacket: false,
|
||||
redPacketAmount: "",
|
||||
enableAutoWelcome: false,
|
||||
welcomeMessage: "",
|
||||
tags: [],
|
||||
})
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 表单验证
|
||||
if (!formData.name) {
|
||||
toast({
|
||||
title: "请输入付款码名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.amountType === "fixed" && !formData.amount) {
|
||||
toast({
|
||||
title: "请输入付款金额",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
toast({
|
||||
title: "付款码创建成功",
|
||||
description: "您可以在付款码列表中查看和管理",
|
||||
})
|
||||
router.push("/scenarios/payment")
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<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.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">新建付款码</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<div className="flex-1 p-4 pb-20">
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<h2 className="font-medium">基本信息</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">付款码名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="请输入付款码名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentMethod">支付方式</Label>
|
||||
<Select value={formData.paymentMethod} onValueChange={(value) => handleChange("paymentMethod", value)}>
|
||||
<SelectTrigger id="paymentMethod">
|
||||
<SelectValue placeholder="选择支付方式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wechat">微信支付</SelectItem>
|
||||
<SelectItem value="alipay">支付宝</SelectItem>
|
||||
<SelectItem value="both">微信和支付宝</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>金额设置</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="w-[200px] text-xs">
|
||||
固定金额:生成固定金额的付款码
|
||||
<br />
|
||||
自由金额:用户可自行输入支付金额
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={formData.amountType}
|
||||
onValueChange={(value) => handleChange("amountType", value)}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="fixed" id="fixed" />
|
||||
<Label htmlFor="fixed">固定金额</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="free" id="free" />
|
||||
<Label htmlFor="free">自由金额</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{formData.amountType === "fixed" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">付款金额 (元)</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="请输入付款金额"
|
||||
value={formData.amount}
|
||||
onChange={(e) => handleChange("amount", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">付款描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="请输入付款描述,用户支付时可见"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange("description", e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<h2 className="font-medium">高级设置</h2>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="enableDistribution">启用分销返利</Label>
|
||||
<p className="text-xs text-gray-500">开启后,用户付款可触发分销返利</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enableDistribution"
|
||||
checked={formData.enableDistribution}
|
||||
onCheckedChange={(checked) => handleChange("enableDistribution", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.enableDistribution && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-blue-100">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="distributionType">返利类型</Label>
|
||||
<Select
|
||||
value={formData.distributionType}
|
||||
onValueChange={(value) => handleChange("distributionType", value)}
|
||||
>
|
||||
<SelectTrigger id="distributionType">
|
||||
<SelectValue placeholder="选择返利类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="percentage">比例返利</SelectItem>
|
||||
<SelectItem value="fixed">固定金额</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="distributionValue">
|
||||
{formData.distributionType === "percentage" ? "返利比例 (%)" : "返利金额 (元)"}
|
||||
</Label>
|
||||
<Input
|
||||
id="distributionValue"
|
||||
type="number"
|
||||
step={formData.distributionType === "percentage" ? "1" : "0.01"}
|
||||
placeholder={formData.distributionType === "percentage" ? "例如:10" : "例如:5"}
|
||||
value={formData.distributionValue}
|
||||
onChange={(e) => handleChange("distributionValue", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="enableRedPacket">启用红包奖励</Label>
|
||||
<p className="text-xs text-gray-500">开启后,用户付款可获得红包奖励</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enableRedPacket"
|
||||
checked={formData.enableRedPacket}
|
||||
onCheckedChange={(checked) => handleChange("enableRedPacket", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.enableRedPacket && (
|
||||
<div className="space-y-2 pl-4 border-l-2 border-blue-100">
|
||||
<Label htmlFor="redPacketAmount">红包金额 (元)</Label>
|
||||
<Input
|
||||
id="redPacketAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="例如:5"
|
||||
value={formData.redPacketAmount}
|
||||
onChange={(e) => handleChange("redPacketAmount", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="enableAutoWelcome">自动欢迎语</Label>
|
||||
<p className="text-xs text-gray-500">开启后,用户付款成功后自动发送欢迎语</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enableAutoWelcome"
|
||||
checked={formData.enableAutoWelcome}
|
||||
onCheckedChange={(checked) => handleChange("enableAutoWelcome", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.enableAutoWelcome && (
|
||||
<div className="space-y-2 pl-4 border-l-2 border-blue-100">
|
||||
<Label htmlFor="welcomeMessage">欢迎语内容</Label>
|
||||
<Textarea
|
||||
id="welcomeMessage"
|
||||
placeholder="请输入欢迎语内容"
|
||||
value={formData.welcomeMessage}
|
||||
onChange={(e) => handleChange("welcomeMessage", e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<h2 className="font-medium">客户标签设置</h2>
|
||||
<p className="text-sm text-gray-500">设置通过此付款码添加的客户自动打上的标签</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="rounded-full">
|
||||
付款客户
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="rounded-full">
|
||||
高价值
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="rounded-full">
|
||||
已成交
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="rounded-full text-blue-500">
|
||||
+ 添加标签
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t p-4 flex justify-between">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>创建付款码</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
278
Cunkebao/app/scenarios/payment/page.tsx
Normal file
278
Cunkebao/app/scenarios/payment/page.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Plus, Settings, QrCode, BarChart4, CreditCard, Users, Gift } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
export default function PaymentCodePage() {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState("codes")
|
||||
|
||||
// 模拟数据
|
||||
const paymentCodes = [
|
||||
{ id: 1, name: "社群会员付款码", amount: "19.9", status: "active", totalPayments: 156, totalAmount: 3104.4 },
|
||||
{ id: 2, name: "课程付款码", amount: "99", status: "active", totalPayments: 78, totalAmount: 7722 },
|
||||
{ id: 3, name: "咨询服务付款码", amount: "199", status: "active", totalPayments: 45, totalAmount: 8955 },
|
||||
{ id: 4, name: "产品购买付款码", amount: "299", status: "active", totalPayments: 32, totalAmount: 9568 },
|
||||
]
|
||||
|
||||
const statistics = {
|
||||
today: { payments: 24, amount: 2568, customers: 22 },
|
||||
week: { payments: 156, amount: 15420, customers: 142 },
|
||||
month: { payments: 311, amount: 29845, customers: 289 },
|
||||
}
|
||||
|
||||
const distributions = [
|
||||
{ id: 1, name: "分销返利规则1", type: "percentage", value: "10%", totalDistributed: 1245.6 },
|
||||
{ id: 2, name: "分销返利规则2", type: "fixed", value: "5元", totalDistributed: 890 },
|
||||
]
|
||||
|
||||
const redPackets = [
|
||||
{ id: 1, name: "新客红包", amount: "5", totalSent: 156, totalAmount: 780 },
|
||||
{ id: 2, name: "推广红包", amount: "10", totalSent: 89, totalAmount: 890 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<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.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">付款码获客</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/scenarios/payment/settings")}>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="p-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-medium">数据概览</h2>
|
||||
<Badge variant="outline" className="text-blue-500 bg-blue-50">
|
||||
今日
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500">支付笔数</p>
|
||||
<p className="text-xl font-semibold">{statistics.today.payments}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500">支付金额</p>
|
||||
<p className="text-xl font-semibold">¥{statistics.today.amount}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500">新增客户</p>
|
||||
<p className="text-xl font-semibold">{statistics.today.customers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex-1 px-4 pb-20">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid grid-cols-4 mb-4">
|
||||
<TabsTrigger value="codes" className="text-xs">
|
||||
<QrCode className="h-4 w-4 mr-1" />
|
||||
付款码
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stats" className="text-xs">
|
||||
<BarChart4 className="h-4 w-4 mr-1" />
|
||||
数据统计
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="distribution" className="text-xs">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
分销返利
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="redpacket" className="text-xs">
|
||||
<Gift className="h-4 w-4 mr-1" />
|
||||
红包池
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 付款码列表 */}
|
||||
<TabsContent value="codes" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => router.push("/scenarios/payment/new")} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建付款码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paymentCodes.map((code) => (
|
||||
<Card key={code.id} className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center p-4 border-b">
|
||||
<div className="bg-blue-100 rounded-full p-2 mr-3">
|
||||
<CreditCard className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{code.name}</h3>
|
||||
<p className="text-sm text-gray-500">金额: ¥{code.amount}</p>
|
||||
</div>
|
||||
<Badge variant={code.status === "active" ? "success" : "secondary"}>
|
||||
{code.status === "active" ? "启用中" : "已停用"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 divide-x">
|
||||
<div className="p-3 text-center">
|
||||
<p className="text-sm text-gray-500">支付笔数</p>
|
||||
<p className="font-semibold">{code.totalPayments}</p>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<p className="text-sm text-gray-500">支付金额</p>
|
||||
<p className="font-semibold">¥{code.totalAmount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-blue-500"
|
||||
onClick={() => router.push(`/scenarios/payment/${code.id}`)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* 数据统计 */}
|
||||
<TabsContent value="stats" className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">支付数据统计</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">今日支付笔数</span>
|
||||
<span className="font-medium">{statistics.today.payments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">今日支付金额</span>
|
||||
<span className="font-medium">¥{statistics.today.amount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">今日新增客户</span>
|
||||
<span className="font-medium">{statistics.today.customers}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">本周支付笔数</span>
|
||||
<span className="font-medium">{statistics.week.payments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">本周支付金额</span>
|
||||
<span className="font-medium">¥{statistics.week.amount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">本月支付金额</span>
|
||||
<span className="font-medium">¥{statistics.month.amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={() => router.push("/scenarios/payment/stats")}>
|
||||
查看详细统计
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 分销返利 */}
|
||||
<TabsContent value="distribution" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => router.push("/scenarios/payment/distribution/new")} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建分销规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{distributions.map((rule) => (
|
||||
<Card key={rule.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium">{rule.name}</h3>
|
||||
<Badge variant="outline">{rule.type === "percentage" ? "比例" : "固定金额"}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">返利值</span>
|
||||
<span className="font-medium">{rule.value}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">已发放金额</span>
|
||||
<span className="font-medium">¥{rule.totalDistributed}</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-blue-500"
|
||||
onClick={() => router.push(`/scenarios/payment/distribution/${rule.id}`)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* 红包池 */}
|
||||
<TabsContent value="redpacket" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => router.push("/scenarios/payment/redpacket/new")} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建红包
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{redPackets.map((packet) => (
|
||||
<Card key={packet.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium">{packet.name}</h3>
|
||||
<Badge variant="outline" className="text-red-500 bg-red-50">
|
||||
¥{packet.amount}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">已发放数量</span>
|
||||
<span className="font-medium">{packet.totalSent}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">已发放金额</span>
|
||||
<span className="font-medium">¥{packet.totalAmount}</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-blue-500"
|
||||
onClick={() => router.push(`/scenarios/payment/redpacket/${packet.id}`)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
260
Cunkebao/app/scenarios/payment/stats/page.tsx
Normal file
260
Cunkebao/app/scenarios/payment/stats/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Download, Calendar, TrendingUp, TrendingDown } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { LineChartComponent, BarChartComponent, PieChartComponent } from "@/app/components/common/Charts"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
export default function PaymentStatsPage() {
|
||||
const router = useRouter()
|
||||
const [dateRange, setDateRange] = useState("week")
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
|
||||
// 模拟图表数据
|
||||
const lineChartData = [
|
||||
{ name: "周一", 支付笔数: 24, 支付金额: 478.8 },
|
||||
{ name: "周二", 支付笔数: 32, 支付金额: 636.8 },
|
||||
{ name: "周三", 支付笔数: 28, 支付金额: 557.2 },
|
||||
{ name: "周四", 支付笔数: 36, 支付金额: 716.4 },
|
||||
{ name: "周五", 支付笔数: 42, 支付金额: 835.8 },
|
||||
{ name: "周六", 支付笔数: 38, 支付金额: 756.2 },
|
||||
{ name: "周日", 支付笔数: 35, 支付金额: 696.5 },
|
||||
]
|
||||
|
||||
const barChartData = [
|
||||
{ name: "社群会员付款码", 支付笔数: 156, 支付金额: 3104.4 },
|
||||
{ name: "课程付款码", 支付笔数: 78, 支付金额: 7722 },
|
||||
{ name: "咨询服务付款码", 支付笔数: 45, 支付金额: 8955 },
|
||||
{ name: "产品购买付款码", 支付笔数: 32, 支付金额: 9568 },
|
||||
]
|
||||
|
||||
const pieChartData = [
|
||||
{ name: "微信支付", value: 75 },
|
||||
{ name: "支付宝", value: 25 },
|
||||
]
|
||||
|
||||
const lineChartConfig = {
|
||||
支付笔数: { label: "支付笔数", color: "#3b82f6" },
|
||||
支付金额: { label: "支付金额", color: "#10b981" },
|
||||
}
|
||||
|
||||
const barChartConfig = {
|
||||
支付笔数: { label: "支付笔数", color: "#3b82f6" },
|
||||
支付金额: { label: "支付金额", color: "#10b981" },
|
||||
}
|
||||
|
||||
const pieChartConfig = {
|
||||
微信支付: { label: "微信支付", color: "#10b981" },
|
||||
支付宝: { label: "支付宝", color: "#3b82f6" },
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: "数据统计报表已导出为Excel文件",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<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.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">数据统计</h1>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleExport}>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 日期范围选择 */}
|
||||
<div className="p-4">
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="选择时间范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">今日</SelectItem>
|
||||
<SelectItem value="week">本周</SelectItem>
|
||||
<SelectItem value="month">本月</SelectItem>
|
||||
<SelectItem value="quarter">本季度</SelectItem>
|
||||
<SelectItem value="year">本年</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="px-4 pb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-500">总支付笔数</p>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<p className="text-2xl font-semibold">311</p>
|
||||
<p className="text-xs text-green-500 mt-1">+12.5% 较上期</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-500">总支付金额</p>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<p className="text-2xl font-semibold">¥29,845</p>
|
||||
<p className="text-xs text-green-500 mt-1">+8.3% 较上期</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-500">新增客户</p>
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
<p className="text-2xl font-semibold">289</p>
|
||||
<p className="text-xs text-red-500 mt-1">-3.2% 较上期</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-gray-500">平均客单价</p>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<p className="text-2xl font-semibold">¥95.98</p>
|
||||
<p className="text-xs text-green-500 mt-1">+5.6% 较上期</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图表标签页 */}
|
||||
<div className="flex-1 px-4 pb-20">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid grid-cols-3 mb-4">
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
<TabsTrigger value="trend">趋势</TabsTrigger>
|
||||
<TabsTrigger value="distribution">分布</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<LineChartComponent
|
||||
title="支付趋势"
|
||||
description="本周支付笔数和金额趋势"
|
||||
data={lineChartData}
|
||||
config={lineChartConfig}
|
||||
height={250}
|
||||
/>
|
||||
|
||||
<BarChartComponent
|
||||
title="付款码对比"
|
||||
description="各付款码支付情况对比"
|
||||
data={barChartData}
|
||||
config={barChartConfig}
|
||||
height={250}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trend" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>支付趋势分析</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LineChartComponent data={lineChartData} config={lineChartConfig} height={300} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">趋势说明</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>• 本周支付笔数较上周增长 12.5%</p>
|
||||
<p>• 周五为支付高峰期,建议加强营销推广</p>
|
||||
<p>• 周末支付量保持稳定,用户活跃度良好</p>
|
||||
<p>• 平均每日支付笔数为 33.6 笔</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="distribution" className="space-y-4">
|
||||
<PieChartComponent
|
||||
title="支付方式分布"
|
||||
description="各支付方式占比"
|
||||
data={pieChartData}
|
||||
config={pieChartConfig}
|
||||
height={250}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">客户分布</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">新客户</span>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-2">289</span>
|
||||
<span className="text-sm text-gray-500">(92.9%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b">
|
||||
<span className="text-gray-500">老客户</span>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-2">22</span>
|
||||
<span className="text-sm text-gray-500">(7.1%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">复购率</span>
|
||||
<span className="font-medium">7.1%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-medium mb-3">地域分布 TOP5</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ city: "广州", count: 89, percent: 28.6 },
|
||||
{ city: "深圳", count: 67, percent: 21.5 },
|
||||
{ city: "上海", count: 45, percent: 14.5 },
|
||||
{ city: "北京", count: 38, percent: 12.2 },
|
||||
{ city: "杭州", count: 32, percent: 10.3 },
|
||||
].map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<span className="text-gray-600">{item.city}</span>
|
||||
<div className="flex items-center">
|
||||
<div className="w-24 bg-gray-200 rounded-full h-2 mr-2">
|
||||
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${item.percent}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-medium w-12 text-right">{item.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -176,4 +176,3 @@ export default function PhoneAcquiredPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -130,4 +130,3 @@ export default function PhoneAddedPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -184,4 +184,3 @@ export default function PhoneDevicesPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -399,4 +399,3 @@ export default function EditPhoneAcquisitionPlan({ params }: { params: { id: str
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
334
Cunkebao/app/scenarios/phone/new/basic/page.tsx
Normal file
334
Cunkebao/app/scenarios/phone/new/basic/page.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Phone, Settings } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
// 电话通话标签
|
||||
const phoneCallTags = [
|
||||
{ id: "tag-1", name: "咨询", color: "bg-blue-100 text-blue-800" },
|
||||
{ id: "tag-2", name: "投诉", color: "bg-red-100 text-red-800" },
|
||||
{ id: "tag-3", name: "合作", color: "bg-green-100 text-green-800" },
|
||||
{ id: "tag-4", name: "价格", color: "bg-orange-100 text-orange-800" },
|
||||
{ id: "tag-5", name: "售后", color: "bg-purple-100 text-purple-800" },
|
||||
{ id: "tag-6", name: "订单", color: "bg-yellow-100 text-yellow-800" },
|
||||
{ id: "tag-7", name: "物流", color: "bg-teal-100 text-teal-800" },
|
||||
{ id: "tag-8", name: "退款", color: "bg-pink-100 text-pink-800" },
|
||||
{ id: "tag-9", name: "技术支持", color: "bg-indigo-100 text-indigo-800" },
|
||||
{ id: "tag-10", name: "其他", color: "bg-gray-100 text-gray-800" },
|
||||
]
|
||||
|
||||
export default function PhoneAcquisitionBasicPage() {
|
||||
const router = useRouter()
|
||||
const [planName, setPlanName] = useState(`电话获客${new Date().toLocaleDateString("zh-CN").replace(/\//g, "")}`)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([])
|
||||
const [phoneCallType, setPhoneCallType] = useState("both")
|
||||
const [isPhoneSettingsOpen, setIsPhoneSettingsOpen] = useState(false)
|
||||
|
||||
// 电话获客设置
|
||||
const [phoneSettings, setPhoneSettings] = useState({
|
||||
autoAdd: true,
|
||||
speechToText: true,
|
||||
questionExtraction: true,
|
||||
})
|
||||
|
||||
// 处理标签选择
|
||||
const handleTagToggle = (tagId: string) => {
|
||||
setSelectedTags((prev) => (prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]))
|
||||
}
|
||||
|
||||
// 处理通话类型选择
|
||||
const handleCallTypeToggle = (type: string) => {
|
||||
if (phoneCallType === "both") {
|
||||
setPhoneCallType(type === "outbound" ? "inbound" : "outbound")
|
||||
} else if (phoneCallType === type) {
|
||||
setPhoneCallType("both")
|
||||
} else {
|
||||
setPhoneCallType("both")
|
||||
}
|
||||
}
|
||||
|
||||
// 处理电话获客设置更新
|
||||
const handlePhoneSettingsUpdate = () => {
|
||||
setIsPhoneSettingsOpen(false)
|
||||
toast({
|
||||
title: "设置已更新",
|
||||
description: "电话获客设置已保存",
|
||||
})
|
||||
}
|
||||
|
||||
// 处理下一步
|
||||
const handleNext = () => {
|
||||
if (!planName.trim()) {
|
||||
toast({
|
||||
title: "请输入计划名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedTags.length === 0) {
|
||||
toast({
|
||||
title: "请至少选择一个通话标签",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 保存数据并跳转到下一步
|
||||
const formData = {
|
||||
scenario: "phone",
|
||||
planName,
|
||||
enabled,
|
||||
phoneSettings,
|
||||
phoneCallType,
|
||||
phoneTags: selectedTags,
|
||||
}
|
||||
|
||||
// 这里应该保存到状态管理或通过路由传递
|
||||
// router.push("/plans/new?step=2&scenario=phone")
|
||||
router.push("/scenarios/phone/new/account")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||
<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.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">电话获客设置</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm text-blue-600 font-medium">电话场景</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-4 pb-20">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
{/* 计划名称 */}
|
||||
<div>
|
||||
<Label htmlFor="planName">计划名称</Label>
|
||||
<Input
|
||||
id="planName"
|
||||
value={planName}
|
||||
onChange={(e) => setPlanName(e.target.value)}
|
||||
placeholder="请输入计划名称"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 电话获客设置卡片 */}
|
||||
<Card className="p-4 border-blue-100 bg-blue-50/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-base font-medium text-blue-700">电话获客设置</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsPhoneSettingsOpen(true)}
|
||||
className="flex items-center gap-1 bg-white border-blue-200 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
修改设置
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="flex items-center justify-between bg-white p-3 rounded-lg border border-blue-100 shadow-sm">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full mr-2 ${phoneSettings.autoAdd ? "bg-green-500" : "bg-gray-300"}`}
|
||||
></div>
|
||||
<span>自动添加客户</span>
|
||||
</div>
|
||||
<div
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${phoneSettings.autoAdd ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
{phoneSettings.autoAdd ? "已开启" : "已关闭"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-white p-3 rounded-lg border border-blue-100 shadow-sm">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full mr-2 ${phoneSettings.speechToText ? "bg-green-500" : "bg-gray-300"}`}
|
||||
></div>
|
||||
<span>语音转文字</span>
|
||||
</div>
|
||||
<div
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${phoneSettings.speechToText ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
{phoneSettings.speechToText ? "已开启" : "已关闭"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-white p-3 rounded-lg border border-blue-100 shadow-sm">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full mr-2 ${phoneSettings.questionExtraction ? "bg-green-500" : "bg-gray-300"}`}
|
||||
></div>
|
||||
<span>问题提取</span>
|
||||
</div>
|
||||
<div
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${phoneSettings.questionExtraction ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
{phoneSettings.questionExtraction ? "已开启" : "已关闭"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
提示:电话获客功能将自动记录来电信息,并根据设置执行相应操作
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 通话类型选择 */}
|
||||
<div>
|
||||
<Label className="text-base mb-2 block">通话类型</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<div
|
||||
className={`p-3 border rounded-lg text-center cursor-pointer ${
|
||||
phoneCallType === "outbound" || phoneCallType === "both"
|
||||
? "bg-blue-50 border-blue-300"
|
||||
: "bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => handleCallTypeToggle("outbound")}
|
||||
>
|
||||
<div className="font-medium">发起外呼</div>
|
||||
<div className="text-sm text-gray-500">主动向客户发起电话</div>
|
||||
</div>
|
||||
<div
|
||||
className={`p-3 border rounded-lg text-center cursor-pointer ${
|
||||
phoneCallType === "inbound" || phoneCallType === "both"
|
||||
? "bg-blue-50 border-blue-300"
|
||||
: "bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => handleCallTypeToggle("inbound")}
|
||||
>
|
||||
<div className="font-medium">接收来电</div>
|
||||
<div className="text-sm text-gray-500">接听客户的来电</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通话标签 */}
|
||||
<div>
|
||||
<Label className="text-base mb-2 block">通话标签(可多选)</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{phoneCallTags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`px-3 py-1.5 rounded-full text-sm cursor-pointer transition-all ${
|
||||
selectedTags.includes(tag.id) ? tag.color : "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
||||
}`}
|
||||
onClick={() => handleTagToggle(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedTags.length > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-2">已选择 {selectedTags.length} 个标签</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 是否启用 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enabled">是否启用</Label>
|
||||
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
{/* 下一步按钮 */}
|
||||
<Button className="w-full h-12 text-base" onClick={handleNext}>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 电话获客设置对话框 */}
|
||||
<Dialog open={isPhoneSettingsOpen} onOpenChange={setIsPhoneSettingsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>电话获客设置</DialogTitle>
|
||||
<DialogDescription>配置电话获客的自动化功能,提高获客效率</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4 space-y-6">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="auto-add" className="font-medium">
|
||||
自动添加客户
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">来电后自动将客户添加为微信好友</p>
|
||||
<p className="text-xs text-blue-600">推荐:开启此功能可提高转化率约30%</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-add"
|
||||
checked={phoneSettings.autoAdd}
|
||||
onCheckedChange={(checked) => setPhoneSettings({ ...phoneSettings, autoAdd: checked })}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="speech-to-text" className="font-medium">
|
||||
语音转文字
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">自动将通话内容转换为文字记录</p>
|
||||
<p className="text-xs text-blue-600">支持普通话、粤语等多种方言识别</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="speech-to-text"
|
||||
checked={phoneSettings.speechToText}
|
||||
onCheckedChange={(checked) => setPhoneSettings({ ...phoneSettings, speechToText: checked })}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="question-extraction" className="font-medium">
|
||||
问题提取
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500">自动从通话中提取客户的首句问题</p>
|
||||
<p className="text-xs text-blue-600">AI智能识别客户意图,提高回复精准度</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="question-extraction"
|
||||
checked={phoneSettings.questionExtraction}
|
||||
onCheckedChange={(checked) => setPhoneSettings({ ...phoneSettings, questionExtraction: checked })}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex space-x-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setIsPhoneSettingsOpen(false)} className="flex-1">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handlePhoneSettingsUpdate} className="flex-1 bg-blue-600 hover:bg-blue-700">
|
||||
保存设置
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
interface AIFilterSettingsProps {
|
||||
settings: {
|
||||
enabled: boolean
|
||||
removeBlacklist: boolean
|
||||
validateFormat: boolean
|
||||
removeDuplicates: boolean
|
||||
}
|
||||
onChange: (settings: any) => void
|
||||
}
|
||||
|
||||
export function AIFilterSettings({ settings, onChange }: AIFilterSettingsProps) {
|
||||
const handleToggle = (key: string, value: boolean) => {
|
||||
onChange({
|
||||
...settings,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="ai-filter">启用AI智能过滤</Label>
|
||||
<p className="text-xs text-gray-500">自动过滤无效号码</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-filter"
|
||||
checked={settings.enabled}
|
||||
onCheckedChange={(checked) => handleToggle("enabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.enabled && (
|
||||
<div className="space-y-3 pl-4 border-l-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remove-blacklist" className="text-sm">
|
||||
过滤黑名单
|
||||
</Label>
|
||||
<Switch
|
||||
id="remove-blacklist"
|
||||
checked={settings.removeBlacklist}
|
||||
onCheckedChange={(checked) => handleToggle("removeBlacklist", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="validate-format" className="text-sm">
|
||||
校验号码格式
|
||||
</Label>
|
||||
<Switch
|
||||
id="validate-format"
|
||||
checked={settings.validateFormat}
|
||||
onCheckedChange={(checked) => handleToggle("validateFormat", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remove-duplicates" className="text-sm">
|
||||
去除重复号码
|
||||
</Label>
|
||||
<Switch
|
||||
id="remove-duplicates"
|
||||
checked={settings.removeDuplicates}
|
||||
onCheckedChange={(checked) => handleToggle("removeDuplicates", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Smartphone, Wifi, WifiOff } from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
imei: string
|
||||
status: "online" | "offline"
|
||||
wechatAccount?: string
|
||||
}
|
||||
|
||||
interface DeviceConfigDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedDevices: string[]
|
||||
onSelect: (devices: string[]) => void
|
||||
}
|
||||
|
||||
export function DeviceConfigDialog({ open, onOpenChange, selectedDevices, onSelect }: DeviceConfigDialogProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [selected, setSelected] = useState<string[]>(selectedDevices)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadDevices()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const loadDevices = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 模拟加载设备列表
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setDevices([
|
||||
{
|
||||
id: "1",
|
||||
name: "设备1",
|
||||
imei: "sd123123",
|
||||
status: "online",
|
||||
wechatAccount: "wxid_abc123",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "设备2",
|
||||
imei: "sd123124",
|
||||
status: "online",
|
||||
wechatAccount: "wxid_xyz789",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "设备3",
|
||||
imei: "sd123125",
|
||||
status: "offline",
|
||||
wechatAccount: "wxid_def456",
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = (deviceId: string) => {
|
||||
setSelected((prev) => (prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId]))
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(selected)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择执行设备</DialogTitle>
|
||||
<DialogDescription>选择用于执行电话获客任务的设备</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-8 text-center text-gray-500">加载设备列表中...</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="h-[300px] pr-4">
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={`p-3 border rounded-lg ${device.status === "offline" ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
checked={selected.includes(device.id)}
|
||||
onCheckedChange={() => handleToggle(device.id)}
|
||||
disabled={device.status === "offline"}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
<span className="font-medium">{device.name}</span>
|
||||
</div>
|
||||
{device.status === "online" ? (
|
||||
<Wifi className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">IMEI: {device.imei}</p>
|
||||
{device.wechatAccount && <p className="text-xs text-gray-500">微信: {device.wechatAccount}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<p className="text-sm text-gray-500">已选择 {selected.length} 个设备</p>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>确认</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
interface DistributionSettingsProps {
|
||||
settings: {
|
||||
enabled: boolean
|
||||
rules: Array<{
|
||||
level: number
|
||||
percentage: number
|
||||
}>
|
||||
}
|
||||
onChange: (settings: any) => void
|
||||
}
|
||||
|
||||
export function DistributionSettings({ settings, onChange }: DistributionSettingsProps) {
|
||||
const handleToggle = (value: boolean) => {
|
||||
onChange({
|
||||
...settings,
|
||||
enabled: value,
|
||||
})
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
onChange({
|
||||
...settings,
|
||||
rules: [...settings.rules, { level: settings.rules.length + 1, percentage: 10 }],
|
||||
})
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
onChange({
|
||||
...settings,
|
||||
rules: settings.rules.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const updateRule = (index: number, percentage: number) => {
|
||||
const newRules = [...settings.rules]
|
||||
newRules[index].percentage = percentage
|
||||
onChange({
|
||||
...settings,
|
||||
rules: newRules,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="distribution">启用分销返利</Label>
|
||||
<p className="text-xs text-gray-500">自动计算并发放返利</p>
|
||||
</div>
|
||||
<Switch id="distribution" checked={settings.enabled} onCheckedChange={handleToggle} />
|
||||
</div>
|
||||
|
||||
{settings.enabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">分销层级设置</Label>
|
||||
<Button size="sm" variant="outline" onClick={addRule}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
添加层级
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{settings.rules.map((rule, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="text-sm w-16">{rule.level}级</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={rule.percentage}
|
||||
onChange={(e) => updateRule(index, Number(e.target.value))}
|
||||
className="flex-1"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<span className="text-sm">%</span>
|
||||
<Button size="icon" variant="ghost" onClick={() => removeRule(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
Cunkebao/app/scenarios/phone/new/components/FollowUpSettings.tsx
Normal file
123
Cunkebao/app/scenarios/phone/new/components/FollowUpSettings.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Plus, X } from "lucide-react"
|
||||
|
||||
interface FollowUpSettingsProps {
|
||||
settings: {
|
||||
autoWelcome: boolean
|
||||
welcomeMessage: string
|
||||
autoTag: boolean
|
||||
tags: string[]
|
||||
}
|
||||
onChange: (settings: any) => void
|
||||
}
|
||||
|
||||
export function FollowUpSettings({ settings, onChange }: FollowUpSettingsProps) {
|
||||
const handleToggle = (key: string, value: boolean) => {
|
||||
onChange({
|
||||
...settings,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMessageChange = (value: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
welcomeMessage: value,
|
||||
})
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const tag = prompt("请输入标签名称")
|
||||
if (tag && !settings.tags.includes(tag)) {
|
||||
onChange({
|
||||
...settings,
|
||||
tags: [...settings.tags, tag],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
tags: settings.tags.filter((t) => t !== tag),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="auto-welcome">自动发送欢迎语</Label>
|
||||
<p className="text-xs text-gray-500">加好友成功后自动发送</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-welcome"
|
||||
checked={settings.autoWelcome}
|
||||
onCheckedChange={(checked) => handleToggle("autoWelcome", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.autoWelcome && (
|
||||
<div>
|
||||
<Label htmlFor="welcome-message" className="text-sm">
|
||||
欢迎语内容
|
||||
</Label>
|
||||
<Textarea
|
||||
id="welcome-message"
|
||||
value={settings.welcomeMessage}
|
||||
onChange={(e) => handleMessageChange(e.target.value)}
|
||||
className="mt-1"
|
||||
rows={3}
|
||||
placeholder="请输入欢迎语内容"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="auto-tag">自动打标签</Label>
|
||||
<p className="text-xs text-gray-500">为新好友自动添加标签</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-tag"
|
||||
checked={settings.autoTag}
|
||||
onCheckedChange={(checked) => handleToggle("autoTag", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.autoTag && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm">标签列表</Label>
|
||||
<Button size="sm" variant="outline" onClick={addTag}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{settings.tags.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-600 rounded-md text-sm"
|
||||
>
|
||||
<span>{tag}</span>
|
||||
<button onClick={() => removeTag(tag)} className="hover:text-blue-800">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Upload } from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface PhoneImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onImport: (numbers: string[]) => void
|
||||
}
|
||||
|
||||
export function PhoneImportDialog({ open, onOpenChange, onImport }: PhoneImportDialogProps) {
|
||||
const [manualInput, setManualInput] = useState("")
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
const handleManualImport = () => {
|
||||
const numbers = manualInput
|
||||
.split(/[\n,,]/)
|
||||
.map((n) => n.trim())
|
||||
.filter((n) => n && /^1[3-9]\d{9}$/.test(n))
|
||||
|
||||
if (numbers.length === 0) {
|
||||
toast({
|
||||
title: "没有有效的电话号码",
|
||||
description: "请输入正确格式的手机号码",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onImport(numbers)
|
||||
setManualInput("")
|
||||
toast({
|
||||
title: "导入成功",
|
||||
description: `成功导入 ${numbers.length} 个电话号码`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setImporting(true)
|
||||
try {
|
||||
// 这里应该解析Excel/CSV文件
|
||||
// 模拟导入过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 模拟导入的号码
|
||||
const mockNumbers = ["13800138000", "13900139000", "13700137000"]
|
||||
|
||||
onImport(mockNumbers)
|
||||
toast({
|
||||
title: "导入成功",
|
||||
description: `成功导入 ${mockNumbers.length} 个电话号码`,
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "导入失败",
|
||||
description: "文件解析失败,请检查文件格式",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入电话号码</DialogTitle>
|
||||
<DialogDescription>支持批量导入Excel/CSV文件或手动输入</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="manual" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="manual">手动输入</TabsTrigger>
|
||||
<TabsTrigger value="file">文件导入</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="manual" className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">电话号码</label>
|
||||
<textarea
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md"
|
||||
rows={6}
|
||||
placeholder="每行一个号码,或用逗号分隔"
|
||||
value={manualInput}
|
||||
onChange={(e) => setManualInput(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">支持格式:13800138000,每行一个或逗号分隔</p>
|
||||
</div>
|
||||
<Button className="w-full" onClick={handleManualImport}>
|
||||
确认导入
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="file" className="space-y-4">
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center">
|
||||
<Upload className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-sm text-gray-600 mb-2">点击或拖拽文件到此处</p>
|
||||
<p className="text-xs text-gray-500 mb-4">支持 Excel (.xlsx, .xls) 和 CSV 文件</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
disabled={importing}
|
||||
/>
|
||||
<label htmlFor="file-upload">
|
||||
<Button as="span" disabled={importing}>
|
||||
{importing ? "导入中..." : "选择文件"}
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded-md">
|
||||
<p className="text-xs text-gray-600 mb-2">文件格式示例:</p>
|
||||
<div className="bg-white p-2 rounded border text-xs font-mono">
|
||||
<div>姓名,电话,来源,备注</div>
|
||||
<div>张三,13800138000,网站,意向客户</div>
|
||||
<div>李四,13900139000,广告,待跟进</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface RedPacketSettingsProps {
|
||||
settings: {
|
||||
enabled: boolean
|
||||
amount: number
|
||||
pool: number
|
||||
}
|
||||
onChange: (settings: any) => void
|
||||
}
|
||||
|
||||
export function RedPacketSettings({ settings, onChange }: RedPacketSettingsProps) {
|
||||
const handleToggle = (value: boolean) => {
|
||||
onChange({
|
||||
...settings,
|
||||
enabled: value,
|
||||
})
|
||||
}
|
||||
|
||||
const handleAmountChange = (value: number) => {
|
||||
onChange({
|
||||
...settings,
|
||||
amount: value,
|
||||
})
|
||||
}
|
||||
|
||||
const handlePoolChange = (value: number) => {
|
||||
onChange({
|
||||
...settings,
|
||||
pool: value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="redpacket">启用红包奖励</Label>
|
||||
<p className="text-xs text-gray-500">加好友成功后自动发红包</p>
|
||||
</div>
|
||||
<Switch id="redpacket" checked={settings.enabled} onCheckedChange={handleToggle} />
|
||||
</div>
|
||||
|
||||
{settings.enabled && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="amount" className="text-sm">
|
||||
单个红包金额(元)
|
||||
</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
value={settings.amount}
|
||||
onChange={(e) => handleAmountChange(Number(e.target.value))}
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="pool" className="text-sm">
|
||||
红包池总额(元)
|
||||
</Label>
|
||||
<Input
|
||||
id="pool"
|
||||
type="number"
|
||||
value={settings.pool}
|
||||
onChange={(e) => handlePoolChange(Number(e.target.value))}
|
||||
min="0"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">剩余可发:{Math.floor(settings.pool / settings.amount)} 个红包</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
254
Cunkebao/app/scenarios/phone/new/page.tsx
Normal file
254
Cunkebao/app/scenarios/phone/new/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Upload, Phone, Users, Filter, TrendingUp, Gift, MessageSquare } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { PhoneImportDialog } from "./components/PhoneImportDialog"
|
||||
import { DeviceConfigDialog } from "./components/DeviceConfigDialog"
|
||||
import { AIFilterSettings } from "./components/AIFilterSettings"
|
||||
import { DistributionSettings } from "./components/DistributionSettings"
|
||||
import { RedPacketSettings } from "./components/RedPacketSettings"
|
||||
import { FollowUpSettings } from "./components/FollowUpSettings"
|
||||
|
||||
export default function NewPhoneAcquisitionPlan() {
|
||||
const router = useRouter()
|
||||
const [showImportDialog, setShowImportDialog] = useState(false)
|
||||
const [showDeviceDialog, setShowDeviceDialog] = useState(false)
|
||||
const [planData, setPlanData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
phoneNumbers: [] as string[],
|
||||
devices: [] as string[],
|
||||
autoAddWechat: true,
|
||||
aiFilter: {
|
||||
enabled: true,
|
||||
removeBlacklist: true,
|
||||
validateFormat: true,
|
||||
removeDuplicates: true,
|
||||
},
|
||||
distribution: {
|
||||
enabled: false,
|
||||
rules: [],
|
||||
},
|
||||
redPacket: {
|
||||
enabled: false,
|
||||
amount: 0,
|
||||
pool: 0,
|
||||
},
|
||||
followUp: {
|
||||
autoWelcome: true,
|
||||
welcomeMessage: "您好,很高兴认识您!",
|
||||
autoTag: true,
|
||||
tags: ["电话获客"],
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 验证必填项
|
||||
if (!planData.name) {
|
||||
toast({
|
||||
title: "请输入计划名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (planData.phoneNumbers.length === 0) {
|
||||
toast({
|
||||
title: "请导入电话号码",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (planData.devices.length === 0) {
|
||||
toast({
|
||||
title: "请选择执行设备",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用API保存计划
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "电话获客计划已创建",
|
||||
})
|
||||
router.push("/scenarios/phone")
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "创建失败",
|
||||
description: "创建计划失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||
<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("/scenarios/phone")}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">新建电话获客计划</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pb-20">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 基础信息 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">基础信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">计划名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md"
|
||||
placeholder="请输入计划名称"
|
||||
value={planData.name}
|
||||
onChange={(e) => setPlanData({ ...planData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">计划描述</label>
|
||||
<textarea
|
||||
className="w-full mt-1 px-3 py-2 border rounded-md"
|
||||
rows={3}
|
||||
placeholder="请输入计划描述(选填)"
|
||||
value={planData.description}
|
||||
onChange={(e) => setPlanData({ ...planData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 电话导入 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4" />
|
||||
电话线索导入
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setShowImportDialog(true)}>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
导入
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>已导入 {planData.phoneNumbers.length} 个电话号码</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* 设备配置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
执行设备
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setShowDeviceDialog(true)}>
|
||||
选择设备
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>已选择 {planData.devices.length} 个设备</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* 功能配置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">功能配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="filter" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="filter">
|
||||
<Filter className="h-3 w-3" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="distribution">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="redpacket">
|
||||
<Gift className="h-3 w-3" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="followup">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="filter" className="mt-4">
|
||||
<AIFilterSettings
|
||||
settings={planData.aiFilter}
|
||||
onChange={(settings) => setPlanData({ ...planData, aiFilter: settings })}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="distribution" className="mt-4">
|
||||
<DistributionSettings
|
||||
settings={planData.distribution}
|
||||
onChange={(settings) => setPlanData({ ...planData, distribution: settings })}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="redpacket" className="mt-4">
|
||||
<RedPacketSettings
|
||||
settings={planData.redPacket}
|
||||
onChange={(settings) => setPlanData({ ...planData, redPacket: settings })}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="followup" className="mt-4">
|
||||
<FollowUpSettings
|
||||
settings={planData.followUp}
|
||||
onChange={(settings) => setPlanData({ ...planData, followUp: settings })}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t p-4">
|
||||
<div className="max-w-[390px] mx-auto">
|
||||
<Button className="w-full" onClick={handleSave}>
|
||||
创建计划
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导入对话框 */}
|
||||
<PhoneImportDialog
|
||||
open={showImportDialog}
|
||||
onOpenChange={setShowImportDialog}
|
||||
onImport={(numbers) => {
|
||||
setPlanData({ ...planData, phoneNumbers: [...planData.phoneNumbers, ...numbers] })
|
||||
setShowImportDialog(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 设备选择对话框 */}
|
||||
<DeviceConfigDialog
|
||||
open={showDeviceDialog}
|
||||
onOpenChange={setShowDeviceDialog}
|
||||
selectedDevices={planData.devices}
|
||||
onSelect={(devices) => {
|
||||
setPlanData({ ...planData, devices })
|
||||
setShowDeviceDialog(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -120,4 +120,3 @@ export default function PhoneAcquisitionPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
233
Cunkebao/app/scenarios/weixinqun/page.tsx
Normal file
233
Cunkebao/app/scenarios/weixinqun/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Plus, Settings, Users, MessageSquare, TrendingUp, Calendar } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
// 微信群获客计划数据
|
||||
const wechatGroupPlans = [
|
||||
{
|
||||
id: 1,
|
||||
name: "产品推广群计划",
|
||||
status: "运行中",
|
||||
groupCount: 8,
|
||||
memberCount: 1250,
|
||||
dailyMessages: 15,
|
||||
tags: ["产品分享", "优惠信息"],
|
||||
createdAt: "2024-01-15",
|
||||
lastActive: "2小时前",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "用户交流群计划",
|
||||
status: "已暂停",
|
||||
groupCount: 5,
|
||||
memberCount: 680,
|
||||
dailyMessages: 8,
|
||||
tags: ["用户交流", "答疑解惑"],
|
||||
createdAt: "2024-01-10",
|
||||
lastActive: "1天前",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "新人欢迎群计划",
|
||||
status: "运行中",
|
||||
groupCount: 12,
|
||||
memberCount: 2100,
|
||||
dailyMessages: 25,
|
||||
tags: ["新人欢迎", "群活动"],
|
||||
createdAt: "2024-01-08",
|
||||
lastActive: "30分钟前",
|
||||
},
|
||||
]
|
||||
|
||||
export default function WechatGroupPage() {
|
||||
const [plans] = useState(wechatGroupPlans)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "运行中":
|
||||
return "bg-green-100 text-green-800"
|
||||
case "已暂停":
|
||||
return "bg-yellow-100 text-yellow-800"
|
||||
case "已完成":
|
||||
return "bg-blue-100 text-blue-800"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800"
|
||||
}
|
||||
}
|
||||
|
||||
const getTagColor = (tag: string) => {
|
||||
const colors = {
|
||||
群活动: "bg-green-100 text-green-800",
|
||||
产品分享: "bg-blue-100 text-blue-800",
|
||||
用户交流: "bg-purple-100 text-purple-800",
|
||||
优惠信息: "bg-pink-100 text-pink-800",
|
||||
答疑解惑: "bg-orange-100 text-orange-800",
|
||||
新人欢迎: "bg-cyan-100 text-cyan-800",
|
||||
群规通知: "bg-indigo-100 text-indigo-800",
|
||||
活跃互动: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
return colors[tag as keyof typeof colors] || "bg-gray-100 text-gray-800"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">微信群获客</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">管理微信群获客计划,提升群活跃度</p>
|
||||
</div>
|
||||
<Link href="/plans/new?type=weixinqun">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建计划
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="px-4 py-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<Users className="w-8 h-8 text-blue-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">总群数</p>
|
||||
<p className="text-xl font-semibold">25</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<MessageSquare className="w-8 h-8 text-green-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">总成员</p>
|
||||
<p className="text-xl font-semibold">4,030</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="w-8 h-8 text-purple-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">日均消息</p>
|
||||
<p className="text-xl font-semibold">48</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-8 h-8 text-orange-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">活跃计划</p>
|
||||
<p className="text-xl font-semibold">2</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 计划列表 */}
|
||||
<div className="space-y-4">
|
||||
{plans.map((plan) => (
|
||||
<Card key={plan.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">{plan.name}</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={getStatusColor(plan.status)}>{plan.status}</Badge>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">群数量</p>
|
||||
<p className="font-semibold">{plan.groupCount} 个</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">成员数</p>
|
||||
<p className="font-semibold">{plan.memberCount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">日均消息</p>
|
||||
<p className="font-semibold">{plan.dailyMessages} 条</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">最后活跃</p>
|
||||
<p className="font-semibold">{plan.lastActive}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{plan.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className={getTagColor(tag)}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<span>创建时间:{plan.createdAt}</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
查看详情
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
编辑计划
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部导航 */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t">
|
||||
<div className="grid grid-cols-4 py-2">
|
||||
<Link href="/" className="flex flex-col items-center py-2 text-gray-600">
|
||||
<div className="w-6 h-6 mb-1">🏠</div>
|
||||
<span className="text-xs">首页</span>
|
||||
</Link>
|
||||
<Link href="/scenarios" className="flex flex-col items-center py-2 text-blue-600">
|
||||
<div className="w-6 h-6 mb-1">🎯</div>
|
||||
<span className="text-xs">场景获客</span>
|
||||
</Link>
|
||||
<Link href="/workspace" className="flex flex-col items-center py-2 text-gray-600">
|
||||
<div className="w-6 h-6 mb-1">💼</div>
|
||||
<span className="text-xs">工作台</span>
|
||||
</Link>
|
||||
<Link href="/profile" className="flex flex-col items-center py-2 text-gray-600">
|
||||
<div className="w-6 h-6 mb-1">👤</div>
|
||||
<span className="text-xs">我的</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4169
Cunkebao/pnpm-lock.yaml
generated
4169
Cunkebao/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user