plan pages

This commit is contained in:
xavier
2025-06-03 16:37:39 +08:00
parent 0d33ba7e34
commit 1f2a1dab00
62 changed files with 12508 additions and 1984 deletions

View 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>
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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"

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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="没有找到设备"
/>
)
}

View File

@@ -1,4 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -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
// 之前的逻辑是直接跳到步骤4setCurrentStep(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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,4 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -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>
)
}

View File

@@ -238,4 +238,3 @@ export default function EditAcquisitionPlan({ params }: { params: { channel: str
</div>
)
}

View File

@@ -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>namephone
<br />
<span className="font-medium"></span>sourceremarktags
</p>
</div>
<p className="text-xs text-gray-500">GET/POST请求namephone</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"
>
PythonJava等多语言示例代码
</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>
)
}

View File

@@ -1,4 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -233,4 +233,3 @@ export default function ChannelTrafficPage({
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,5 @@
import { NextResponse } from "next/server"
export async function GET(request: Request) {
return NextResponse.json({ message: "场景获客API" })
}

View File

@@ -120,4 +120,3 @@ export default function DouyinAcquisitionPage() {
</div>
)
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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>
)
}

View File

@@ -207,4 +207,3 @@ export function TrafficChannelSettings({ formData, onChange, onNext, onPrev }: T
</Card>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -1,4 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -176,4 +176,3 @@ export default function PhoneAcquiredPage() {
</div>
)
}

View File

@@ -1,4 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -130,4 +130,3 @@ export default function PhoneAddedPage() {
</div>
)
}

View File

@@ -184,4 +184,3 @@ export default function PhoneDevicesPage() {
</div>
)
}

View File

@@ -399,4 +399,3 @@ export default function EditPhoneAcquisitionPlan({ params }: { params: { id: str
</div>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -120,4 +120,3 @@ export default function PhoneAcquisitionPage() {
</div>
)
}

View 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

File diff suppressed because it is too large Load Diff