存客宝 React

This commit is contained in:
柳清爽
2025-03-29 16:50:39 +08:00
parent caea0b4b99
commit 7e7c199996
388 changed files with 53282 additions and 2076 deletions

5
.next/trace Normal file
View File

@@ -0,0 +1,5 @@
[{"name":"next-dev","duration":1133882,"timestamp":77673052994,"id":1,"tags":{},"startTime":1743230471261,"traceId":"783da342eecd516c"}]
[{"name":"next-dev","duration":1106982,"timestamp":78353056801,"id":1,"tags":{},"startTime":1743231151264,"traceId":"bdd508402794a0ab"}]
[{"name":"next-dev","duration":1216133,"timestamp":79753595153,"id":1,"tags":{},"startTime":1743232551803,"traceId":"67862971c58777c2"}]
[{"name":"next-dev","duration":1179753,"timestamp":80176478960,"id":1,"tags":{},"startTime":1743232974687,"traceId":"0e6fe535ed5bf51d"}]
[{"name":"next-dev","duration":1173589,"timestamp":80452943657,"id":1,"tags":{},"startTime":1743233251152,"traceId":"1b3e7015fce31dcf"}]

4
Cunkebao/.babelrc Normal file
View File

@@ -0,0 +1,4 @@
{
"presets": ["next/babel"]
}

29
Cunkebao/.gitignore vendored
View File

@@ -1,2 +1,27 @@
node_modules
.env
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

131
Cunkebao/api/devices.ts Normal file
View File

@@ -0,0 +1,131 @@
import type {
ApiResponse,
Device,
DeviceStats,
DeviceTaskRecord,
PaginatedResponse,
QueryDeviceParams,
CreateDeviceParams,
UpdateDeviceParams,
DeviceStatus, // Added DeviceStatus import
} from "@/types/device"
const API_BASE = "/api/devices"
// 设备管理API
export const deviceApi = {
// 创建设备
async create(params: CreateDeviceParams): Promise<ApiResponse<Device>> {
const response = await fetch(`${API_BASE}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
})
return response.json()
},
// 更新设备
async update(params: UpdateDeviceParams): Promise<ApiResponse<Device>> {
const response = await fetch(`${API_BASE}/${params.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
})
return response.json()
},
// 获取设备详情
async getById(id: string): Promise<ApiResponse<Device>> {
const response = await fetch(`${API_BASE}/${id}`)
return response.json()
},
// 查询设备列表
async query(params: QueryDeviceParams): Promise<ApiResponse<PaginatedResponse<Device>>> {
const queryString = new URLSearchParams({
...params,
tags: params.tags ? JSON.stringify(params.tags) : "",
dateRange: params.dateRange ? JSON.stringify(params.dateRange) : "",
}).toString()
const response = await fetch(`${API_BASE}?${queryString}`)
return response.json()
},
// 删除设备
async delete(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "DELETE",
})
return response.json()
},
// 重启设备
async restart(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}/restart`, {
method: "POST",
})
return response.json()
},
// 解绑设备
async unbind(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}/unbind`, {
method: "POST",
})
return response.json()
},
// 获取设备统计数据
async getStats(id: string): Promise<ApiResponse<DeviceStats>> {
const response = await fetch(`${API_BASE}/${id}/stats`)
return response.json()
},
// 获取设备任务记录
async getTaskRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<DeviceTaskRecord>>> {
const response = await fetch(`${API_BASE}/${id}/tasks?page=${page}&pageSize=${pageSize}`)
return response.json()
},
// 批量更新设备标签
async updateTags(ids: string[], tags: string[]): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/tags`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceIds: ids, tags }),
})
return response.json()
},
// 批量导出设备数据
async exportDevices(ids: string[]): Promise<Blob> {
const response = await fetch(`${API_BASE}/export`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceIds: ids }),
})
return response.blob()
},
// 检查设备在线状态
async checkStatus(ids: string[]): Promise<ApiResponse<Record<string, DeviceStatus>>> {
const response = await fetch(`${API_BASE}/status`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceIds: ids }),
})
return response.json()
},
}

74
Cunkebao/api/route.ts Normal file
View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server"
import type { CreateScenarioParams, QueryScenarioParams, ScenarioBase, ApiResponse } from "@/types/scenario"
// 获客场景路由处理
export async function POST(request: Request) {
try {
const body: CreateScenarioParams = await request.json()
// TODO: 实现创建场景的具体逻辑
const scenario: ScenarioBase = {
id: "generated-id",
...body,
status: "draft",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
creator: "current-user-id",
}
const response: ApiResponse<ScenarioBase> = {
code: 0,
message: "创建成功",
data: scenario,
}
return NextResponse.json(response)
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "创建失败",
data: null,
},
{ status: 500 },
)
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const params: QueryScenarioParams = {
type: searchParams.get("type") as any,
status: searchParams.get("status") as any,
keyword: searchParams.get("keyword") || undefined,
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
page: Number(searchParams.get("page")) || 1,
pageSize: Number(searchParams.get("pageSize")) || 20,
}
// TODO: 实现查询场景列表的具体逻辑
return NextResponse.json({
code: 0,
message: "查询成功",
data: {
items: [],
total: 0,
page: params.page,
pageSize: params.pageSize,
totalPages: 0,
},
})
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "查询失败",
data: null,
},
{ status: 500 },
)
}
}

112
Cunkebao/api/scenarios.ts Normal file
View File

@@ -0,0 +1,112 @@
import type {
ApiResponse,
CreateScenarioParams,
UpdateScenarioParams,
QueryScenarioParams,
ScenarioBase,
ScenarioStats,
AcquisitionRecord,
PaginatedResponse,
} from "@/types/scenario"
const API_BASE = "/api/scenarios"
// 获客场景API
export const scenarioApi = {
// 创建场景
async create(params: CreateScenarioParams): Promise<ApiResponse<ScenarioBase>> {
const response = await fetch(`${API_BASE}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
})
return response.json()
},
// 更新场景
async update(params: UpdateScenarioParams): Promise<ApiResponse<ScenarioBase>> {
const response = await fetch(`${API_BASE}/${params.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
})
return response.json()
},
// 获取场景详情
async getById(id: string): Promise<ApiResponse<ScenarioBase>> {
const response = await fetch(`${API_BASE}/${id}`)
return response.json()
},
// 查询场景列表
async query(params: QueryScenarioParams): Promise<ApiResponse<PaginatedResponse<ScenarioBase>>> {
const queryString = new URLSearchParams({
...params,
dateRange: params.dateRange ? JSON.stringify(params.dateRange) : "",
}).toString()
const response = await fetch(`${API_BASE}?${queryString}`)
return response.json()
},
// 删除场景
async delete(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "DELETE",
})
return response.json()
},
// 启动场景
async start(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}/start`, {
method: "POST",
})
return response.json()
},
// 暂停场景
async pause(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}/pause`, {
method: "POST",
})
return response.json()
},
// 获取场景统计数据
async getStats(id: string): Promise<ApiResponse<ScenarioStats>> {
const response = await fetch(`${API_BASE}/${id}/stats`)
return response.json()
},
// 获取获客记录
async getRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<AcquisitionRecord>>> {
const response = await fetch(`${API_BASE}/${id}/records?page=${page}&pageSize=${pageSize}`)
return response.json()
},
// 导出获客记录
async exportRecords(id: string, dateRange?: { start: string; end: string }): Promise<Blob> {
const queryString = dateRange ? `?start=${dateRange.start}&end=${dateRange.end}` : ""
const response = await fetch(`${API_BASE}/${id}/records/export${queryString}`)
return response.blob()
},
// 批量更新标签
async updateTags(id: string, customerIds: string[], tags: string[]): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}/tags`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ customerIds, tags }),
})
return response.json()
},
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server"
import type { OrderFormData } from "@/types/acquisition"
export async function POST(request: Request, { params }: { params: { planId: string } }) {
try {
const data: OrderFormData = await request.json()
// 这里应该添加实际的数据库存储逻辑
console.log("Received order:", data, "for plan:", params.planId)
// 模拟成功响应
return NextResponse.json({
success: true,
message: "订单已成功提交",
})
} catch (error) {
return NextResponse.json({ success: false, message: "订单提交失败" }, { status: 500 })
}
}

69
Cunkebao/app/api/auth.ts Normal file
View File

@@ -0,0 +1,69 @@
// API请求工具函数
import { toast } from "@/components/ui/use-toast"
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"
// 带有认证的请求函数
export async function authFetch(url: string, options: RequestInit = {}) {
const token = localStorage.getItem("token")
// 合并headers
let headers = { ...options.headers }
// 如果有token添加到请求头
if (token) {
headers = {
...headers,
Token: `${token}`,
}
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers,
})
const data = await response.json()
// 检查token是否过期仅当有token时
if (token && (data.code === 401 || data.code === 403)) {
// 清除token
localStorage.removeItem("token")
// 暂时不重定向到登录页
// if (typeof window !== "undefined") {
// window.location.href = "/login"
// }
console.warn("登录已过期")
}
return data
} catch (error) {
console.error("API请求错误:", error)
toast({
variant: "destructive",
title: "请求失败",
description: error instanceof Error ? error.message : "网络错误,请稍后重试",
})
throw error
}
}
// 不需要认证的请求函数
export async function publicFetch(url: string, options: RequestInit = {}) {
try {
const response = await fetch(`${API_BASE_URL}${url}`, options)
return await response.json()
} catch (error) {
console.error("API请求错误:", error)
toast({
variant: "destructive",
title: "请求失败",
description: error instanceof Error ? error.message : "网络错误,请稍后重试",
})
throw error
}
}

View File

@@ -0,0 +1,82 @@
import { NextResponse } from "next/server"
import type {
CreateDeviceParams,
QueryDeviceParams,
Device,
ApiResponse,
DeviceStatus,
DeviceType,
} from "@/types/device"
// 设备管理路由处理
export async function POST(request: Request) {
try {
const body: CreateDeviceParams = await request.json()
// TODO: 实现创建设备的具体逻辑
const device: Device = {
id: "generated-id",
...body,
status: DeviceStatus.OFFLINE,
lastOnlineTime: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
const response: ApiResponse<Device> = {
code: 0,
message: "创建成功",
data: device,
}
return NextResponse.json(response)
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "创建失败",
data: null,
},
{ status: 500 },
)
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const params: QueryDeviceParams = {
keyword: searchParams.get("keyword") || undefined,
status: (searchParams.get("status") as DeviceStatus) || undefined,
type: (searchParams.get("type") as DeviceType) || undefined,
tags: searchParams.get("tags") ? JSON.parse(searchParams.get("tags")!) : undefined,
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
page: Number(searchParams.get("page")) || 1,
pageSize: Number(searchParams.get("pageSize")) || 20,
}
// TODO: 实现查询设备列表的具体逻辑
return NextResponse.json({
code: 0,
message: "查询成功",
data: {
items: [],
total: 0,
page: params.page,
pageSize: params.pageSize,
totalPages: 0,
},
})
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "查询失败",
data: null,
},
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,231 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Copy, Check, Info } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { useToast } from "@/components/ui/use-toast"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getApiGuideForScenario } from "@/docs/api-guide"
import { Badge } from "@/components/ui/badge"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
export default function ApiDocPage({ params }: { params: { channel: string; id: string } }) {
const router = useRouter()
const { toast } = useToast()
const [copiedExample, setCopiedExample] = useState<string | null>(null)
const apiGuide = getApiGuideForScenario(params.id, params.channel)
const copyToClipboard = (text: string, exampleId: string) => {
navigator.clipboard.writeText(text)
setCopiedExample(exampleId)
toast({
title: "已复制代码",
description: "代码示例已复制到剪贴板",
})
setTimeout(() => {
setCopiedExample(null)
}, 2000)
}
return (
<div className="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">{apiGuide.title}</h1>
</div>
</div>
</header>
<div className="container mx-auto py-6 px-4 max-w-4xl">
<Card className="mb-6">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{apiGuide.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-500" />
<p className="text-sm text-gray-700">
使API密钥进行身份验证
</p>
</div>
<div className="rounded-md bg-amber-50 p-4 border border-amber-200">
<p className="text-sm text-amber-800">
<strong></strong>{" "}
API密钥使
</p>
</div>
</div>
</CardContent>
</Card>
<Accordion type="single" collapsible className="mb-6">
{apiGuide.endpoints.map((endpoint, index) => (
<AccordionItem key={index} value={`endpoint-${index}`}>
<AccordionTrigger className="px-4 hover:bg-gray-50">
<div className="flex items-center">
<Badge className="mr-2">{endpoint.method}</Badge>
<span className="font-mono text-sm">{endpoint.url}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pt-2">
<div className="space-y-4">
<p className="text-sm text-gray-700">{endpoint.description}</p>
<div>
<h4 className="text-sm font-medium mb-2"></h4>
<div className="bg-gray-50 rounded-md p-3">
{endpoint.headers.map((header, i) => (
<div key={i} className="flex items-start mb-2 last:mb-0">
<Badge variant="outline" className="mr-2 mt-0.5 font-mono">
{header.required ? "*" : ""}
{header.name}
</Badge>
<div>
<p className="text-sm">{header.value}</p>
<p className="text-xs text-gray-500">{header.description}</p>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-2"></h4>
<div className="bg-gray-50 rounded-md p-3">
{endpoint.parameters.map((param, i) => (
<div key={i} className="flex items-start mb-3 last:mb-0">
<Badge variant="outline" className="mr-2 mt-0.5 font-mono">
{param.required ? "*" : ""}
{param.name}
</Badge>
<div>
<p className="text-sm">
<span className="text-gray-500 font-mono">{param.type}</span>
</p>
<p className="text-xs text-gray-500">{param.description}</p>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-2"></h4>
<pre className="bg-gray-50 rounded-md p-3 text-xs overflow-auto">
{JSON.stringify(endpoint.response, null, 2)}
</pre>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue={apiGuide.examples[0].language}>
<TabsList className="mb-4">
{apiGuide.examples.map((example) => (
<TabsTrigger key={example.language} value={example.language}>
{example.title}
</TabsTrigger>
))}
</TabsList>
{apiGuide.examples.map((example) => (
<TabsContent key={example.language} value={example.language}>
<div className="relative">
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{example.code}</pre>
<Button
variant="outline"
size="sm"
className="absolute top-2 right-2"
onClick={() => copyToClipboard(example.code, example.language)}
>
{copiedExample === example.language ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
<div className="mt-8">
<h3 className="text-lg font-medium mb-4"></h3>
<Card className="mb-4">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-2 text-sm">
<li></li>
<li>"应用集成" > "外部接口"</li>
<li>"添加新接口"</li>
<li>"X-API-KEY"API密钥</li>
<li>
URL为
<code className="bg-gray-100 px-1 py-0.5 rounded">
&lt;code&gt;{apiGuide.endpoints[0].url}&lt;/code&gt;
</code>
</li>
<li>name, phone等</li>
<li></li>
</ol>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-1"></h4>
<p className="text-sm text-gray-700">
X-API-KEY正确无误
</p>
</div>
<div>
<h4 className="text-sm font-medium mb-1"></h4>
<p className="text-sm text-gray-700">
</p>
</div>
<div>
<h4 className="text-sm font-medium mb-1"></h4>
<p className="text-sm text-gray-700">
API密钥每分钟最多可发送30个请求使
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server"
import type { CreateScenarioParams, QueryScenarioParams, ScenarioBase, ApiResponse } from "@/types/scenario"
// 获客场景路由处理
export async function POST(request: Request) {
try {
const body: CreateScenarioParams = await request.json()
// TODO: 实现创建场景的具体逻辑
const scenario: ScenarioBase = {
id: "generated-id",
...body,
status: "draft",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
creator: "current-user-id",
}
const response: ApiResponse<ScenarioBase> = {
code: 0,
message: "创建成功",
data: scenario,
}
return NextResponse.json(response)
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "创建失败",
data: null,
},
{ status: 500 },
)
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const params: QueryScenarioParams = {
type: searchParams.get("type") as any,
status: searchParams.get("status") as any,
keyword: searchParams.get("keyword") || undefined,
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
page: Number(searchParams.get("page")) || 1,
pageSize: Number(searchParams.get("pageSize")) || 20,
}
// TODO: 实现查询场景列表的具体逻辑
return NextResponse.json({
code: 0,
message: "查询成功",
data: {
items: [],
total: 0,
page: params.page,
pageSize: params.pageSize,
totalPages: 0,
},
})
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "查询失败",
data: null,
},
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,273 @@
import { NextResponse } from "next/server"
import type { TrafficUser } from "@/types/traffic"
// 中文名字生成器数据
const familyNames = [
"张",
"王",
"李",
"赵",
"陈",
"刘",
"杨",
"黄",
"周",
"吴",
"朱",
"孙",
"马",
"胡",
"郭",
"林",
"何",
"高",
"梁",
"郑",
"罗",
"宋",
"谢",
"唐",
"韩",
"曹",
"许",
"邓",
"萧",
"冯",
]
const givenNames1 = [
"志",
"建",
"文",
"明",
"永",
"春",
"秀",
"金",
"水",
"玉",
"国",
"立",
"德",
"海",
"和",
"荣",
"伟",
"新",
"英",
"佳",
]
const givenNames2 = [
"华",
"平",
"军",
"强",
"辉",
"敏",
"峰",
"磊",
"超",
"艳",
"娜",
"霞",
"燕",
"娟",
"静",
"丽",
"涛",
"洋",
"勇",
"龙",
]
// 生成固定的用户数据池
const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
// 生成随机时间在过去7天内
const date = new Date()
date.setDate(date.getDate() - Math.floor(Math.random() * 7))
return {
id: `${Date.now()}-${i}`,
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
nickname: fullName,
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
region: [
"广东深圳",
"浙江杭州",
"江苏苏州",
"北京",
"上海",
"四川成都",
"湖北武汉",
"福建厦门",
"山东青岛",
"河南郑州",
][Math.floor(Math.random() * 10)],
note: [
"咨询产品价格",
"对产品很感兴趣",
"准备购买",
"需要更多信息",
"想了解优惠活动",
"询问产品规格",
"要求产品demo",
"索要产品目录",
"询问售后服务",
"要求上门演示",
][Math.floor(Math.random() * 10)],
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
addTime: date.toISOString(),
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页"][Math.floor(Math.random() * 6)],
assignedTo: "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
tags: [],
}
})
// 计算今日新增数量
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const todayUsers = userPool.filter((user) => new Date(user.addTime) >= todayStart)
// 生成微信好友数据池
const generateWechatFriends = (wechatId: string, count: number) => {
return Array.from({ length: count }, (_, i) => {
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
// 生成随机时间在过去30天内
const date = new Date()
date.setDate(date.getDate() - Math.floor(Math.random() * 30))
return {
id: `wechat-${wechatId}-${i}`,
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
nickname: fullName,
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
region: [
"广东深圳",
"浙江杭州",
"江苏苏州",
"北京",
"上海",
"四川成都",
"湖北武汉",
"福建厦门",
"山东青岛",
"河南郑州",
][Math.floor(Math.random() * 10)],
note: [
"咨询产品价格",
"对产品很感兴趣",
"准备购买",
"需要更多信息",
"想了解优惠活动",
"询问产品规格",
"要求产品demo",
"索要产品目录",
"询问售后服务",
"要求上门演示",
][Math.floor(Math.random() * 10)],
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
addTime: date.toISOString(),
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页", "微信好友"][
Math.floor(Math.random() * 7)
],
assignedTo: "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
tags: [],
}
})
}
// 微信好友数据缓存
const wechatFriendsCache = new Map<string, TrafficUser[]>()
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = Number.parseInt(searchParams.get("page") || "1")
const pageSize = Number.parseInt(searchParams.get("pageSize") || "10")
const search = searchParams.get("search") || ""
const category = searchParams.get("category") || "all"
const source = searchParams.get("source") || "all"
const status = searchParams.get("status") || "all"
const startDate = searchParams.get("startDate")
const endDate = searchParams.get("endDate")
const wechatSource = searchParams.get("wechatSource") || ""
let filteredUsers = [...userPool]
// 如果有微信来源参数,生成或获取微信好友数据
if (wechatSource) {
if (!wechatFriendsCache.has(wechatSource)) {
// 生成150-300个随机好友
const friendCount = Math.floor(Math.random() * (300 - 150)) + 150
wechatFriendsCache.set(wechatSource, generateWechatFriends(wechatSource, friendCount))
}
filteredUsers = wechatFriendsCache.get(wechatSource) || []
}
// 应用过滤条件
filteredUsers = filteredUsers.filter((user) => {
const matchesSearch = search
? user.nickname.toLowerCase().includes(search.toLowerCase()) ||
user.wechatId.toLowerCase().includes(search.toLowerCase()) ||
user.phone.includes(search)
: true
const matchesCategory = category === "all" ? true : user.category === category
const matchesSource = source === "all" ? true : user.source === source
const matchesStatus = status === "all" ? true : user.status === status
const matchesDate =
startDate && endDate
? new Date(user.addTime) >= new Date(startDate) && new Date(user.addTime) <= new Date(endDate)
: true
return matchesSearch && matchesCategory && matchesSource && matchesStatus && matchesDate
})
// 按添加时间倒序排序
filteredUsers.sort((a, b) => new Date(b.addTime).getTime() - new Date(a.addTime).getTime())
// 计算分页
const total = filteredUsers.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const end = start + pageSize
const users = filteredUsers.slice(start, end)
// 计算分类统计
const categoryStats = {
potential: userPool.filter((user) => user.category === "potential").length,
customer: userPool.filter((user) => user.category === "customer").length,
lost: userPool.filter((user) => user.category === "lost").length,
}
// 模拟网络延迟
await new Promise((resolve) => setTimeout(resolve, 500))
return NextResponse.json({
users,
pagination: {
total,
totalPages,
currentPage: page,
pageSize,
},
stats: {
total: wechatSource ? filteredUsers.length : userPool.length,
todayNew: wechatSource ? Math.floor(filteredUsers.length * 0.1) : todayUsers.length,
categoryStats,
},
})
}

View File

@@ -0,0 +1,46 @@
"use client"
import "./globals.css"
import BottomNav from "./components/BottomNav"
import "regenerator-runtime/runtime"
import type React from "react"
import ErrorBoundary from "./components/ErrorBoundary"
import { VideoTutorialButton } from "@/components/VideoTutorialButton"
import { AuthProvider } from "@/app/components/AuthProvider"
import { usePathname } from "next/navigation"
// 创建一个包装组件来使用 usePathname hook
function LayoutContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
// 只在主页路径显示底部导航栏
const showBottomNav =
pathname === "/" || pathname === "/devices" || pathname === "/content" || pathname === "/profile"
return (
<main className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col relative">
{children}
{showBottomNav && <BottomNav />}
{showBottomNav && <VideoTutorialButton />}
</main>
)
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body className="bg-gray-100">
<AuthProvider>
<ErrorBoundary>
<LayoutContent>{children}</LayoutContent>
</ErrorBoundary>
</AuthProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,54 @@
"use client"
import { useState } from "react"
import { X } from "lucide-react"
import { Button } from "./ui/button"
import { Card } from "./ui/card"
interface AIRewriteModalProps {
isOpen: boolean
onClose: () => void
originalContent: string
}
export function AIRewriteModal({ isOpen, onClose, originalContent }: AIRewriteModalProps) {
const [rewrittenContent, setRewrittenContent] = useState("")
const handleRewrite = async () => {
// 这里应该调用 AI 改写 API
// 为了演示,我们只是简单地反转字符串
setRewrittenContent(originalContent.split("").reverse().join(""))
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="w-full max-w-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">AI </h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2"></h3>
<p className="text-sm text-gray-600">{originalContent}</p>
</div>
<div>
<h3 className="font-medium mb-2"></h3>
<p className="text-sm text-gray-600">{rewrittenContent || '点击"开始改写"按钮生成内容'}</p>
</div>
</div>
<div className="mt-6 flex justify-end space-x-2">
<Button variant="outline" onClick={onClose}>
</Button>
<Button onClick={handleRewrite}></Button>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,67 @@
"use client"
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
import { useRouter } from "next/navigation"
interface AuthContextType {
isAuthenticated: boolean
token: string | null
login: (token: string) => void
logout: () => void
}
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
token: null,
login: () => {},
logout: () => {},
})
export const useAuth = () => useContext(AuthContext)
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [token, setToken] = useState<string | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const router = useRouter()
useEffect(() => {
// 客户端检查token
if (typeof window !== "undefined") {
const storedToken = localStorage.getItem("token")
if (storedToken) {
setToken(storedToken)
setIsAuthenticated(true)
} else {
setIsAuthenticated(false)
// 暂时禁用重定向逻辑,允许访问所有页面
// 将来需要恢复登录验证时,取消下面注释
/*
if (pathname !== "/login") {
router.push("/login")
}
*/
}
}
}, [])
const login = (newToken: string) => {
localStorage.setItem("token", newToken)
setToken(newToken)
setIsAuthenticated(true)
}
const logout = () => {
localStorage.removeItem("token")
setToken(null)
setIsAuthenticated(false)
// 登出后不强制跳转到登录页
// router.push("/login")
}
return <AuthContext.Provider value={{ isAuthenticated, token, login, logout }}>{children}</AuthContext.Provider>
}

View File

@@ -0,0 +1,32 @@
"use client"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { QrCode } from "lucide-react"
export function BindDouyinQRCode() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button variant="ghost" size="icon" onClick={() => setIsOpen(true)}>
<QrCode className="h-4 w-4" />
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center p-4">
<div className="w-64 h-64 bg-gray-100 rounded-lg flex items-center justify-center">
<img src="/placeholder.svg?height=256&width=256" alt="抖音二维码" className="w-full h-full" />
</div>
<p className="mt-4 text-sm text-gray-600">使APP扫描二维码进行绑定</p>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,36 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, Users, User, Briefcase } from "lucide-react"
const navItems = [
{ href: "/", icon: Home, label: "首页" },
{ href: "/scenarios", icon: Users, label: "场景获客" },
{ href: "/workspace", icon: Briefcase, label: "工作台" },
{ href: "/profile", icon: User, label: "我的" },
]
export default function BottomNav() {
const pathname = usePathname()
return (
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200">
<div className="max-w-md mx-auto flex justify-around">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center py-2 px-3 ${
pathname === item.href ? "text-blue-500" : "text-gray-500"
}`}
>
<item.icon className="w-6 h-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import {
LineChart as RechartsLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts"
import { BarChart as RechartsBarChart, Bar } from "recharts"
const lineData = [
{ name: "周一", 新增微信号: 12 },
{ name: "周二", 新增微信号: 19 },
{ name: "周三", 新增微信号: 3 },
{ name: "周四", 新增微信号: 5 },
{ name: "周五", 新增微信号: 2 },
{ name: "周六", 新增微信号: 3 },
{ name: "周日", 新增微信号: 10 },
]
const barData = [
{ name: "周一", 新增好友: 120 },
{ name: "周二", 新增好友: 190 },
{ name: "周三", 新增好友: 30 },
{ name: "周四", 新增好友: 50 },
{ name: "周五", 新增好友: 20 },
{ name: "周六", 新增好友: 30 },
{ name: "周日", 新增好友: 100 },
]
export function LineChart() {
return (
<ResponsiveContainer width="100%" height={180}>
<RechartsLineChart data={lineData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="新增微信号" stroke="#8884d8" />
</RechartsLineChart>
</ResponsiveContainer>
)
}
export function BarChart() {
return (
<ResponsiveContainer width="100%" height={180}>
<RechartsBarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="新增好友" fill="#82ca9d" />
</RechartsBarChart>
</ResponsiveContainer>
)
}
export function TrendChart({ data, dataKey = "value", height = 300 }) {
return (
<div className="w-full" style={{ height: `${height}px` }}>
<ResponsiveContainer width="100%" height="100%">
<RechartsLineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey={dataKey}
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 4, fill: "#3b82f6" }}
activeDot={{ r: 6, fill: "#3b82f6" }}
isAnimationActive={true}
/>
</RechartsLineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { Avatar } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
interface ChatMessageProps {
content: string
isUser: boolean
timestamp: Date
avatar?: string
}
export function ChatMessage({ content, isUser, timestamp, avatar }: ChatMessageProps) {
return (
<div className={cn("flex w-full gap-3 p-4", isUser ? "justify-end" : "justify-start")}>
{!isUser && (
<Avatar className="h-8 w-8">
<div className="bg-primary text-primary-foreground flex h-full w-full items-center justify-center rounded-full text-sm font-semibold">
AI
</div>
</Avatar>
)}
<div className={cn("rounded-lg p-3 max-w-[80%]", isUser ? "bg-primary text-primary-foreground" : "bg-muted")}>
<p className="whitespace-pre-wrap">{content}</p>
<div className={cn("text-xs mt-1", isUser ? "text-primary-foreground/70" : "text-muted-foreground")}>
{timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
{isUser && avatar && (
<Avatar className="h-8 w-8">
<img src={avatar || "/placeholder.svg"} alt="User" className="rounded-full" />
</Avatar>
)}
</div>
)
}

View File

@@ -0,0 +1,106 @@
"use client"
import { useState } from "react"
import { Card } from "../ui/card"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { ChevronLeft, Search, Plus } from "lucide-react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"
interface ContentLibrary {
id: string
name: string
type: string
count: number
}
const mockLibraries: ContentLibrary[] = [
{
id: "1",
name: "卡若朋友圈",
type: "朋友圈",
count: 307,
},
{
id: "2",
name: "业务推广内容",
type: "朋友圈",
count: 156,
},
]
export function ContentSelector({ onPrev, onFinish }) {
const [selectedLibraries, setSelectedLibraries] = useState<string[]>([])
return (
<Card className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold"></h2>
<Button>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="flex items-center space-x-4 mb-6">
<div className="flex-1">
<Input placeholder="搜索内容库" className="w-full" prefix={<Search className="w-4 h-4 text-gray-400" />} />
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockLibraries.map((library) => (
<TableRow key={library.id}>
<TableCell>
<input
type="checkbox"
checked={selectedLibraries.includes(library.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedLibraries([...selectedLibraries, library.id])
} else {
setSelectedLibraries(selectedLibraries.filter((id) => id !== library.id))
}
}}
/>
</TableCell>
<TableCell>{library.name}</TableCell>
<TableCell>{library.type}</TableCell>
<TableCell>{library.count}</TableCell>
<TableCell>
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between mt-8">
<Button variant="outline" onClick={onPrev}>
<ChevronLeft className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={onFinish}
disabled={selectedLibraries.length === 0}
className="bg-green-500 hover:bg-green-600"
>
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { Card } from "../ui/card"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"
import { ChevronLeft, ChevronRight, Search, Plus } from "lucide-react"
interface Device {
id: string
imei: string
status: "online" | "offline"
friendStatus: string
}
const mockDevices: Device[] = [
{
id: "1",
imei: "123456789012345",
status: "online",
friendStatus: "正常",
},
{
id: "2",
imei: "987654321098765",
status: "offline",
friendStatus: "异常",
},
]
export function DeviceSelector({ onNext, onPrev }) {
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
return (
<Card className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold"></h2>
<Button>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="flex items-center space-x-4 mb-6">
<div className="flex-1">
<Input
placeholder="搜索设备IMEI/备注/手机号"
className="w-full"
prefix={<Search className="w-4 h-4 text-gray-400" />}
/>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>IMEI//</TableHead>
<TableHead>线</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockDevices.map((device) => (
<TableRow key={device.id}>
<TableCell>
<input
type="checkbox"
checked={selectedDevices.includes(device.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedDevices([...selectedDevices, device.id])
} else {
setSelectedDevices(selectedDevices.filter((id) => id !== device.id))
}
}}
/>
</TableCell>
<TableCell>{device.imei}</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</span>
</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
device.friendStatus === "正常" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{device.friendStatus}
</span>
</TableCell>
<TableCell>
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between mt-8">
<Button variant="outline" onClick={onPrev}>
<ChevronLeft className="w-4 h-4 mr-2" />
</Button>
<Button onClick={onNext} disabled={selectedDevices.length === 0}>
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import { Card } from "../ui/card"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Switch } from "../ui/switch"
import { Label } from "../ui/label"
import { ChevronLeft, ChevronRight, Plus, Minus } from "lucide-react"
interface TaskSetupProps {
onNext?: () => void
onPrev?: () => void
step: number
}
export function TaskSetup({ onNext, onPrev, step }: TaskSetupProps) {
const [syncCount, setSyncCount] = useState(5)
const [startTime, setStartTime] = useState("06:00")
const [endTime, setEndTime] = useState("23:59")
const [isEnabled, setIsEnabled] = useState(true)
const [accountType, setAccountType] = useState("business") // business or personal
return (
<Card className="p-6 max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-semibold"></h2>
<div className="flex items-center space-x-2">
<Label htmlFor="task-enabled"></Label>
<Switch id="task-enabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
</div>
</div>
<div className="space-y-6">
<div className="grid gap-4">
<Label></Label>
<Input placeholder="请输入任务名称" />
</div>
<div className="grid gap-4">
<Label></Label>
<div className="flex items-center space-x-2">
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
<span></span>
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
</div>
</div>
<div className="grid gap-4">
<Label></Label>
<div className="flex items-center space-x-4">
<Button variant="outline" size="icon" onClick={() => setSyncCount(Math.max(1, syncCount - 1))}>
<Minus className="h-4 w-4" />
</Button>
<span className="w-12 text-center">{syncCount}</span>
<Button variant="outline" size="icon" onClick={() => setSyncCount(syncCount + 1)}>
<Plus className="h-4 w-4" />
</Button>
<span className="text-gray-500"></span>
</div>
</div>
<div className="grid gap-4">
<Label></Label>
<div className="flex space-x-4">
<Button
variant={accountType === "business" ? "default" : "outline"}
onClick={() => setAccountType("business")}
className="w-24"
>
</Button>
<Button
variant={accountType === "personal" ? "default" : "outline"}
onClick={() => setAccountType("personal")}
className="w-24"
>
</Button>
</div>
</div>
</div>
<div className="flex justify-between mt-8">
{step > 1 ? (
<Button variant="outline" onClick={onPrev}>
<ChevronLeft className="w-4 h-4 mr-2" />
</Button>
) : (
<div />
)}
<Button onClick={onNext}>
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,253 @@
"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 { Filter, Search, RefreshCw, AlertCircle } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "@/components/ui/use-toast"
import { Progress } from "@/components/ui/progress"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
interface WechatAccount {
wechatId: string
nickname: string
remainingAdds: number
maxDailyAdds: number
todayAdded: number
}
interface Device {
id: string
imei: string
name: string
status: "online" | "offline"
wechatAccounts: WechatAccount[]
usedInPlans: number
}
interface DeviceSelectorProps {
onSelect: (selectedDevices: string[]) => void
initialSelectedDevices?: string[]
excludeUsedDevices?: boolean
}
export function DeviceSelector({
onSelect,
initialSelectedDevices = [],
excludeUsedDevices = true,
}: DeviceSelectorProps) {
const [devices, setDevices] = useState<Device[]>([])
const [selectedDevices, setSelectedDevices] = useState<string[]>(initialSelectedDevices)
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [currentPage, setCurrentPage] = useState(1)
const devicesPerPage = 10
useEffect(() => {
// 模拟获取设备数据
const fetchDevices = async () => {
await new Promise((resolve) => setTimeout(resolve, 500))
const mockDevices = Array.from({ length: 42 }, (_, i) => ({
id: `device-${i + 1}`,
imei: `IMEI-${Math.random().toString(36).substr(2, 9)}`,
name: `设备 ${i + 1}`,
status: Math.random() > 0.3 ? "online" : "offline",
wechatAccounts: Array.from({ length: Math.floor(Math.random() * 2) + 1 }, (_, j) => ({
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
nickname: `微信号 ${j + 1}`,
remainingAdds: Math.floor(Math.random() * 10) + 5,
maxDailyAdds: 20,
todayAdded: Math.floor(Math.random() * 15),
})),
usedInPlans: Math.floor(Math.random() * 3),
}))
setDevices(mockDevices)
}
fetchDevices()
}, [])
const handleRefresh = () => {
toast({
title: "刷新成功",
description: "设备列表已更新",
})
}
const filteredDevices = devices.filter((device) => {
const matchesSearch =
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.wechatAccounts.some((account) => account.wechatId.toLowerCase().includes(searchQuery.toLowerCase()))
const matchesStatus = statusFilter === "all" || device.status === statusFilter
const matchesUsage = !excludeUsedDevices || device.usedInPlans === 0
return matchesSearch && matchesStatus && matchesUsage
})
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
const handleSelectAll = () => {
if (selectedDevices.length === paginatedDevices.length) {
setSelectedDevices([])
} else {
setSelectedDevices(paginatedDevices.map((device) => device.id))
}
onSelect(selectedDevices)
}
const handleDeviceSelect = (deviceId: string) => {
const updatedSelection = selectedDevices.includes(deviceId)
? selectedDevices.filter((id) => id !== deviceId)
: [...selectedDevices, deviceId]
setSelectedDevices(updatedSelection)
onSelect(updatedSelection)
}
return (
<div className="space-y-4">
<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">
<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>
<Button variant="outline" onClick={handleSelectAll}>
{selectedDevices.length === paginatedDevices.length ? "取消全选" : "全选"}
</Button>
</div>
<div className="space-y-2">
{paginatedDevices.map((device) => (
<Card key={device.id} className="p-3 hover:shadow-md transition-shadow">
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedDevices.includes(device.id)}
onCheckedChange={() => 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>
<div
className={`px-2 py-1 rounded-full text-xs ${
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
<div className="mt-2 space-y-2">
{device.wechatAccounts.map((account) => (
<div key={account.wechatId} className="bg-gray-50 rounded-lg p-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{account.nickname}</span>
<span className="text-gray-500">{account.wechatId}</span>
</div>
<div className="mt-1 space-y-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-1">
<span></span>
<span className="font-medium">{account.remainingAdds}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertCircle className="h-4 w-4 text-gray-400" />
</TooltipTrigger>
<TooltipContent>
<p> {account.maxDailyAdds} </p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<span className="text-sm text-gray-500">
{account.todayAdded}/{account.maxDailyAdds}
</span>
</div>
<Progress value={(account.todayAdded / account.maxDailyAdds) * 100} className="h-1.5" />
</div>
</div>
))}
</div>
{!excludeUsedDevices && device.usedInPlans > 0 && (
<div className="text-sm text-orange-500 mt-2"> {device.usedInPlans} </div>
)}
</div>
</div>
</Card>
))}
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage((prev) => Math.max(1, prev - 1))
}}
/>
</PaginationItem>
{Array.from({ length: Math.ceil(filteredDevices.length / devicesPerPage) }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
isActive={currentPage === page}
onClick={(e) => {
e.preventDefault()
setCurrentPage(page)
}}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}

View File

@@ -0,0 +1,47 @@
"use client"
import React, { type ErrorInfo } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface ErrorBoundaryProps {
children: React.ReactNode
}
interface ErrorBoundaryState {
hasError: boolean
error?: Error
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<Card className="p-6 max-w-md mx-auto mt-8">
<h2 className="text-2xl font-bold mb-4 text-red-600"></h2>
<p className="text-gray-600 mb-4"></p>
<p className="text-sm text-gray-500 mb-4">{this.state.error?.message}</p>
<Button onClick={() => this.setState({ hasError: false })}></Button>
</Card>
)
}
return this.props.children
}
}
export default ErrorBoundary

View File

@@ -0,0 +1,150 @@
"use client"
import type React from "react"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Upload, X } from "lucide-react"
import { Progress } from "@/components/ui/progress"
interface FileUploaderProps {
onFileUploaded: (file: File) => void
acceptedTypes?: string
maxSize?: number // in MB
}
export function FileUploader({
onFileUploaded,
acceptedTypes = ".pdf,.doc,.docx,.jpg,.jpeg,.png",
maxSize = 10, // 10MB default
}: FileUploaderProps) {
const [dragActive, setDragActive] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [uploadProgress, setUploadProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true)
} else if (e.type === "dragleave") {
setDragActive(false)
}
}
const validateFile = (file: File): boolean => {
// 检查文件类型
const fileType = file.name.split(".").pop()?.toLowerCase() || ""
const isValidType = acceptedTypes.includes(fileType)
// 检查文件大小
const isValidSize = file.size <= maxSize * 1024 * 1024
if (!isValidType) {
setError(`不支持的文件类型。请上传 ${acceptedTypes} 格式的文件。`)
return false
}
if (!isValidSize) {
setError(`文件过大。最大支持 ${maxSize}MB。`)
return false
}
return true
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0]
handleFile(file)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
handleFile(file)
}
}
const handleFile = (file: File) => {
setError(null)
if (validateFile(file)) {
setSelectedFile(file)
simulateUpload(file)
}
}
const simulateUpload = (file: File) => {
// 模拟上传进度
setUploadProgress(0)
const interval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 100) {
clearInterval(interval)
onFileUploaded(file)
return 100
}
return prev + 10
})
}, 200)
}
const handleButtonClick = () => {
inputRef.current?.click()
}
const cancelUpload = () => {
setSelectedFile(null)
setUploadProgress(0)
setError(null)
}
return (
<div className="w-full">
{!selectedFile ? (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center ${
dragActive ? "border-blue-500 bg-blue-50" : "border-gray-300"
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input ref={inputRef} type="file" className="hidden" onChange={handleChange} accept={acceptedTypes} />
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm font-medium text-gray-700"></p>
<Button variant="outline" onClick={handleButtonClick} className="mt-2">
</Button>
<p className="mt-1 text-xs text-gray-500">
{acceptedTypes.replace(/\./g, "")} {maxSize}MB
</p>
</div>
) : (
<div className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<div className="font-medium truncate">{selectedFile.name}</div>
<Button variant="ghost" size="icon" onClick={cancelUpload} className="h-6 w-6 min-w-6">
<X className="h-4 w-4" />
</Button>
</div>
<Progress value={uploadProgress} className="h-2" />
<div className="text-xs text-right mt-1 text-gray-500">{uploadProgress}%</div>
</div>
)}
{error && <div className="mt-2 text-sm text-red-500">{error}</div>}
</div>
)
}

View File

@@ -0,0 +1,23 @@
"use client"
import { usePathname } from "next/navigation"
import BottomNav from "./BottomNav"
import { VideoTutorialButton } from "@/components/VideoTutorialButton"
import type React from "react"
export default function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
// 只在四个主页显示底部导航栏:首页、场景获客、工作台和我的
const mainPages = ["/", "/scenarios", "/workspace", "/profile"]
const showBottomNav = mainPages.includes(pathname)
return (
<main className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col relative">
{children}
{showBottomNav && <BottomNav />}
{showBottomNav && <VideoTutorialButton />}
</main>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { useState, useEffect } from "react"
import { toast } from "@/components/ui/use-toast"
interface SpeechToTextProcessorProps {
audioUrl: string
onTranscriptReady: (transcript: string) => void
onQuestionExtracted: (question: string) => void
enabled: boolean
}
export function SpeechToTextProcessor({
audioUrl,
onTranscriptReady,
onQuestionExtracted,
enabled = true,
}: SpeechToTextProcessorProps) {
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || !audioUrl) return
const processAudio = async () => {
try {
setIsProcessing(true)
setError(null)
// 模拟API调用延迟
await new Promise((resolve) => setTimeout(resolve, 2000))
// 模拟转录结果
const mockTranscript = `
客服: 您好这里是XX公司客服请问有什么可以帮到您
客户: 请问贵公司的产品有什么特点?
客服: 我们的产品主要有以下几个特点:首先,质量非常可靠;其次,价格比较有竞争力;第三,售后服务非常完善。
客户: 那你们的价格是怎么样的?
客服: 我们有多种套餐可以选择基础版每月只需99元高级版每月299元具体可以根据您的需求来选择。
客户: 好的,我了解了,谢谢。
客服: 不客气,如果您有兴趣,我可以添加您的微信,给您发送更详细的产品资料。
客户: 可以的,谢谢。
客服: 好的,稍后我会添加您为好友,再次感谢您的咨询。
`
onTranscriptReady(mockTranscript)
// 提取首句问题
const questionMatch = mockTranscript.match(/客户: (.*?)\n/)
if (questionMatch && questionMatch[1]) {
onQuestionExtracted(questionMatch[1])
} else {
onQuestionExtracted("未识别到有效问题")
}
setIsProcessing(false)
} catch (err) {
setError("处理音频时出错")
setIsProcessing(false)
toast({
title: "处理失败",
description: "语音转文字处理失败,请重试",
variant: "destructive",
})
}
}
processAudio()
}, [audioUrl, enabled, onTranscriptReady, onQuestionExtracted])
return null // 这是一个功能性组件不渲染任何UI
}

View File

@@ -0,0 +1,177 @@
"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 { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Plus, Pencil, Trash2 } from "lucide-react"
interface TrafficTeam {
id: string
name: string
commission: number
}
interface TrafficTeamSettingsProps {
formData?: any
onChange: (data: any) => void
}
export function TrafficTeamSettings({ formData = {}, onChange }: TrafficTeamSettingsProps) {
// Initialize teams with an empty array if formData.trafficTeams is undefined
const [teams, setTeams] = useState<TrafficTeam[]>([])
const [isAddTeamOpen, setIsAddTeamOpen] = useState(false)
const [editingTeam, setEditingTeam] = useState<TrafficTeam | null>(null)
const [newTeam, setNewTeam] = useState<Partial<TrafficTeam>>({
name: "",
commission: 0,
})
// Initialize teams state safely
useEffect(() => {
if (formData && Array.isArray(formData.trafficTeams)) {
setTeams(formData.trafficTeams)
} else {
// If formData.trafficTeams is undefined or not an array, initialize with empty array
// Also update the parent formData to include the empty trafficTeams array
setTeams([])
onChange({ ...formData, trafficTeams: [] })
}
}, [formData])
const handleAddTeam = () => {
if (!newTeam.name) return
const updatedTeams = [...teams]
if (editingTeam) {
const index = updatedTeams.findIndex((team) => team.id === editingTeam.id)
if (index !== -1) {
updatedTeams[index] = {
...updatedTeams[index],
name: newTeam.name || updatedTeams[index].name,
commission: newTeam.commission !== undefined ? newTeam.commission : updatedTeams[index].commission,
}
}
} else {
updatedTeams.push({
id: Date.now().toString(),
name: newTeam.name,
commission: newTeam.commission || 0,
})
}
setTeams(updatedTeams)
setIsAddTeamOpen(false)
setNewTeam({ name: "", commission: 0 })
setEditingTeam(null)
// Ensure we're creating a new object for formData to trigger proper updates
const updatedFormData = { ...(formData || {}), trafficTeams: updatedTeams }
onChange(updatedFormData)
}
const handleEditTeam = (team: TrafficTeam) => {
setEditingTeam(team)
setNewTeam(team)
setIsAddTeamOpen(true)
}
const handleDeleteTeam = (teamId: string) => {
const updatedTeams = teams.filter((team) => team.id !== teamId)
setTeams(updatedTeams)
// Ensure we're creating a new object for formData to trigger proper updates
const updatedFormData = { ...(formData || {}), trafficTeams: updatedTeams }
onChange(updatedFormData)
}
return (
<Card className="p-6">
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold"></h2>
<Button onClick={() => setIsAddTeamOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teams.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-gray-500">
</TableCell>
</TableRow>
) : (
teams.map((team) => (
<TableRow key={team.id}>
<TableCell>{team.name}</TableCell>
<TableCell>{team.commission}%</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm" onClick={() => handleEditTeam(team)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteTeam(team.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<Dialog open={isAddTeamOpen} onOpenChange={setIsAddTeamOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTeam ? "编辑团队" : "添加团队"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label></Label>
<Input
value={newTeam.name}
onChange={(e) => setNewTeam({ ...newTeam, name: e.target.value })}
placeholder="请输入团队名称"
/>
</div>
<div className="space-y-2">
<Label> (%)</Label>
<Input
type="number"
value={newTeam.commission}
onChange={(e) => setNewTeam({ ...newTeam, commission: Number(e.target.value) })}
placeholder="请输入佣金比例"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddTeamOpen(false)}>
</Button>
<Button onClick={handleAddTeam}>{editingTeam ? "保存" : "添加"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -0,0 +1,63 @@
"use client"
import { useEffect, useState } from "react"
interface VoiceRecognitionProps {
onResult: (text: string) => void
onStop: () => void
}
export function VoiceRecognition({ onResult, onStop }: VoiceRecognitionProps) {
const [isListening, setIsListening] = useState(true)
useEffect(() => {
// 模拟语音识别
const timer = setTimeout(() => {
const mockResults = [
"你好,我想了解一下私域运营的基本策略",
"请帮我分析一下最近的销售数据",
"我需要一份客户画像分析报告",
"如何提高朋友圈内容的互动率?",
"帮我生成一个营销方案",
]
const randomResult = mockResults[Math.floor(Math.random() * mockResults.length)]
onResult(randomResult)
setIsListening(false)
}, 2000)
return () => {
clearTimeout(timer)
}
}, [onResult])
useEffect(() => {
if (!isListening) {
onStop()
}
}, [isListening, onStop])
return (
<div className="fixed inset-0 bg-black/20 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full">
<div className="flex flex-col items-center">
<div className="relative w-20 h-20 mb-4">
<div className="absolute inset-0 bg-blue-100 rounded-full animate-ping opacity-25"></div>
<div className="relative bg-blue-500 rounded-full w-20 h-20 flex items-center justify-center">
<div className="w-4 h-16 bg-white rounded-full animate-pulse"></div>
</div>
</div>
<h3 className="text-lg font-medium mb-2">...</h3>
<p className="text-gray-500 text-center"></p>
<button
className="mt-4 px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 transition-colors"
onClick={onStop}
>
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
"use client"
import { useState, useEffect } from "react"
import { ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid, Area, AreaChart, Legend } from "recharts"
interface AcquisitionPlanChartProps {
data: { date: string; customers: number }[]
}
export function AcquisitionPlanChart({ data }: AcquisitionPlanChartProps) {
const [chartData, setChartData] = useState<any[]>([])
// 生成更真实的数据
useEffect(() => {
if (!data || data.length === 0) return
// 使用获客计划中的获客数和添加数作为指标模拟近7天数据
const enhancedData = data.map((item) => {
// 添加数通常是获客数的一定比例这里使用70%-90%的随机比例
const addRate = 0.7 + Math.random() * 0.2
const addedCount = Math.round(item.customers * addRate)
return {
date: item.date,
获客数: item.customers,
添加数: addedCount,
}
})
setChartData(enhancedData)
}, [data])
// 如果没有数据,显示空状态
if (!data || data.length === 0 || chartData.length === 0) {
return <div className="h-[180px] flex items-center justify-center text-gray-400"></div>
}
return (
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="colorCustomers" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="colorAdded" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} width={30} />
<Tooltip
contentStyle={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: "6px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
border: "none",
padding: "8px",
}}
labelStyle={{ fontWeight: "bold", marginBottom: "4px" }}
/>
<Legend
verticalAlign="top"
height={36}
iconType="circle"
iconSize={8}
formatter={(value) => <span style={{ color: "#6b7280", fontSize: "12px" }}>{value}</span>}
/>
<Area
type="monotone"
dataKey="获客数"
name="获客数"
stroke="#3b82f6"
fillOpacity={1}
fill="url(#colorCustomers)"
strokeWidth={2}
dot={{ r: 3, strokeWidth: 2 }}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
<Area
type="monotone"
dataKey="添加数"
name="添加数"
stroke="#8b5cf6"
fillOpacity={0.5}
fill="url(#colorAdded)"
strokeWidth={2}
dot={{ r: 3, strokeWidth: 2 }}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,55 @@
"use client"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
interface DailyAcquisitionData {
date: string
acquired: number
added: number
}
interface DailyAcquisitionChartProps {
data: DailyAcquisitionData[]
height?: number
}
export function DailyAcquisitionChart({ data, height = 200 }: DailyAcquisitionChartProps) {
return (
<div className="w-full" style={{ height: `${height}px` }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" stroke="#666" />
<YAxis stroke="#666" />
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "6px",
}}
/>
<Legend />
<Line
type="monotone"
dataKey="acquired"
name="获客数量"
stroke="#3b82f6"
strokeWidth={2}
dot={{ fill: "#3b82f6" }}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="added"
name="添加成功"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: "#10b981" }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,75 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from "recharts"
// 模拟数据
const weeklyData = [
{ day: "周一", 获客数: 12, 添加好友: 8 },
{ day: "周二", 获客数: 18, 添加好友: 12 },
{ day: "周三", 获客数: 15, 添加好友: 10 },
{ day: "周四", 获客数: 25, 添加好友: 18 },
{ day: "周五", 获客数: 30, 添加好友: 22 },
{ day: "周六", 获客数: 18, 添加好友: 14 },
{ day: "周日", 获客数: 15, 添加好友: 11 },
]
const monthlyData = [
{ day: "1月", 获客数: 120, 添加好友: 85 },
{ day: "2月", 获客数: 180, 添加好友: 130 },
{ day: "3月", 获客数: 150, 添加好友: 110 },
{ day: "4月", 获客数: 250, 添加好友: 180 },
{ day: "5月", 获客数: 300, 添加好友: 220 },
{ day: "6月", 获客数: 280, 添加好友: 210 },
]
export function DeviceTreeChart() {
const [period, setPeriod] = useState("week")
const data = period === "week" ? weeklyData : monthlyData
return (
<Card className="w-full mt-4">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium"></CardTitle>
<Tabs defaultValue="week" value={period} onValueChange={setPeriod} className="h-9">
<TabsList className="grid w-[180px] grid-cols-2">
<TabsTrigger value="week"></TabsTrigger>
<TabsTrigger value="month"></TabsTrigger>
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
<Tooltip />
<Line
type="monotone"
dataKey="获客数"
stroke="#4f46e5"
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="添加好友"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,205 @@
"use client"
import { useState } from "react"
import { ChevronDown, ChevronUp, MoreVertical, Copy, Pencil, Trash2, Clock, Link } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from "recharts"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { DailyAcquisitionChart } from "./DailyAcquisitionChart"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
interface Task {
id: string
name: string
status: "running" | "paused" | "completed"
stats: {
devices: number
acquired: number
added: number
}
lastUpdated: string
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
dailyData?: { date: string; acquired: number; added: number }[]
}
interface ExpandableAcquisitionCardProps {
task: Task
channel: string
onCopy: (taskId: string) => void
onDelete: (taskId: string) => void
onOpenSettings?: (taskId: string) => void
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
}
export function ExpandableAcquisitionCard({
task,
channel,
onCopy,
onDelete,
onOpenSettings,
onStatusChange,
}: ExpandableAcquisitionCardProps) {
const router = useRouter()
const [expanded, setExpanded] = useState(false)
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
const passRate = calculatePassRate(acquiredCount, addedCount)
const handleEdit = (taskId: string) => {
router.push(`/scenarios/${channel}/edit/${taskId}`)
}
const toggleTaskStatus = () => {
if (onStatusChange) {
onStatusChange(task.id, task.status === "running" ? "paused" : "running")
}
}
return (
<div className="mb-6">
<Card className="p-6 hover:shadow-lg transition-all bg-white/80 backdrop-blur-sm">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Badge
variant={task.status === "running" ? "success" : "secondary"}
className="cursor-pointer hover:opacity-80"
onClick={toggleTaskStatus}
>
{task.status === "running" ? "进行中" : "已暂停"}
</Badge>
</div>
<div className="relative z-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => handleEdit(task.id)}>
<Pencil className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onCopy(task.id)}>
<Copy className="w-4 h-4 mr-2" />
</DropdownMenuItem>
{onOpenSettings && (
<DropdownMenuItem onClick={() => onOpenSettings(task.id)}>
<Link className="w-4 h-4 mr-2" />
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onDelete(task.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-4">
<a href={`/scenarios/${channel}/devices`}>
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{deviceCount}</div>
</Card>
</a>
<a href={`/scenarios/${channel}/acquired`}>
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{acquiredCount}</div>
</Card>
</a>
<a href={`/scenarios/${channel}/added`}>
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{addedCount}</div>
</Card>
</a>
<Card className="p-2">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{passRate}%</div>
</Card>
</div>
<div className="h-48 bg-white rounded-lg p-4 mb-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={task.trend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" stroke="#666" />
<YAxis stroke="#666" />
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "6px",
}}
/>
<Line
type="monotone"
dataKey="customers"
name="获客数"
stroke="#3b82f6"
strokeWidth={2}
dot={{ fill: "#3b82f6" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.lastUpdated}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.nextExecutionTime}</span>
</div>
</div>
</Card>
{expanded && task.dailyData && (
<div className="mt-4 bg-white p-6 rounded-lg shadow-sm">
<h4 className="text-lg font-medium mb-4"></h4>
<div className="h-64">
<DailyAcquisitionChart data={task.dailyData} />
</div>
</div>
)}
<div className="flex justify-center mt-2">
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)} className="text-gray-500">
{expanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
</>
)}
</Button>
</div>
</div>
)
}
// 计算通过率
function calculatePassRate(acquired: number, added: number) {
if (acquired === 0) return 0
return Math.round((added / acquired) * 100)
}

View File

@@ -0,0 +1,97 @@
"use client"
import type React from "react"
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 { TrafficTeamSettings } from "@/app/components/TrafficTeamSettings"
interface NewAcquisitionPlanFormProps {
onSubmit: (data: any) => void
onCancel: () => void
}
export function NewAcquisitionPlanForm({ onSubmit, onCancel }: NewAcquisitionPlanFormProps) {
const [formData, setFormData] = useState({
name: "",
description: "",
trafficTeams: [], // Initialize trafficTeams as an empty array
})
const [currentStep, setCurrentStep] = useState(1)
const handleChange = (data: any) => {
setFormData(data)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit(formData)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="steps-container">
{/* 步骤指示器 */}
<div className="step-indicators flex justify-between mb-8">
{[1, 2, 3, 4].map((step) => (
<div
key={step}
className={`flex flex-col items-center ${currentStep >= step ? "text-primary" : "text-gray-400"}`}
>
<div
className={`flex items-center justify-center w-8 h-8 rounded-full mb-2 ${
currentStep >= step ? "bg-primary text-white" : "bg-gray-100 text-gray-400"
}`}
>
{step}
</div>
<div className="text-sm font-medium">
{step === 1 && "基础设置"}
{step === 2 && "好友设置"}
{step === 3 && "消息设置"}
{step === 4 && "流量标签"}
</div>
</div>
))}
</div>
</div>
<Card className="p-6">
<div className="space-y-4">
<h2 className="text-lg font-semibold"></h2>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="输入计划名称"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="输入计划描述"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
</div>
</Card>
<TrafficTeamSettings formData={formData} onChange={handleChange} />
<div className="flex justify-end space-x-4">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<Button type="submit"></Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,202 @@
"use client"
import { Card } from "@/components/ui/card"
import type React from "react"
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from "lucide-react"
interface Task {
id: string
name: string
status: "running" | "paused" | "completed"
stats: {
devices: number
acquired: number
added: number
}
lastUpdated: string
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
}
interface ScenarioAcquisitionCardProps {
task: Task
channel: string
onEdit: (taskId: string) => void
onCopy: (taskId: string) => void
onDelete: (taskId: string) => void
onOpenSettings?: (taskId: string) => void
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
}
export function ScenarioAcquisitionCard({
task,
channel,
onEdit,
onCopy,
onDelete,
onOpenSettings,
onStatusChange,
}: ScenarioAcquisitionCardProps) {
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
const passRate = calculatePassRate(acquiredCount, addedCount)
const [menuOpen, setMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const handleStatusChange = (e: React.MouseEvent) => {
e.stopPropagation()
if (onStatusChange) {
onStatusChange(task.id, task.status === "running" ? "paused" : "running")
}
}
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(false)
onEdit(task.id)
}
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(false)
onCopy(task.id)
}
const handleOpenSettings = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(false)
if (onOpenSettings) {
onOpenSettings(task.id)
}
}
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(false)
onDelete(task.id)
}
const toggleMenu = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(!menuOpen)
}
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
return (
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Badge
variant={task.status === "running" ? "success" : "secondary"}
className="cursor-pointer hover:opacity-80"
onClick={handleStatusChange}
>
{task.status === "running" ? "进行中" : "已暂停"}
</Badge>
</div>
<div className="relative z-20" ref={menuRef}>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
<MoreHorizontal className="h-4 w-4" />
</Button>
{menuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleEdit}
>
<Pencil className="w-4 h-4 mr-2" />
</button>
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleCopy}
>
<Copy className="w-4 h-4 mr-2" />
</button>
{onOpenSettings && (
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleOpenSettings}
>
<Link className="w-4 h-4 mr-2" />
</button>
)}
<button
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
onClick={handleDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
</button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-4">
<a href={`/scenarios/${channel}/devices`} className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{deviceCount}</div>
</Card>
</a>
<a href={`/scenarios/${channel}/acquired`} className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{acquiredCount}</div>
</Card>
</a>
<a href={`/scenarios/${channel}/added`} className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{addedCount}</div>
</Card>
</a>
<Card className="p-2">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{passRate}%</div>
</Card>
</div>
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.lastUpdated}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.nextExecutionTime}</span>
</div>
</div>
</Card>
)
}
// 计算通过率
function calculatePassRate(acquired: number, added: number) {
if (acquired === 0) return 0
return Math.round((added / acquired) * 100)
}

View File

@@ -0,0 +1,207 @@
"use client"
import { useState } from "react"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Battery, Smartphone, MessageCircle, Users, Clock } from "lucide-react"
export interface Device {
id: string
imei: string
name: string
status: "online" | "offline"
battery: number
wechatId: string
friendCount: number
todayAdded: number
messageCount: number
lastActive: string
addFriendStatus: "normal" | "abnormal"
}
interface DeviceGridProps {
devices: Device[]
selectable?: boolean
selectedDevices?: string[]
onSelect?: (deviceIds: string[]) => void
itemsPerRow?: number
}
export function DeviceGrid({
devices,
selectable = false,
selectedDevices = [],
onSelect,
itemsPerRow = 2,
}: DeviceGridProps) {
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
const handleSelectAll = () => {
if (selectedDevices.length === devices.length) {
onSelect?.([])
} else {
onSelect?.(devices.map((d) => d.id))
}
}
return (
<div className="space-y-4">
{selectable && (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
checked={selectedDevices.length === devices.length && devices.length > 0}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm"></span>
</div>
<span className="text-sm text-gray-500"> {selectedDevices.length} </span>
</div>
)}
<div className={`grid grid-cols-${itemsPerRow} gap-4`}>
{devices.map((device) => (
<Card
key={device.id}
className={`p-4 hover:shadow-md transition-all cursor-pointer ${
selectedDevices.includes(device.id) ? "ring-2 ring-primary" : ""
}`}
onClick={() => {
if (selectable) {
const newSelection = selectedDevices.includes(device.id)
? selectedDevices.filter((id) => id !== device.id)
: [...selectedDevices, device.id]
onSelect?.(newSelection)
} else {
setSelectedDevice(device)
}
}}
>
<div className="flex items-start space-x-3">
{selectable && (
<Checkbox
checked={selectedDevices.includes(device.id)}
className="mt-1"
onClick={(e) => e.stopPropagation()}
/>
)}
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="font-medium">{device.name}</div>
<Badge variant={device.status === "online" ? "success" : "secondary"}>
{device.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
<span>{device.battery}%</span>
</div>
<div className="flex items-center space-x-1">
<Users className="w-4 h-4" />
<span>{device.friendCount}</span>
</div>
<div className="flex items-center space-x-1">
<MessageCircle className="w-4 h-4" />
<span>{device.messageCount}</span>
</div>
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>+{device.todayAdded}</span>
</div>
</div>
<div className="text-sm text-gray-500">
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
<Badge variant={device.addFriendStatus === "normal" ? "outline" : "destructive"} className="mt-2">
{device.addFriendStatus === "normal" ? "加友正常" : "加友异常"}
</Badge>
</div>
</div>
</Card>
))}
</div>
<Dialog open={!!selectedDevice} onOpenChange={() => setSelectedDevice(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedDevice && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-3 bg-gray-100 rounded-lg">
<Smartphone className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium">{selectedDevice.name}</h3>
<p className="text-sm text-gray-500">IMEI: {selectedDevice.imei}</p>
</div>
</div>
<Badge variant={selectedDevice.status === "online" ? "success" : "secondary"}>
{selectedDevice.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Battery className={`w-5 h-5 ${selectedDevice.battery < 20 ? "text-red-500" : "text-green-500"}`} />
<span className="font-medium">{selectedDevice.battery}%</span>
</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Users className="w-5 h-5 text-blue-500" />
<span className="font-medium">{selectedDevice.friendCount}</span>
</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Users className="w-5 h-5 text-green-500" />
<span className="font-medium">+{selectedDevice.todayAdded}</span>
</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<MessageCircle className="w-5 h-5 text-purple-500" />
<span className="font-medium">{selectedDevice.messageCount}</span>
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{selectedDevice.wechatId}</div>
</div>
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{selectedDevice.lastActive}</div>
</div>
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<Badge variant={selectedDevice.addFriendStatus === "normal" ? "outline" : "destructive"}>
{selectedDevice.addFriendStatus === "normal" ? "加友正常" : "加友异常"}
</Badge>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,341 @@
"use client"
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, Filter, RefreshCw } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface WechatAccount {
wechatId: string
nickname: string
remainingAdds: number
maxDailyAdds: number
todayAdded: number
}
interface Device {
id: string
imei: string
name: string
status: "online" | "offline"
wechatAccounts: WechatAccount[]
usedInPlans: number
tags?: string[]
}
interface DeviceSelectionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedDevices: string[]
onSelect: (deviceIds: string[]) => void
excludeUsedDevices?: boolean
}
export function DeviceSelectionDialog({
open,
onOpenChange,
selectedDevices,
onSelect,
excludeUsedDevices = false,
}: DeviceSelectionDialogProps) {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [tagFilter, setTagFilter] = useState("all")
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([])
const [activeTab, setActiveTab] = useState("all")
// 初始化已选设备
useEffect(() => {
if (open) {
setSelectedDeviceIds(selectedDevices)
}
}, [open, selectedDevices])
// 模拟获取设备数据
useEffect(() => {
if (!open) return
const fetchDevices = async () => {
setLoading(true)
try {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 800))
// 生成模拟数据
const deviceTags = ["高性能", "稳定", "新设备", "已配置", "测试中", "备用"]
const mockDevices: Device[] = Array.from({ length: 30 }, (_, i) => {
// 随机生成1-3个标签
const tags = Array.from(
{ length: Math.floor(Math.random() * 3) + 1 },
() => deviceTags[Math.floor(Math.random() * deviceTags.length)],
)
// 确保标签唯一
const uniqueTags = Array.from(new Set(tags))
return {
id: `device-${i + 1}`,
imei: `IMEI-${Math.random().toString(36).substr(2, 9)}`,
name: `设备 ${i + 1}`,
status: Math.random() > 0.3 ? "online" : "offline",
wechatAccounts: Array.from({ length: Math.floor(Math.random() * 2) + 1 }, (_, j) => ({
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
nickname: `微信号 ${j + 1}`,
remainingAdds: Math.floor(Math.random() * 10) + 5,
maxDailyAdds: 20,
todayAdded: Math.floor(Math.random() * 15),
})),
usedInPlans: Math.floor(Math.random() * 3),
tags: uniqueTags,
}
})
setDevices(mockDevices)
} catch (error) {
console.error("Failed to fetch devices:", error)
} finally {
setLoading(false)
}
}
fetchDevices()
}, [open])
// 过滤设备
const filteredDevices = devices.filter((device) => {
const matchesSearch =
searchQuery === "" ||
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.wechatAccounts.some(
(account) =>
account.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
account.nickname.toLowerCase().includes(searchQuery.toLowerCase()),
)
const matchesStatus = statusFilter === "all" || device.status === statusFilter
const matchesUsage = !excludeUsedDevices || device.usedInPlans === 0
const matchesTag = tagFilter === "all" || (device.tags && device.tags.includes(tagFilter))
const matchesTab =
activeTab === "all" ||
(activeTab === "online" && device.status === "online") ||
(activeTab === "offline" && device.status === "offline") ||
(activeTab === "unused" && device.usedInPlans === 0)
return matchesSearch && matchesStatus && matchesUsage && matchesTag && matchesTab
})
// 处理选择设备
const handleSelectDevice = (deviceId: string) => {
setSelectedDeviceIds((prev) =>
prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId],
)
}
// 处理全选
const handleSelectAll = () => {
if (selectedDeviceIds.length === filteredDevices.length) {
setSelectedDeviceIds([])
} else {
setSelectedDeviceIds(filteredDevices.map((device) => device.id))
}
}
// 处理确认选择
const handleConfirm = () => {
onSelect(selectedDeviceIds)
onOpenChange(false)
}
// 获取所有标签选项
const allTags = Array.from(new Set(devices.flatMap((device) => device.tags || [])))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{/* 搜索和筛选区域 */}
<div className="space-y-4 mb-4">
<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">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* 分类标签页 */}
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="online">线</TabsTrigger>
<TabsTrigger value="offline">线</TabsTrigger>
<TabsTrigger value="unused">使</TabsTrigger>
</TabsList>
</Tabs>
{/* 筛选器 */}
<div className="flex space-x-2">
<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>
{allTags.length > 0 && (
<Select value={tagFilter} onValueChange={setTagFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="标签" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{allTags.map((tag) => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="outline" className="ml-auto" onClick={handleSelectAll}>
{selectedDeviceIds.length === filteredDevices.length && filteredDevices.length > 0
? "取消全选"
: "全选"}
</Button>
</div>
</div>
{/* 设备列表 */}
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : filteredDevices.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchQuery || statusFilter !== "all" || tagFilter !== "all" || activeTab !== "all"
? "没有符合条件的设备"
: "暂无设备数据"}
</div>
) : (
<div className="space-y-2 pr-4">
{filteredDevices.map((device) => (
<Card
key={device.id}
className={`p-3 hover:shadow-md transition-shadow ${
selectedDeviceIds.includes(device.id) ? "border-primary" : ""
}`}
>
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedDeviceIds.includes(device.id)}
onCheckedChange={() => handleSelectDevice(device.id)}
id={`device-${device.id}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<label htmlFor={`device-${device.id}`} className="font-medium truncate cursor-pointer">
{device.name}
</label>
<div
className={`px-2 py-1 rounded-full text-xs ${
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
{/* 微信账号信息 */}
<div className="mt-2 space-y-2">
{device.wechatAccounts.map((account) => (
<div key={account.wechatId} className="bg-gray-50 rounded-lg p-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{account.nickname}</span>
<span className="text-gray-500">{account.wechatId}</span>
</div>
<div className="mt-1">
<div className="flex items-center justify-between text-sm">
<span>{account.remainingAdds}</span>
<span className="text-sm text-gray-500">
{account.todayAdded}/{account.maxDailyAdds}
</span>
</div>
</div>
</div>
))}
</div>
{/* 标签展示 */}
{device.tags && device.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{device.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{device.usedInPlans > 0 && (
<div className="text-sm text-orange-500 mt-2"> {device.usedInPlans} </div>
)}
</div>
</div>
</Card>
))}
</div>
)}
</ScrollArea>
</div>
<DialogFooter className="flex items-center justify-between pt-4 border-t">
<div className="text-sm">
<span className="font-medium text-primary">{selectedDeviceIds.length}</span>
</div>
<div className="space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm}></Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Check } from "lucide-react"
interface PosterTemplate {
id: string
title: string
type: "领取" | "了解"
imageUrl: string
}
interface PosterSelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (template: PosterTemplate) => void
}
const templates: PosterTemplate[] = [
{
id: "1",
title: "点击领取",
type: "领取",
imageUrl: "/placeholder.svg?height=400&width=300",
},
{
id: "2",
title: "点击了解",
type: "了解",
imageUrl: "/placeholder.svg?height=400&width=300",
},
// ... 其他模板
]
export function PosterSelector({ open, onOpenChange, onSelect }: PosterSelectorProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div>
<h3 className="text-sm text-gray-500 mb-4">使</h3>
<div className="grid grid-cols-3 gap-6">
{templates.map((template) => (
<div
key={template.id}
className="group relative cursor-pointer"
onClick={() => {
onSelect(template)
onOpenChange(false)
}}
>
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-gray-100">
<img
src={template.imageUrl || "/placeholder.svg"}
alt={template.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 bg-black/50 group-hover:opacity-100 transition-opacity">
<Check className="w-8 h-8 text-white" />
</div>
<div className="mt-2 text-center">
<div className="font-medium">{template.title}</div>
<div className="text-sm text-gray-500">{template.type}</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button></Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,324 @@
"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 { Search, Filter } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface UserTag {
id: string
name: string
color: string
}
interface TrafficUser {
id: string
avatar: string
nickname: string
wechatId: string
phone: string
region: string
note: string
status: "pending" | "added" | "failed"
addTime: string
source: string
assignedTo: string
category: "potential" | "customer" | "lost"
tags: UserTag[]
}
interface TrafficPoolSelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedUsers: TrafficUser[]
onSelect: (users: TrafficUser[]) => void
}
export function TrafficPoolSelector({ open, onOpenChange, selectedUsers, onSelect }: TrafficPoolSelectorProps) {
const [users, setUsers] = useState<TrafficUser[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [activeCategory, setActiveCategory] = useState("all")
const [sourceFilter, setSourceFilter] = useState("all")
const [tagFilter, setTagFilter] = useState("all")
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([])
// 初始化已选用户
useEffect(() => {
if (open) {
setSelectedUserIds(selectedUsers.map((user) => user.id))
}
}, [open, selectedUsers])
// 模拟获取用户数据
useEffect(() => {
if (!open) return
const fetchUsers = async () => {
setLoading(true)
try {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 800))
// 生成模拟数据
const mockUsers: TrafficUser[] = Array.from({ length: 30 }, (_, i) => {
// 随机标签
const tagPool = [
{ id: "tag1", name: "潜在客户", color: "bg-blue-100 text-blue-800" },
{ id: "tag2", name: "高意向", color: "bg-green-100 text-green-800" },
{ id: "tag3", name: "已成交", color: "bg-purple-100 text-purple-800" },
{ id: "tag4", name: "需跟进", color: "bg-yellow-100 text-yellow-800" },
{ id: "tag5", name: "活跃用户", color: "bg-indigo-100 text-indigo-800" },
{ id: "tag6", name: "沉默用户", color: "bg-gray-100 text-gray-800" },
{ id: "tag7", name: "企业客户", color: "bg-red-100 text-red-800" },
{ id: "tag8", name: "个人用户", color: "bg-pink-100 text-pink-800" },
]
const randomTags = Array.from(
{ length: Math.floor(Math.random() * 3) + 1 },
() => tagPool[Math.floor(Math.random() * tagPool.length)],
)
// 确保标签唯一
const uniqueTags = randomTags.filter((tag, index, self) => index === self.findIndex((t) => t.id === tag.id))
const sources = ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号"]
const statuses = ["pending", "added", "failed"] as const
return {
id: `user-${i + 1}`,
avatar: `/placeholder.svg?height=40&width=40`,
nickname: `用户${i + 1}`,
wechatId: `wxid_${Math.random().toString(36).substring(2, 10)}`,
phone: `1${Math.floor(Math.random() * 9 + 1)}${Array(9)
.fill(0)
.map(() => Math.floor(Math.random() * 10))
.join("")}`,
region: ["北京", "上海", "广州", "深圳", "杭州"][Math.floor(Math.random() * 5)],
note: Math.random() > 0.7 ? `这是用户${i + 1}的备注信息` : "",
status: statuses[Math.floor(Math.random() * 3)],
addTime: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toISOString(),
source: sources[Math.floor(Math.random() * sources.length)],
assignedTo: Math.random() > 0.5 ? `销售${Math.floor(Math.random() * 5) + 1}` : "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as
| "potential"
| "customer"
| "lost",
tags: uniqueTags,
}
})
setUsers(mockUsers)
} catch (error) {
console.error("Failed to fetch users:", error)
} finally {
setLoading(false)
}
}
fetchUsers()
}, [open])
// 过滤用户
const filteredUsers = users.filter((user) => {
const matchesSearch =
searchQuery === "" ||
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.phone.includes(searchQuery)
const matchesCategory = activeCategory === "all" || user.category === activeCategory
const matchesSource = sourceFilter === "all" || user.source === sourceFilter
const matchesTag = tagFilter === "all" || user.tags.some((tag) => tag.id === tagFilter)
return matchesSearch && matchesCategory && matchesSource && matchesTag
})
// 处理选择用户
const handleSelectUser = (userId: string) => {
setSelectedUserIds((prev) => (prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]))
}
// 处理全选
const handleSelectAll = () => {
if (selectedUserIds.length === filteredUsers.length) {
setSelectedUserIds([])
} else {
setSelectedUserIds(filteredUsers.map((user) => user.id))
}
}
// 处理确认选择
const handleConfirm = () => {
const selectedUsersList = users.filter((user) => selectedUserIds.includes(user.id))
onSelect(selectedUsersList)
onOpenChange(false)
}
// 获取所有标签选项
const allTags = Array.from(new Set(users.flatMap((user) => user.tags).map((tag) => JSON.stringify(tag)))).map(
(tag) => JSON.parse(tag) as UserTag,
)
// 获取所有来源选项
const allSources = Array.from(new Set(users.map((user) => user.source)))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{/* 搜索和筛选区域 */}
<div className="space-y-4 mb-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索用户名/微信号/手机号"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</div>
{/* 分类标签页 */}
<Tabs defaultValue="all" value={activeCategory} onValueChange={setActiveCategory}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="potential"></TabsTrigger>
<TabsTrigger value="customer"></TabsTrigger>
<TabsTrigger value="lost"></TabsTrigger>
</TabsList>
</Tabs>
{/* 筛选器 */}
<div className="flex space-x-2">
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{allSources.map((source) => (
<SelectItem key={source} value={source}>
{source}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tagFilter} onValueChange={setTagFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="标签" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{allTags.map((tag) => (
<SelectItem key={tag.id} value={tag.id}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" className="ml-auto" onClick={handleSelectAll}>
{selectedUserIds.length === filteredUsers.length && filteredUsers.length > 0 ? "取消全选" : "全选"}
</Button>
</div>
</div>
{/* 用户列表 */}
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchQuery || activeCategory !== "all" || sourceFilter !== "all" || tagFilter !== "all"
? "没有符合条件的用户"
: "暂无用户数据"}
</div>
) : (
<div className="space-y-2 pr-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className={`p-3 hover:shadow-md transition-shadow ${
selectedUserIds.includes(user.id) ? "border-primary" : ""
}`}
>
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedUserIds.includes(user.id)}
onCheckedChange={() => handleSelectUser(user.id)}
id={`user-${user.id}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<label htmlFor={`user-${user.id}`} className="font-medium truncate cursor-pointer">
{user.nickname}
</label>
<div
className={`px-2 py-1 rounded-full text-xs ${
user.status === "added"
? "bg-green-100 text-green-800"
: user.status === "pending"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{user.status === "added" ? "已添加" : user.status === "pending" ? "待处理" : "已失败"}
</div>
</div>
<div className="text-sm text-gray-500">: {user.wechatId}</div>
<div className="text-sm text-gray-500">: {user.source}</div>
{/* 标签展示 */}
<div className="flex flex-wrap gap-1 mt-2">
{user.tags.map((tag) => (
<span key={tag.id} className={`text-xs px-2 py-0.5 rounded-full ${tag.color}`}>
{tag.name}
</span>
))}
</div>
</div>
</div>
</Card>
))}
</div>
)}
</ScrollArea>
</div>
<DialogFooter className="flex items-center justify-between pt-4 border-t">
<div className="text-sm">
<span className="font-medium text-primary">{selectedUserIds.length}</span>
</div>
<div className="space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm}></Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className="pb-4 pt-0">{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,41 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,30 @@
import type * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, children, ...props }, ref) => {
// If asChild is true and the first child is a valid element, clone it with the button props
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
className: cn(buttonVariants({ variant, size, className })),
ref,
...props,
...children.props,
})
}
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props}>
{children}
</button>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,55 @@
"use client"
import type * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "text-muted-foreground opacity-50",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,44 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,12 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,98 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,182 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,82 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { type ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
)
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@@ -0,0 +1,7 @@
"use client"
import * as PopoverPrimitive from "@radix-ui/react-popover"
const Popover = PopoverPrimitive.Root
const PopoverTrigger

View File

@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./dialog"
import { Button } from "./button"
import { Eye } from "lucide-react"
interface PreviewDialogProps {
children: React.ReactNode
title?: string
}
export function PreviewDialog({ children, title = "预览效果" }: PreviewDialogProps) {
const [open, setOpen] = React.useState(false)
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Eye className="w-4 h-4 mr-2" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[360px] p-0">
<DialogHeader className="p-4 border-b">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="relative bg-gray-50">
<div className="w-full overflow-hidden">{children}</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,39 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,41 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,104 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator }

View File

@@ -0,0 +1,9 @@
import type React from "react"
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
}
export { Skeleton }

View File

@@ -0,0 +1,68 @@
import React from "react"
import { cn } from "@/app/lib/utils"
interface StepsProps {
currentStep: number
className?: string
children: React.ReactNode
}
interface StepProps {
title: string
description?: string
}
export function Steps({ currentStep, className, children }: StepsProps) {
const steps = React.Children.toArray(children)
return (
<div className={cn("flex items-center", className)}>
{steps.map((step, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
return (
<React.Fragment key={index}>
<div className="flex items-center">
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium",
isActive && "bg-blue-500 text-white",
isCompleted && "bg-green-500 text-white",
!isActive && !isCompleted && "bg-gray-200 text-gray-500",
)}
>
{isCompleted ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
index + 1
)}
</div>
<div className="ml-3">{step}</div>
</div>
{index < steps.length - 1 && (
<div className={cn("flex-1 h-0.5 mx-4", index < currentStep ? "bg-green-500" : "bg-gray-200")} />
)}
</React.Fragment>
)
})}
</div>
)
}
export function Step({ title, description }: StepProps) {
return (
<div>
<div className="text-sm font-medium">{title}</div>
{description && <div className="text-xs text-gray-500">{description}</div>}
</div>
)
}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,73 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
)
Table.displayName = "Table"
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
)
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
),
)
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("bg-primary font-medium text-primary-foreground", className)} {...props} />
),
)
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
{...props}
/>
),
)
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
),
)
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
),
)
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
),
)
TableCaption.displayName = "TableCaption"
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }

View File

@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,112 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,108 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface TooltipProps {
children: React.ReactNode
content: React.ReactNode
className?: string
delayDuration?: number
side?: "top" | "right" | "bottom" | "left"
}
const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
({ children, content, className, delayDuration = 200, side = "top" }, ref) => {
const [isVisible, setIsVisible] = React.useState(false)
const [position, setPosition] = React.useState({ top: 0, left: 0 })
const tooltipRef = React.useRef<HTMLDivElement>(null)
const timeoutRef = React.useRef<NodeJS.Timeout>()
const handleMouseEnter = (e: React.MouseEvent) => {
timeoutRef.current = setTimeout(() => {
const rect = (e.target as HTMLElement).getBoundingClientRect()
const tooltipRect = tooltipRef.current?.getBoundingClientRect()
if (tooltipRect) {
let top = 0
let left = 0
switch (side) {
case "top":
top = rect.top - tooltipRect.height - 8
left = rect.left + (rect.width - tooltipRect.width) / 2
break
case "bottom":
top = rect.bottom + 8
left = rect.left + (rect.width - tooltipRect.width) / 2
break
case "left":
top = rect.top + (rect.height - tooltipRect.height) / 2
left = rect.left - tooltipRect.width - 8
break
case "right":
top = rect.top + (rect.height - tooltipRect.height) / 2
left = rect.right + 8
break
}
setPosition({ top, left })
setIsVisible(true)
}
}, delayDuration)
}
const handleMouseLeave = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setIsVisible(false)
}
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
return (
<div className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={ref}>
{children}
{isVisible && (
<div
ref={tooltipRef}
className={cn(
"fixed z-50 px-2 py-1 text-xs text-primary-foreground bg-primary rounded-md shadow-sm scale-90 animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
style={{
top: position.top,
left: position.left,
transition: "opacity 150ms ease-in-out, transform 150ms ease-in-out",
}}
>
{content}
</div>
)}
</div>
)
},
)
Tooltip.displayName = "Tooltip"
// 为了保持 API 兼容性,我们导出相同的组件名称
export const TooltipProvider = ({ children }: { children: React.ReactNode }) => children
export const TooltipTrigger = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((props, ref) => (
<div ref={ref} {...props} />
))
TooltipTrigger.displayName = "TooltipTrigger"
export const TooltipContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((props, ref) => (
<div ref={ref} {...props} />
))
TooltipContent.displayName = "TooltipContent"
export { Tooltip }

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
}
case "DISMISS_TOAST": {
const { toastId } = action
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -0,0 +1,131 @@
"use client"
import type React from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, Plus, X } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { toast } from "@/components/ui/use-toast"
export default function NewMaterialPage({ params }: { params: { id: string } }) {
const router = useRouter()
const [content, setContent] = useState("")
const [newTag, setNewTag] = useState("")
const [tags, setTags] = useState<string[]>([])
const handleAddTag = () => {
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag])
setNewTag("")
}
}
const handleRemoveTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!content) {
toast({
title: "错误",
description: "请输入素材内容",
variant: "destructive",
})
return
}
try {
// 模拟保存新素材
await new Promise((resolve) => setTimeout(resolve, 1000))
toast({
title: "成功",
description: "新素材已创建",
})
router.push(`/content/${params.id}/materials`)
} catch (error) {
console.error("Failed to create new material:", error)
toast({
title: "错误",
description: "创建新素材失败",
variant: "destructive",
})
}
}
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-lg font-medium"></h1>
</div>
</div>
</header>
<div className="p-4">
<Card className="p-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="content"></Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="请输入素材内容"
className="mt-1"
rows={5}
/>
</div>
<div>
<Label htmlFor="tags"></Label>
<div className="flex items-center mt-1">
<Input
id="tags"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="输入标签"
className="flex-1"
/>
<Button type="button" onClick={handleAddTag} className="ml-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{tags.map((tag, index) => (
<Badge key={index} variant="secondary" className="flex items-center">
{tag}
<Button
type="button"
variant="ghost"
size="sm"
className="h-4 w-4 ml-1 p-0"
onClick={() => handleRemoveTag(tag)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
</div>
<Button type="submit" className="w-full">
</Button>
</form>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
"use client"
import { useState, useEffect } from "react"
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
interface Material {
id: string
content: string
tags: string[]
aiAnalysis?: string
}
export default function MaterialsPage({ params }: { params: { id: string } }) {
const router = useRouter()
const [materials, setMaterials] = useState<Material[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null)
useEffect(() => {
const fetchMaterials = async () => {
setIsLoading(true)
try {
// 模拟从API获取素材数据
await new Promise((resolve) => setTimeout(resolve, 500))
const mockMaterials: Material[] = [
{
id: "1",
content: "今天的阳光真好,适合出去走走",
tags: ["日常", "心情"],
},
{
id: "2",
content: "新品上市,限时优惠,快来抢购!",
tags: ["营销", "促销"],
},
{
id: "3",
content: "学习新技能的第一天,感觉很充实",
tags: ["学习", "成长"],
},
]
setMaterials(mockMaterials)
} catch (error) {
console.error("Failed to fetch materials:", error)
toast({
title: "错误",
description: "获取素材数据失败",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
fetchMaterials()
}, [])
const handleDownload = () => {
// 实现下载功能
toast({
title: "下载开始",
description: "正在将素材导出为Excel格式",
})
}
const handleNewMaterial = () => {
// 实现新建素材功能
router.push(`/content/${params.id}/materials/new`)
}
const handleAIAnalysis = async (material: Material) => {
try {
// 模拟AI分析过程
await new Promise((resolve) => setTimeout(resolve, 1000))
const analysis = "这是一条" + material.tags.join("、") + "相关的内容,情感倾向积极。"
setMaterials(materials.map((m) => (m.id === material.id ? { ...m, aiAnalysis: analysis } : m)))
setSelectedMaterial({ ...material, aiAnalysis: analysis })
} catch (error) {
console.error("AI analysis failed:", error)
toast({
title: "错误",
description: "AI分析失败",
variant: "destructive",
})
}
}
const filteredMaterials = materials.filter(
(material) =>
material.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
material.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase())),
)
if (isLoading) {
return <div className="flex justify-center items-center h-screen">...</div>
}
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-lg font-medium"></h1>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
Excel
</Button>
<Button onClick={handleNewMaterial}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</header>
<div className="p-4">
<Card className="p-4">
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索素材或标签..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-2">
{filteredMaterials.map((material) => (
<div key={material.id} className="flex items-center justify-between bg-white p-3 rounded-lg shadow">
<div className="flex-1">
<p className="text-sm text-gray-600 mb-2">{material.content}</p>
<div className="flex flex-wrap gap-2">
{material.tags.map((tag, index) => (
<Badge key={index} variant="secondary">
<Tag className="h-3 w-3 mr-1" />
{tag}
</Badge>
))}
</div>
</div>
<div className="flex items-center space-x-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={() => handleAIAnalysis(material)}>
<BarChart className="h-4 w-4 mr-1" />
AI分析
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>AI </DialogTitle>
</DialogHeader>
<div className="mt-4">
<p>{selectedMaterial?.aiAnalysis || "正在分析中..."}</p>
</div>
</DialogContent>
</Dialog>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,290 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, Save } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { DateRangePicker } from "@/components/ui/date-range-picker"
import { WechatFriendSelector } from "@/components/WechatFriendSelector"
import { WechatGroupSelector } from "@/components/WechatGroupSelector"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { toast } from "@/components/ui/use-toast"
interface ContentLibrary {
id: string
name: string
sourceType: "friends" | "groups"
keywordsInclude: string
keywordsExclude: string
startDate: string
endDate: string
selectedFriends: any[]
selectedGroups: any[]
useAI: boolean
aiPrompt: string
enabled: boolean
}
export default function ContentLibraryPage({ params }: { params: { id: string } }) {
const router = useRouter()
const [library, setLibrary] = useState<ContentLibrary | null>(null)
const [isWechatFriendSelectorOpen, setIsWechatFriendSelectorOpen] = useState(false)
const [isWechatGroupSelectorOpen, setIsWechatGroupSelectorOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const fetchLibrary = async () => {
setIsLoading(true)
try {
// 模拟从API获取内容库数据
await new Promise((resolve) => setTimeout(resolve, 500))
const data = {
id: params.id,
name: "示例内容库",
sourceType: "friends",
keywordsInclude: "关键词1,关键词2",
keywordsExclude: "排除词1,排除词2",
startDate: "2024-01-01",
endDate: "2024-12-31",
selectedFriends: [
{ id: "1", nickname: "张三", avatar: "/placeholder.svg?height=40&width=40" },
{ id: "2", nickname: "李四", avatar: "/placeholder.svg?height=40&width=40" },
],
selectedGroups: [],
useAI: true,
aiPrompt: "AI提示词示例",
enabled: true,
}
setLibrary(data)
} catch (error) {
console.error("Failed to fetch library data:", error)
toast({
title: "错误",
description: "获取内容库数据失败",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
fetchLibrary()
}, [params.id])
const handleSave = async () => {
if (!library) return
try {
// 模拟保存到API
await new Promise((resolve) => setTimeout(resolve, 500))
toast({
title: "成功",
description: "内容库已保存",
})
// 这里应该调用一个函数来更新外部展示的数据
// updateExternalDisplay(library)
} catch (error) {
console.error("Failed to save library:", error)
toast({
title: "错误",
description: "保存内容库失败",
variant: "destructive",
})
}
}
if (isLoading) {
return <div className="flex justify-center items-center h-screen">...</div>
}
if (!library) {
return <div className="flex justify-center items-center h-screen"></div>
}
return (
<div className="flex-1 bg-gray-50 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}>
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
<Card className="p-4">
<div className="space-y-4">
<div>
<Label htmlFor="name" className="text-base required">
</Label>
<Input
id="name"
value={library.name}
onChange={(e) => setLibrary({ ...library, name: e.target.value })}
placeholder="请输入内容库名称"
required
className="mt-1.5"
/>
</div>
<div>
<Label className="text-base"></Label>
<Tabs
value={library.sourceType}
onValueChange={(value: "friends" | "groups") => setLibrary({ ...library, sourceType: value })}
className="mt-1.5"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="friends"></TabsTrigger>
<TabsTrigger value="groups"></TabsTrigger>
</TabsList>
<TabsContent value="friends" className="mt-4">
<Button variant="outline" className="w-full" onClick={() => setIsWechatFriendSelectorOpen(true)}>
</Button>
{library.selectedFriends.length > 0 && (
<div className="mt-2 space-y-2">
{library.selectedFriends.map((friend) => (
<div key={friend.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
<span>{friend.nickname}</span>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="groups" className="mt-4">
<Button variant="outline" className="w-full" onClick={() => setIsWechatGroupSelectorOpen(true)}>
</Button>
{library.selectedGroups.length > 0 && (
<div className="mt-2 space-y-2">
{library.selectedGroups.map((group) => (
<div key={group.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
<span>{group.name}</span>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="keywords">
<AccordionTrigger></AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div>
<Label htmlFor="keywordsInclude" className="text-base">
</Label>
<Textarea
id="keywordsInclude"
value={library.keywordsInclude}
onChange={(e) => setLibrary({ ...library, keywordsInclude: e.target.value })}
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
className="mt-1.5 min-h-[100px]"
/>
</div>
<div>
<Label htmlFor="keywordsExclude" className="text-base">
</Label>
<Textarea
id="keywordsExclude"
value={library.keywordsExclude}
onChange={(e) => setLibrary({ ...library, keywordsExclude: e.target.value })}
placeholder="如果设置了关键字,匹配到关键字的,系统将不会采集。多个关键字,用半角的','隔开。"
className="mt-1.5 min-h-[100px]"
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex items-center justify-between">
<div>
<Label className="text-base">AI</Label>
<p className="text-sm text-gray-500 mt-1">
AI之后AI重新生成内容
</p>
</div>
<Switch
checked={library.useAI}
onCheckedChange={(checked) => setLibrary({ ...library, useAI: checked })}
/>
</div>
{library.useAI && (
<div>
<Label htmlFor="aiPrompt" className="text-base">
AI
</Label>
<Textarea
id="aiPrompt"
value={library.aiPrompt}
onChange={(e) => setLibrary({ ...library, aiPrompt: e.target.value })}
placeholder="请输入 AI 提示词"
className="mt-1.5 min-h-[100px]"
/>
</div>
)}
<div>
<Label className="text-base"></Label>
<DateRangePicker
className="mt-1.5"
onChange={(range) => {
if (range?.from) {
setLibrary({
...library,
startDate: range.from.toISOString(),
endDate: range.to?.toISOString() || "",
})
}
}}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-base required"></Label>
<Switch
checked={library.enabled}
onCheckedChange={(checked) => setLibrary({ ...library, enabled: checked })}
/>
</div>
</div>
</Card>
</div>
<WechatFriendSelector
open={isWechatFriendSelectorOpen}
onOpenChange={setIsWechatFriendSelectorOpen}
selectedFriends={library.selectedFriends}
onSelect={(friends) => setLibrary({ ...library, selectedFriends: friends })}
/>
<WechatGroupSelector
open={isWechatGroupSelectorOpen}
onOpenChange={setIsWechatGroupSelectorOpen}
selectedGroups={library.selectedGroups}
onSelect={(groups) => setLibrary({ ...library, selectedGroups: groups })}
/>
</div>
)
}

View File

@@ -0,0 +1,117 @@
"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 { Checkbox } from "@/components/ui/checkbox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Search, Plus } from "lucide-react"
interface Device {
id: string
name: string
account: string
status: "online" | "offline"
}
const mockDevices: Device[] = [
{
id: "1",
name: "iPhone 13 Pro",
account: "wxid_abc123",
status: "online",
},
{
id: "2",
name: "Huawei P40",
account: "wxid_xyz789",
status: "offline",
},
]
interface DeviceSelectorProps {
selectedDevices: string[]
onChange: (devices: string[]) => void
}
export function DeviceSelector({ selectedDevices, onChange }: DeviceSelectorProps) {
const [searchQuery, setSearchQuery] = useState("")
const toggleSelectAll = () => {
if (selectedDevices.length === mockDevices.length) {
onChange([])
} else {
onChange(mockDevices.map((device) => device.id))
}
}
const toggleDevice = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onChange(selectedDevices.filter((id) => id !== deviceId))
} else {
onChange([...selectedDevices, deviceId])
}
}
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索设备"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button className="ml-2" size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox checked={selectedDevices.length === mockDevices.length} onCheckedChange={toggleSelectAll} />
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockDevices.map((device) => (
<TableRow key={device.id}>
<TableCell>
<Checkbox
checked={selectedDevices.includes(device.id)}
onCheckedChange={() => toggleDevice(device.id)}
/>
</TableCell>
<TableCell>{device.name}</TableCell>
<TableCell>{device.account}</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,274 @@
"use client"
import { useState } from "react"
import { ChevronLeft, X } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { useRouter } from "next/navigation"
import { DateRangePicker } from "@/components/ui/date-range-picker"
import { WechatFriendSelector } from "@/components/WechatFriendSelector"
import { WechatGroupSelector } from "@/components/WechatGroupSelector"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
interface WechatFriend {
id: string
nickname: string
wechatId: string
avatar: string
gender: "male" | "female"
customer: string
}
interface WechatGroup {
id: string
name: string
memberCount: number
avatar: string
owner: string
customer: string
}
export default function NewContentLibraryPage() {
const router = useRouter()
const [formData, setFormData] = useState({
name: "",
sourceType: "friends" as "friends" | "groups",
keywordsInclude: "",
keywordsExclude: "",
startDate: "",
endDate: "",
selectedFriends: [] as WechatFriend[],
selectedGroups: [] as WechatGroup[],
useAI: false,
aiPrompt: "",
enabled: true,
})
const [isWechatFriendSelectorOpen, setIsWechatFriendSelectorOpen] = useState(false)
const [isWechatGroupSelectorOpen, setIsWechatGroupSelectorOpen] = useState(false)
const removeFriend = (friendId: string) => {
setFormData((prev) => ({
...prev,
selectedFriends: prev.selectedFriends.filter((friend) => friend.id !== friendId),
}))
}
const removeGroup = (groupId: string) => {
setFormData((prev) => ({
...prev,
selectedGroups: prev.selectedGroups.filter((group) => group.id !== groupId),
}))
}
return (
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center p-4">
<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>
</header>
<div className="p-4 space-y-4">
<Card className="p-4">
<div className="space-y-4">
<div>
<Label htmlFor="name" className="text-base required">
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="请输入内容库名称"
required
className="mt-1.5"
/>
</div>
<div>
<Label className="text-base"></Label>
<Tabs
value={formData.sourceType}
onValueChange={(value: "friends" | "groups") => setFormData({ ...formData, sourceType: value })}
className="mt-1.5"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="friends"></TabsTrigger>
<TabsTrigger value="groups"></TabsTrigger>
</TabsList>
<TabsContent value="friends" className="mt-4">
<Button variant="outline" className="w-full" onClick={() => setIsWechatFriendSelectorOpen(true)}>
</Button>
{formData.selectedFriends.length > 0 && (
<div className="mt-2 space-y-2">
{formData.selectedFriends.map((friend) => (
<div key={friend.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
<div className="flex items-center space-x-2">
<img
src={friend.avatar || "/placeholder.svg"}
alt={friend.nickname}
className="w-8 h-8 rounded-full"
/>
<span>{friend.nickname}</span>
</div>
<Button variant="ghost" size="sm" onClick={() => removeFriend(friend.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="groups" className="mt-4">
<Button variant="outline" className="w-full" onClick={() => setIsWechatGroupSelectorOpen(true)}>
</Button>
{formData.selectedGroups.length > 0 && (
<div className="mt-2 space-y-2">
{formData.selectedGroups.map((group) => (
<div key={group.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
<div className="flex items-center space-x-2">
<img
src={group.avatar || "/placeholder.svg"}
alt={group.name}
className="w-8 h-8 rounded-full"
/>
<span>{group.name}</span>
</div>
<Button variant="ghost" size="sm" onClick={() => removeGroup(group.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="keywords">
<AccordionTrigger></AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div>
<Label htmlFor="keywordsInclude" className="text-base">
</Label>
<Textarea
id="keywordsInclude"
value={formData.keywordsInclude}
onChange={(e) => setFormData({ ...formData, keywordsInclude: e.target.value })}
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
className="mt-1.5 min-h-[100px]"
/>
</div>
<div>
<Label htmlFor="keywordsExclude" className="text-base">
</Label>
<Textarea
id="keywordsExclude"
value={formData.keywordsExclude}
onChange={(e) => setFormData({ ...formData, keywordsExclude: e.target.value })}
placeholder="如果设置了关键字,匹配到关键字的,系统将不会采集。多个关键字,用半角的','隔开。"
className="mt-1.5 min-h-[100px]"
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex items-center justify-between">
<div>
<Label className="text-base">AI</Label>
<p className="text-sm text-gray-500 mt-1">
AI之后AI重新生成内容
</p>
</div>
<Switch
checked={formData.useAI}
onCheckedChange={(checked) => setFormData({ ...formData, useAI: checked })}
/>
</div>
{formData.useAI && (
<div>
<Label htmlFor="aiPrompt" className="text-base">
AI
</Label>
<Textarea
id="aiPrompt"
value={formData.aiPrompt}
onChange={(e) => setFormData({ ...formData, aiPrompt: e.target.value })}
placeholder="请输入 AI 提示词"
className="mt-1.5 min-h-[100px]"
/>
</div>
)}
<div>
<Label className="text-base"></Label>
<DateRangePicker
className="mt-1.5"
onChange={(range) => {
if (range?.from) {
setFormData({
...formData,
startDate: range.from.toISOString(),
endDate: range.to?.toISOString() || "",
})
}
}}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-base required"></Label>
<Switch
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
</div>
</div>
</Card>
<div className="flex gap-4">
<Button type="button" variant="outline" className="flex-1" onClick={() => router.back()}>
</Button>
<Button type="submit" className="flex-1">
</Button>
</div>
</div>
<WechatFriendSelector
open={isWechatFriendSelectorOpen}
onOpenChange={setIsWechatFriendSelectorOpen}
selectedFriends={formData.selectedFriends}
onSelect={(friends) => setFormData({ ...formData, selectedFriends: friends })}
/>
<WechatGroupSelector
open={isWechatGroupSelectorOpen}
onOpenChange={setIsWechatGroupSelectorOpen}
selectedGroups={formData.selectedGroups}
onSelect={(groups) => setFormData({ ...formData, selectedGroups: groups })}
/>
</div>
)
}

View File

@@ -0,0 +1,214 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Filter, Search, RefreshCw, Plus, Edit, Trash2, Eye, MoreVertical } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import Image from "next/image"
interface ContentLibrary {
id: string
name: string
source: "friends" | "groups"
targetAudience: {
id: string
nickname: string
avatar: string
}[]
creator: string
itemCount: number
lastUpdated: string
enabled: boolean
}
export default function ContentLibraryPage() {
const router = useRouter()
const [libraries, setLibraries] = useState<ContentLibrary[]>([
{
id: "129",
name: "微信好友广告",
source: "friends",
targetAudience: [
{ id: "1", nickname: "张三", avatar: "/placeholder.svg?height=40&width=40" },
{ id: "2", nickname: "李四", avatar: "/placeholder.svg?height=40&width=40" },
{ id: "3", nickname: "王五", avatar: "/placeholder.svg?height=40&width=40" },
],
creator: "海尼",
itemCount: 0,
lastUpdated: "2024-02-09 12:30",
enabled: false,
},
{
id: "127",
name: "开发群",
source: "groups",
targetAudience: [{ id: "4", nickname: "开发群1", avatar: "/placeholder.svg?height=40&width=40" }],
creator: "karuo",
itemCount: 0,
lastUpdated: "2024-02-09 12:30",
enabled: true,
},
])
const [searchQuery, setSearchQuery] = useState("")
const [activeTab, setActiveTab] = useState("all")
const handleCreateNew = () => {
// 模拟创建新内容库
const newId = Date.now().toString()
const newLibrary = {
id: newId,
name: "新内容库",
source: "friends" as const,
targetAudience: [],
creator: "当前用户",
itemCount: 0,
lastUpdated: new Date().toISOString(),
enabled: true,
}
setLibraries([newLibrary, ...libraries])
router.push(`/content/${newId}`)
}
const handleEdit = (id: string) => {
router.push(`/content/${id}`)
}
const handleDelete = (id: string) => {
// 实现删除功能
setLibraries(libraries.filter((lib) => lib.id !== id))
}
const handleViewMaterials = (id: string) => {
router.push(`/content/${id}/materials`)
}
const filteredLibraries = libraries.filter(
(library) =>
(activeTab === "all" || library.source === activeTab) &&
(library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
library.targetAudience.some((target) => target.nickname.toLowerCase().includes(searchQuery.toLowerCase()))),
)
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-lg font-medium"></h1>
</div>
<Button onClick={handleCreateNew}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索内容库..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="friends"></TabsTrigger>
<TabsTrigger value="groups"></TabsTrigger>
</TabsList>
</Tabs>
<div className="space-y-3">
{filteredLibraries.map((library) => (
<Card key={library.id} className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<h3 className="font-medium">{library.name}</h3>
<Badge variant={library.enabled ? "success" : "secondary"}>
{library.enabled ? "已启用" : "已停用"}
</Badge>
</div>
<div className="text-sm text-gray-500">
<div className="flex items-center space-x-1">
<span></span>
<div className="flex -space-x-2 overflow-hidden">
{library.targetAudience.slice(0, 3).map((target) => (
<Image
key={target.id}
src={target.avatar || "/placeholder.svg"}
alt={target.nickname}
width={24}
height={24}
className="inline-block h-6 w-6 rounded-full ring-2 ring-white"
/>
))}
{library.targetAudience.length > 3 && (
<span className="flex items-center justify-center w-6 h-6 text-xs font-medium text-white bg-gray-400 rounded-full ring-2 ring-white">
+{library.targetAudience.length - 3}
</span>
)}
</div>
</div>
<div>{library.creator}</div>
<div>{library.itemCount}</div>
<div>{library.lastUpdated}</div>
</div>
</div>
<div className="flex space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(library.id)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(library.id)}>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleViewMaterials(library.id)}>
<Eye className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
))}
</div>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,283 @@
"use client"
import { useState, useEffect } from "react"
import { useParams, useRouter } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ChevronLeft, Smartphone, Battery, Wifi, MessageCircle, Users, Settings, History } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { ScrollArea } from "@/components/ui/scroll-area"
interface WechatAccount {
id: string
avatar: string
nickname: string
wechatId: string
gender: "male" | "female"
status: "normal" | "abnormal"
addFriendStatus: "enabled" | "disabled"
friendCount: number
lastActive: string
}
interface Device {
id: string
imei: string
name: string
status: "online" | "offline"
battery: number
lastActive: string
historicalIds: string[]
wechatAccounts: WechatAccount[]
features: {
autoAddFriend: boolean
autoReply: boolean
contentSync: boolean
aiChat: boolean
}
history: {
time: string
action: string
operator: string
}[]
}
export default function DeviceDetailPage() {
const params = useParams()
const router = useRouter()
const [device, setDevice] = useState<Device | null>(null)
const [activeTab, setActiveTab] = useState("info")
useEffect(() => {
// 模拟API调用
const mockDevice: Device = {
id: params.id as string,
imei: "sd123123",
name: "设备 1",
status: "online",
battery: 85,
lastActive: "2024-02-09 15:30:45",
historicalIds: ["vx412321", "vfbadasd"],
wechatAccounts: [
{
id: "1",
avatar: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-q2rVrFbfDdAbSnT3ZTNE7gfn3QCbvr.png",
nickname: "老张",
wechatId: "wxid_abc123",
gender: "male",
status: "normal",
addFriendStatus: "enabled",
friendCount: 523,
lastActive: "2024-02-09 15:20:33",
},
{
id: "2",
avatar: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-q2rVrFbfDdAbSnT3ZTNE7gfn3QCbvr.png",
nickname: "老李",
wechatId: "wxid_xyz789",
gender: "male",
status: "abnormal",
addFriendStatus: "disabled",
friendCount: 245,
lastActive: "2024-02-09 14:15:22",
},
],
features: {
autoAddFriend: true,
autoReply: true,
contentSync: false,
aiChat: true,
},
history: [
{
time: "2024-02-09 15:30:45",
action: "开启自动加好友",
operator: "系统",
},
{
time: "2024-02-09 14:20:33",
action: "添加微信号",
operator: "管理员",
},
],
}
setDevice(mockDevice)
}, [params.id])
if (!device) {
return <div>...</div>
}
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<div className="max-w-[390px] mx-auto bg-white">
<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 variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
<Card className="p-4">
<div className="flex items-center space-x-4">
<div className="p-3 bg-blue-50 rounded-lg">
<Smartphone className="h-6 w-6 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h2 className="font-medium truncate">{device.name}</h2>
<Badge variant={device.status === "online" ? "success" : "secondary"}>
{device.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="text-sm text-gray-500 mt-1">IMEI: {device.imei}</div>
<div className="text-sm text-gray-500">ID: {device.historicalIds.join(", ")}</div>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
<span className="text-sm">{device.battery}%</span>
</div>
<div className="flex items-center space-x-2">
<Wifi className="w-4 h-4 text-blue-500" />
<span className="text-sm">{device.status === "online" ? "已连接" : "未连接"}</span>
</div>
</div>
<div className="mt-2 text-sm text-gray-500">{device.lastActive}</div>
</Card>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="info"></TabsTrigger>
<TabsTrigger value="accounts"></TabsTrigger>
<TabsTrigger value="history"></TabsTrigger>
</TabsList>
<TabsContent value="info">
<Card className="p-4 space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label></Label>
<div className="text-sm text-gray-500"></div>
</div>
<Switch checked={device.features.autoAddFriend} />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label></Label>
<div className="text-sm text-gray-500"></div>
</div>
<Switch checked={device.features.autoReply} />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label></Label>
<div className="text-sm text-gray-500"></div>
</div>
<Switch checked={device.features.contentSync} />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>AI会话</Label>
<div className="text-sm text-gray-500">AI智能对话</div>
</div>
<Switch checked={device.features.aiChat} />
</div>
</div>
</Card>
</TabsContent>
<TabsContent value="accounts">
<Card className="p-4">
<ScrollArea className="h-[calc(100vh-300px)]">
<div className="space-y-4">
{device.wechatAccounts.map((account) => (
<div key={account.id} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
<img
src={account.avatar || "/placeholder.svg"}
alt={account.nickname}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium truncate">{account.nickname}</div>
<Badge variant={account.status === "normal" ? "success" : "destructive"}>
{account.status === "normal" ? "正常" : "异常"}
</Badge>
</div>
<div className="text-sm text-gray-500 mt-1">: {account.wechatId}</div>
<div className="text-sm text-gray-500">: {account.gender === "male" ? "男" : "女"}</div>
<div className="flex items-center justify-between mt-2">
<span className="text-sm text-gray-500">: {account.friendCount}</span>
<Badge variant={account.addFriendStatus === "enabled" ? "outline" : "secondary"}>
{account.addFriendStatus === "enabled" ? "可加友" : "已停用"}
</Badge>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</Card>
</TabsContent>
<TabsContent value="history">
<Card className="p-4">
<ScrollArea className="h-[calc(100vh-300px)]">
<div className="space-y-4">
{device.history.map((record, index) => (
<div key={index} className="flex items-start space-x-3">
<div className="p-2 bg-blue-50 rounded-full">
<History className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{record.action}</div>
<div className="text-xs text-gray-500 mt-1">
: {record.operator} · {record.time}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</Card>
</TabsContent>
</Tabs>
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<div className="flex items-center space-x-2 text-gray-500">
<Users className="w-4 h-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold text-blue-600 mt-2">
{device?.wechatAccounts?.reduce((sum, account) => sum + account.friendCount, 0)}
</div>
</Card>
<Card className="p-4">
<div className="flex items-center space-x-2 text-gray-500">
<MessageCircle className="w-4 h-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold text-blue-600 mt-2">5,678</div>
</Card>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,284 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ChevronLeft, Plus, Filter, Search, RefreshCw, QrCode } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "@/components/ui/use-toast"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
interface Device {
id: string
imei: string
name: string
remark: string
status: "online" | "offline"
battery: number
wechatId: string
friendCount: number
todayAdded: number
messageCount: number
lastActive: string
addFriendStatus: "normal" | "abnormal"
avatar?: string
}
export default function DevicesPage() {
const router = useRouter()
const [devices, setDevices] = useState<Device[]>([])
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
const [stats, setStats] = useState({
totalDevices: 42,
onlineDevices: 35,
})
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [currentPage, setCurrentPage] = useState(1)
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
const devicesPerPage = 10
useEffect(() => {
// 模拟API调用
const fetchDevices = async () => {
const mockDevices = Array.from({ length: 42 }, (_, i) => ({
id: `device-${i + 1}`,
imei: `sd${123123 + i}`,
name: `设备 ${i + 1}`,
remark: `备注 ${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",
avatar: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-kYhfQsrrByfbzefv6MEV7W7ogz0IRt.png",
}))
setDevices(mockDevices)
}
fetchDevices()
}, [])
const handleRefresh = () => {
toast({
title: "刷新成功",
description: "设备列表已更新",
})
}
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)
const handleBatchDelete = () => {
if (selectedDevices.length === 0) {
toast({
title: "请选择设备",
description: "您需要选择至少一个设备来执行批量删除操作",
variant: "destructive",
})
return
}
toast({
title: "批量删除成功",
description: `已删除 ${selectedDevices.length} 个设备`,
})
setSelectedDevices([])
}
const handleDeviceClick = (deviceId: string) => {
router.push(`/devices/${deviceId}`)
}
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-lg font-medium"></h1>
</div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => setIsAddDeviceOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<Card className="p-3">
<div className="text-sm text-gray-500"></div>
<div className="text-xl font-bold text-blue-600">{stats.totalDevices}</div>
</Card>
<Card className="p-3">
<div className="text-sm text-gray-500">线</div>
<div className="text-xl font-bold text-green-600">{stats.onlineDevices}</div>
</Card>
</div>
<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 className="flex items-center space-x-2">
<Checkbox
checked={selectedDevices.length === paginatedDevices.length}
onCheckedChange={(checked) => {
if (checked) {
setSelectedDevices(paginatedDevices.map((d) => d.id))
} else {
setSelectedDevices([])
}
}}
/>
<span className="text-sm text-gray-500"></span>
</div>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleBatchDelete}
disabled={selectedDevices.length === 0}
>
</Button>
</div>
<div className="space-y-2">
{paginatedDevices.map((device) => (
<Card
key={device.id}
className="p-3 hover:shadow-md transition-shadow cursor-pointer relative"
onClick={() => handleDeviceClick(device.id)}
>
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedDevices.includes(device.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedDevices([...selectedDevices, device.id])
} else {
setSelectedDevices(selectedDevices.filter((id) => id !== device.id))
}
}}
onClick={(e) => e.stopPropagation()}
/>
<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"} className="ml-2">
{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>
<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>
<Dialog open={isAddDeviceOpen} onOpenChange={setIsAddDeviceOpen}>
<DialogContent className="sm:max-w-[390px]">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center p-6 space-y-6">
<div className="w-48 h-48 bg-gray-100 rounded-lg flex items-center justify-center">
<QrCode className="w-12 h-12 text-gray-400" />
</div>
<p className="text-sm text-gray-500 text-center">
使
<br />
ID
</p>
<Input placeholder="请输入设备ID" className="max-w-[280px]" />
<div className="flex space-x-2">
<Button variant="outline" onClick={() => setIsAddDeviceOpen(false)}>
</Button>
<Button></Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

60
Cunkebao/app/globals.css Normal file
View File

@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.75rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 40% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 40% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 210 40% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 40% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 224 71.4% 4.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,47 @@
"use client"
import { useState, useEffect } from "react"
import type { Device } from "@/components/device-grid"
interface DeviceStatus {
status: "online" | "offline"
battery: number
}
async function fetchDeviceStatuses(deviceIds: string[]): Promise<Record<string, DeviceStatus>> {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
return deviceIds.reduce(
(acc, id) => {
acc[id] = {
status: Math.random() > 0.3 ? "online" : "offline",
battery: Math.floor(Math.random() * 100),
}
return acc
},
{} as Record<string, DeviceStatus>,
)
}
export function useDeviceStatusPolling(devices: Device[]) {
const [statuses, setStatuses] = useState<Record<string, DeviceStatus>>({})
useEffect(() => {
const pollStatus = async () => {
try {
const newStatuses = await fetchDeviceStatuses(devices.map((d) => d.id))
setStatuses((prevStatuses) => ({ ...prevStatuses, ...newStatuses }))
} catch (error) {
console.error("Failed to fetch device statuses:", error)
}
}
pollStatus() // 立即执行一次
const intervalId = setInterval(pollStatus, 30000) // 每30秒更新一次
return () => clearInterval(intervalId)
}, [devices])
return statuses
}

35
Cunkebao/app/layout.tsx Normal file
View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next"
import "./globals.css"
import "regenerator-runtime/runtime"
import type React from "react"
import ErrorBoundary from "./components/ErrorBoundary"
import { AuthProvider } from "@/app/components/AuthProvider"
import LayoutWrapper from "./components/LayoutWrapper"
export const metadata: Metadata = {
title: "存客宝",
description: "智能客户管理系统",
generator: 'v0.dev'
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body className="bg-gray-100">
<AuthProvider>
<ErrorBoundary>
<LayoutWrapper>{children}</LayoutWrapper>
</ErrorBoundary>
</AuthProvider>
</body>
</html>
)
}
import './globals.css'

View File

@@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

356
Cunkebao/app/login/page.tsx Normal file
View File

@@ -0,0 +1,356 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { Eye, EyeOff, Phone } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { useRouter } from "next/navigation"
import { WeChatIcon } from "@/components/icons/wechat-icon"
import { AppleIcon } from "@/components/icons/apple-icon"
import { useToast } from "@/components/ui/use-toast"
// 使用环境变量获取API域名
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"
// 定义登录响应类型
interface LoginResponse {
code: number
message: string
data?: {
token: string
}
}
interface LoginForm {
phone: string
password: string
verificationCode: string
agreeToTerms: boolean
}
export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false)
const [activeTab, setActiveTab] = useState<"password" | "verification">("password")
const [isLoading, setIsLoading] = useState(false)
const [form, setForm] = useState<LoginForm>({
phone: "",
password: "",
verificationCode: "",
agreeToTerms: false,
})
const router = useRouter()
const { toast } = useToast()
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setForm((prev) => ({ ...prev, [name]: value }))
}
const handleCheckboxChange = (checked: boolean) => {
setForm((prev) => ({ ...prev, agreeToTerms: checked }))
}
const validateForm = () => {
if (!form.phone) {
toast({
variant: "destructive",
title: "请输入手机号",
description: "手机号不能为空",
})
return false
}
if (!form.agreeToTerms) {
toast({
variant: "destructive",
title: "请同意用户协议",
description: "需要同意用户协议和隐私政策才能继续",
})
return false
}
if (activeTab === "password" && !form.password) {
toast({
variant: "destructive",
title: "请输入密码",
description: "密码不能为空",
})
return false
}
if (activeTab === "verification" && !form.verificationCode) {
toast({
variant: "destructive",
title: "请输入验证码",
description: "验证码不能为空",
})
return false
}
return true
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
setIsLoading(true)
try {
// 创建FormData对象
const formData = new FormData()
formData.append("phone", form.phone)
if (activeTab === "password") {
formData.append("password", form.password)
} else {
formData.append("verificationCode", form.verificationCode)
}
// 发送登录请求
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: "POST",
body: formData,
// 不需要设置Content-Type浏览器会自动设置为multipart/form-data并添加boundary
})
const result: LoginResponse = await response.json()
if (result.code === 10000 && result.data?.token) {
// 保存token到localStorage
localStorage.setItem("token", result.data.token)
// 成功后跳转
router.push("/profile")
toast({
title: "登录成功",
description: "欢迎回来!",
})
} else {
throw new Error(result.message || "登录失败")
}
} catch (error) {
toast({
variant: "destructive",
title: "登录失败",
description: error instanceof Error ? error.message : "请稍后重试",
})
} finally {
setIsLoading(false)
}
}
const handleSendVerificationCode = async () => {
if (!form.phone) {
toast({
variant: "destructive",
title: "请输入手机号",
description: "发送验证码需要手机号",
})
return
}
setIsLoading(true)
try {
// 创建FormData对象
const formData = new FormData()
formData.append("phone", form.phone)
// 发送验证码请求
const response = await fetch(`${API_BASE_URL}/auth/send-code`, {
method: "POST",
body: formData,
})
const result = await response.json()
if (result.code === 10000) {
toast({
title: "验证码已发送",
description: "请查看手机短信",
})
} else {
throw new Error(result.message || "发送失败")
}
} catch (error) {
toast({
variant: "destructive",
title: "发送失败",
description: error instanceof Error ? error.message : "请稍后重试",
})
} finally {
setIsLoading(false)
}
}
useEffect(() => {
// 检查是否已登录
const token = localStorage.getItem("token")
if (token) {
router.push("/profile")
}
}, [router])
return (
<div className="min-h-screen bg-white text-gray-900 flex flex-col px-4 py-8">
<div className="max-w-md w-full mx-auto space-y-8">
<Tabs
defaultValue="password"
className="w-full"
onValueChange={(v) => setActiveTab(v as "password" | "verification")}
>
<TabsList className="w-full bg-transparent border-b border-gray-200">
<TabsTrigger
value="verification"
className="flex-1 data-[state=active]:border-blue-500 data-[state=active]:text-blue-500 border-b-2 border-transparent"
>
</TabsTrigger>
<TabsTrigger
value="password"
className="flex-1 data-[state=active]:border-blue-500 data-[state=active]:text-blue-500 border-b-2 border-transparent"
>
</TabsTrigger>
</TabsList>
<div className="mt-8">
<p className="text-gray-600 mb-6"> / / Apple </p>
<form onSubmit={handleLogin} className="space-y-6">
<div className="relative">
<Input
type="tel"
name="phone"
value={form.phone}
onChange={handleInputChange}
placeholder="手机号"
className="pl-16 border-gray-300 text-gray-900 h-12"
disabled={isLoading}
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1">
<Phone className="h-4 w-4" />
+86
</span>
</div>
<TabsContent value="password" className="m-0">
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
name="password"
value={form.password}
onChange={handleInputChange}
placeholder="密码"
className="pr-12 border-gray-300 text-gray-900 h-12"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</TabsContent>
<TabsContent value="verification" className="m-0">
<div className="flex gap-3">
<Input
type="text"
name="verificationCode"
value={form.verificationCode}
onChange={handleInputChange}
placeholder="验证码"
className="border-gray-300 text-gray-900 h-12"
disabled={isLoading}
/>
<Button
type="button"
variant="outline"
className="w-32 h-12 border-gray-300 text-gray-600 hover:text-gray-900"
onClick={handleSendVerificationCode}
disabled={isLoading}
>
</Button>
</div>
</TabsContent>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={form.agreeToTerms}
onCheckedChange={handleCheckboxChange}
className="border-gray-300 data-[state=checked]:bg-blue-500"
disabled={isLoading}
/>
<label htmlFor="terms" className="text-sm text-gray-600">
<a href="#" className="text-blue-500 mx-1">
</a>
<a href="#" className="text-blue-500 ml-1">
</a>
</label>
</div>
<Button
type="submit"
className="w-full h-12 bg-blue-500 hover:bg-blue-600 text-white"
disabled={isLoading}
>
{isLoading ? "登录中..." : "登录"}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-300"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500"></span>
</div>
</div>
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50"
disabled={isLoading}
>
<WeChatIcon className="w-6 h-6 mr-2 text-[#07C160]" />
使
</Button>
<Button
type="button"
variant="outline"
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50"
disabled={isLoading}
>
<AppleIcon className="w-6 h-6 mr-2" />
使 Apple
</Button>
</div>
</form>
<div className="mt-8 text-center">
<a href="#" className="text-sm text-gray-500">
</a>
</div>
</div>
</Tabs>
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/use-toast"
import type { OrderFormData } from "@/types/acquisition"
export default function OrderSubmitPage({ params }: { params: { planId: string } }) {
const [formData, setFormData] = useState<OrderFormData>({
customerName: "",
phone: "",
wechatId: "",
source: "",
amount: undefined,
orderDate: new Date().toISOString().split("T")[0],
remark: "",
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const response = await fetch(`/api/acquisition/${params.planId}/orders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
})
const data = await response.json()
if (data.success) {
toast({
title: "提交成功",
description: "订单信息已成功提交",
})
// 重置表单
setFormData({
customerName: "",
phone: "",
wechatId: "",
source: "",
amount: undefined,
orderDate: new Date().toISOString().split("T")[0],
remark: "",
})
} else {
throw new Error(data.message)
}
} catch (error) {
toast({
title: "提交失败",
description: "订单提交失败,请稍后重试",
variant: "destructive",
})
}
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-md mx-auto">
<Card className="p-6">
<h1 className="text-2xl font-semibold mb-6"></h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="customerName"></Label>
<Input
id="customerName"
value={formData.customerName}
onChange={(e) => setFormData((prev) => ({ ...prev, customerName: e.target.value }))}
required
/>
</div>
<div>
<Label htmlFor="phone"></Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => setFormData((prev) => ({ ...prev, phone: e.target.value }))}
required
/>
</div>
<div>
<Label htmlFor="wechatId"></Label>
<Input
id="wechatId"
value={formData.wechatId}
onChange={(e) => setFormData((prev) => ({ ...prev, wechatId: e.target.value }))}
required
/>
</div>
<div>
<Label htmlFor="source"></Label>
<Input
id="source"
value={formData.source}
onChange={(e) => setFormData((prev) => ({ ...prev, source: e.target.value }))}
required
/>
</div>
<div>
<Label htmlFor="amount"></Label>
<Input
id="amount"
type="number"
step="0.01"
value={formData.amount || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, amount: Number(e.target.value) }))}
/>
</div>
<div>
<Label htmlFor="orderDate"></Label>
<Input
id="orderDate"
type="date"
value={formData.orderDate}
onChange={(e) => setFormData((prev) => ({ ...prev, orderDate: e.target.value }))}
required
/>
</div>
<div>
<Label htmlFor="remark"></Label>
<Textarea
id="remark"
value={formData.remark}
onChange={(e) => setFormData((prev) => ({ ...prev, remark: e.target.value }))}
/>
</div>
<Button type="submit" className="w-full">
</Button>
</form>
</Card>
</div>
</div>
)
}

238
Cunkebao/app/page.tsx Normal file
View File

@@ -0,0 +1,238 @@
"use client"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Bell, Smartphone, Users, Activity } from "lucide-react"
import { Progress } from "@/components/ui/progress"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useState, useEffect, useRef } from "react"
// 导入Chart.js
import { Chart, registerables } from "chart.js"
Chart.register(...registerables)
export default function Home() {
const router = useRouter()
const chartRef = useRef(null)
const chartInstance = useRef(null)
// 统一设备数据
const [stats, setStats] = useState({
totalDevices: 42,
onlineDevices: 35,
totalWechatAccounts: 42,
onlineWechatAccounts: 35,
})
useEffect(() => {
// 模拟API调用
const fetchStats = async () => {
// 这里应该是实际的API调用
const mockStats = {
totalDevices: 42,
onlineDevices: 35,
totalWechatAccounts: 42,
onlineWechatAccounts: 35,
}
setStats(mockStats)
}
fetchStats()
}, [])
// 使用Chart.js创建图表
useEffect(() => {
if (chartRef.current) {
// 如果已经有图表实例,先销毁它
if (chartInstance.current) {
chartInstance.current.destroy()
}
const ctx = chartRef.current.getContext("2d")
// 创建新的图表实例
chartInstance.current = new Chart(ctx, {
type: "line",
data: {
labels: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
datasets: [
{
label: "获客数量",
data: [120, 150, 180, 200, 230, 210, 190],
backgroundColor: "rgba(59, 130, 246, 0.2)",
borderColor: "rgba(59, 130, 246, 1)",
borderWidth: 2,
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: "rgba(59, 130, 246, 1)",
pointHoverRadius: 6,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(255, 255, 255, 0.9)",
titleColor: "#333",
bodyColor: "#666",
borderColor: "#ddd",
borderWidth: 1,
padding: 10,
displayColors: false,
callbacks: {
label: (context) => `获客数量: ${context.parsed.y}`,
},
},
},
scales: {
x: {
grid: {
display: false,
},
},
y: {
beginAtZero: true,
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
},
},
})
}
// 组件卸载时清理图表实例
return () => {
if (chartInstance.current) {
chartInstance.current.destroy()
}
}
}, [])
const handleDevicesClick = () => {
router.push("/devices")
}
const handleWechatClick = () => {
router.push("/wechat-accounts")
}
const scenarioFeatures = [
{
id: "douyin",
name: "抖音获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-QR8ManuDplYTySUJsY4mymiZkDYnQ9.png",
color: "bg-blue-100 text-blue-600",
value: 156,
growth: 12,
},
{
id: "xiaohongshu",
name: "小红书获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png",
color: "bg-red-100 text-red-600",
value: 89,
growth: 8,
},
{
id: "gongzhonghao",
name: "公众号获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-Gsg0CMf5tsZb41mioszdjqU1WmsRxW.png",
color: "bg-green-100 text-green-600",
value: 234,
growth: 15,
},
{
id: "haibao",
name: "海报获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-x92XJgXy4MI7moNYlA1EAes2FqDxMH.png",
color: "bg-orange-100 text-orange-600",
value: 167,
growth: 10,
},
]
return (
<div className="flex-1 overflow-y-auto pb-16 bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex justify-between items-center p-4">
<h1 className="text-xl font-semibold text-blue-600"></h1>
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
</Button>
</div>
</header>
<div className="p-4 space-y-6">
<div className="grid grid-cols-3 gap-4">
<Card className="p-4 bg-white hover:shadow-lg transition-all cursor-pointer" onClick={handleDevicesClick}>
<div className="flex flex-col">
<span className="text-sm text-gray-500 mb-2"></span>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-blue-600">{stats.totalDevices}</span>
<Smartphone className="w-6 h-6 text-blue-600" />
</div>
</div>
</Card>
<Card className="p-4 bg-white hover:shadow-lg transition-all cursor-pointer" onClick={handleWechatClick}>
<div className="flex flex-col">
<span className="text-sm text-gray-500 mb-2"></span>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-blue-600">{stats.totalWechatAccounts}</span>
<Users className="w-6 h-6 text-blue-600" />
</div>
</div>
</Card>
<Card className="p-4 bg-white">
<div className="flex flex-col">
<span className="text-sm text-gray-500 mb-2">线</span>
<div className="flex items-center justify-between mb-2">
<span className="text-2xl font-bold text-blue-600">{stats.onlineWechatAccounts}</span>
<Activity className="w-6 h-6 text-blue-600" />
</div>
<Progress value={(stats.onlineWechatAccounts / stats.totalWechatAccounts) * 100} className="h-1" />
</div>
</Card>
</div>
{/* 场景获客统计 */}
<Card className="p-4 bg-white">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold"></h2>
</div>
<div className="flex justify-between">
{scenarioFeatures
.sort((a, b) => b.value - a.value)
.map((scenario) => (
<Link href={`/scenarios/${scenario.id}`} key={scenario.id} className="block flex-1">
<div className="flex flex-col items-center text-center space-y-2">
<div className={`w-12 h-12 rounded-full ${scenario.color} flex items-center justify-center`}>
<img src={scenario.icon || "/placeholder.svg"} alt={scenario.name} className="w-6 h-6" />
</div>
<div className="text-sm font-medium">{scenario.value}</div>
<div className="text-xs text-gray-500 whitespace-nowrap overflow-hidden text-ellipsis w-full">
{scenario.name}
</div>
</div>
</Link>
))}
</div>
</Card>
{/* 每日获客趋势 - 使用Canvas和Chart.js */}
<Card className="p-4 bg-white">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="w-full h-64 relative">
<canvas ref={chartRef} />
</div>
</Card>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,289 @@
"use client"
import { useState, useEffect } from "react"
import { ChevronLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
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: 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() {
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,
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 字段
})
// 如果是从微信号好友转移过来,自动设置计划名称
useEffect(() => {
if (type === "order" && source) {
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
setFormData((prev) => ({
...prev,
planName: `${source}好友转移${today}`,
scenario: "order",
}))
// 模拟加载好友数据
setTimeout(() => {
toast({
title: "好友数据加载成功",
description: `已从微信号 ${source} 导入好友数据`,
})
}, 1000)
}
}, [type, source])
// 根据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)
console.log("计划已创建:", { ...formData, scenario })
toast({
title: "创建成功",
description: "获客计划已创建完成",
})
// 跳转到首页
router.push("/")
}
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))
}
}
}
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 renderStepContent = () => {
switch (currentStep) {
case 1:
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} />
case 2:
return (
<FriendRequestSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
)
case 3:
return <MessageSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
case 4:
return <TagSettings formData={formData} onComplete={handleSave} onPrev={handlePrev} onChange={setFormData} />
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-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("/")}>
<ChevronLeft 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-b border-gray-200">
<div className="px-4 py-4">
<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-1 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>
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
<div className="sticky bottom-0 left-0 right-0 bg-white border-t p-4">
<div className="flex justify-between max-w-[390px] mx-auto">
{currentStep > 1 && (
<Button variant="outline" onClick={handlePrev}>
</Button>
)}
<Button className={cn("min-w-[120px]", currentStep === 1 ? "w-full" : "ml-auto")} onClick={handleNext}>
{currentStep === steps.length ? "完成" : "下一步"}
</Button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,713 @@
"use client"
import type React from "react"
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 { Switch } from "@/components/ui/switch"
import { QrCode, X, ChevronDown, Plus, Maximize2, Upload, Download, Settings } from "lucide-react"
import { TooltipProvider } from "@/components/ui/tooltip"
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog"
// 调整场景顺序确保API获客在最后并且前三个是最常用的场景
const scenarios = [
{ id: "haibao", name: "海报获客", type: "material" },
{ id: "order", name: "订单获客", type: "api" },
{ id: "douyin", name: "抖音获客", type: "social" },
{ id: "xiaohongshu", name: "小红书获客", type: "social" },
{ id: "phone", name: "电话获客", type: "social" },
{ id: "gongzhonghao", name: "公众号获客", type: "social" },
{ id: "weixinqun", name: "微信群获客", type: "social" },
{ id: "payment", name: "付款码获客", type: "material" },
{ id: "api", name: "API获客", type: "api" }, // API获客放在最后
]
interface Account {
id: string
nickname: string
avatar: string
}
interface Material {
id: string
name: string
type: "poster" | "payment"
preview: string
}
interface BasicSettingsProps {
formData: any
onChange: (data: any) => void
onNext?: () => void
}
const posterTemplates = [
{
id: "poster-1",
name: "点击领取",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E9%A2%86%E5%8F%961-tipd1HI7da6qooY5NkhxQnXBnT5LGU.gif",
},
{
id: "poster-2",
name: "点击合作",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%90%88%E4%BD%9C-LPlMdgxtvhqCSr4IM1bZFEFDBF3ztI.gif",
},
{
id: "poster-3",
name: "点击咨询",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif",
},
{
id: "poster-4",
name: "点击签到",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E7%AD%BE%E5%88%B0-94TZIkjLldb4P2jTVlI6MkSDg0NbXi.gif",
},
{
id: "poster-5",
name: "点击了解",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E4%BA%86%E8%A7%A3-6GCl7mQVdO4WIiykJyweSubLsTwj71.gif",
},
{
id: "poster-6",
name: "点击报名",
preview:
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E6%8A%A5%E5%90%8D-Mj0nnva0BiASeDAIhNNaRRAbjPgjEj.gif",
},
]
const generateRandomAccounts = (count: number): Account[] => {
return Array.from({ length: count }, (_, index) => ({
id: `account-${index + 1}`,
nickname: `账号-${Math.random().toString(36).substring(2, 7)}`,
avatar: `/placeholder.svg?height=40&width=40&text=${index + 1}`,
}))
}
const generatePosterMaterials = (): Material[] => {
return posterTemplates.map((template) => ({
id: template.id,
name: template.name,
type: "poster",
preview: template.preview,
}))
}
export function BasicSettings({ formData, onChange, onNext }: BasicSettingsProps) {
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false)
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false)
const [isQRCodeOpen, setIsQRCodeOpen] = useState(false)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const [isPhoneSettingsOpen, setIsPhoneSettingsOpen] = useState(false)
const [previewImage, setPreviewImage] = useState("")
const [accounts] = useState<Account[]>(generateRandomAccounts(50))
const [materials] = useState<Material[]>(generatePosterMaterials())
const [selectedAccounts, setSelectedAccounts] = useState<Account[]>(
formData.accounts?.length > 0 ? formData.accounts : [],
)
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
formData.materials?.length > 0 ? formData.materials : [],
)
const [showAllScenarios, setShowAllScenarios] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [importedTags, setImportedTags] = useState<
Array<{
phone: string
wechat: string
source?: string
orderAmount?: number
orderDate?: string
}>
>(formData.importedTags || [])
// 初始化电话获客设置
const [phoneSettings, setPhoneSettings] = useState({
autoAdd: formData.phoneSettings?.autoAdd ?? true,
speechToText: formData.phoneSettings?.speechToText ?? true,
questionExtraction: formData.phoneSettings?.questionExtraction ?? true,
})
// 初始化时,如果没有选择场景,默认选择海报获客
useEffect(() => {
if (!formData.scenario) {
onChange({ ...formData, scenario: "haibao" })
}
if (!formData.planName) {
if (formData.materials?.length > 0) {
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
onChange({ ...formData, planName: `海报${today}` })
} else {
onChange({ ...formData, planName: "场景" })
}
}
}, [formData, onChange])
const handleScenarioSelect = (scenarioId: string) => {
onChange({ ...formData, scenario: scenarioId })
// 如果选择了电话获客,自动更新计划名称
if (scenarioId === "phone") {
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
onChange({ ...formData, planName: `电话获客${today}` })
}
}
const handleAccountSelect = (account: Account) => {
const updatedAccounts = [...selectedAccounts, account]
setSelectedAccounts(updatedAccounts)
onChange({ ...formData, accounts: updatedAccounts })
}
const handleMaterialSelect = (material: Material) => {
const updatedMaterials = [material]
setSelectedMaterials(updatedMaterials)
onChange({ ...formData, materials: updatedMaterials })
setIsMaterialDialogOpen(false)
// 更新计划名称
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
onChange({ ...formData, planName: `海报${today}`, materials: updatedMaterials })
}
const handleRemoveAccount = (accountId: string) => {
const updatedAccounts = selectedAccounts.filter((a) => a.id !== accountId)
setSelectedAccounts(updatedAccounts)
onChange({ ...formData, accounts: updatedAccounts })
}
const handleRemoveMaterial = (materialId: string) => {
const updatedMaterials = selectedMaterials.filter((m) => m.id !== materialId)
setSelectedMaterials(updatedMaterials)
onChange({ ...formData, materials: updatedMaterials })
}
const handlePreviewImage = (imageUrl: string) => {
setPreviewImage(imageUrl)
setIsPreviewOpen(true)
}
// 只显示前三个场景,其他的需要点击展开
const displayedScenarios = showAllScenarios ? scenarios : scenarios.slice(0, 3)
const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const rows = content.split("\n").filter((row) => row.trim())
const tags = rows.slice(1).map((row) => {
const [phone, wechat, source, orderAmount, orderDate] = row.split(",")
return {
phone: phone.trim(),
wechat: wechat.trim(),
source: source?.trim(),
orderAmount: orderAmount ? Number(orderAmount) : undefined,
orderDate: orderDate?.trim(),
}
})
setImportedTags(tags)
onChange({ ...formData, importedTags: tags })
} catch (error) {
console.error("导入失败:", error)
}
}
reader.readAsText(file)
}
}
const handleDownloadTemplate = () => {
const template = "电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03"
const blob = new Blob([template], { type: "text/csv" })
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "订单导入模板.csv"
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
// 处理电话获客设置更新
const handlePhoneSettingsUpdate = () => {
onChange({ ...formData, phoneSettings })
setIsPhoneSettingsOpen(false)
}
return (
<TooltipProvider>
<Card className="p-6">
<div className="space-y-6">
<div>
<Label className="text-base mb-4 block"></Label>
<div className="grid grid-cols-3 gap-2">
{displayedScenarios.map((scenario) => (
<button
key={scenario.id}
className={`p-2 rounded-lg text-center transition-all ${
formData.scenario === scenario.id
? "bg-blue-100 text-blue-600 font-medium"
: "bg-gray-50 text-gray-600 hover:bg-gray-100"
}`}
onClick={() => handleScenarioSelect(scenario.id)}
>
{scenario.name.replace("获客", "")}
</button>
))}
</div>
{!showAllScenarios && (
<Button variant="ghost" className="mt-2 w-full text-blue-600" onClick={() => setShowAllScenarios(true)}>
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<div>
<Label htmlFor="planName"></Label>
<Input
id="planName"
value={formData.planName}
onChange={(e) => onChange({ ...formData, planName: e.target.value })}
placeholder="请输入计划名称"
className="mt-2"
/>
</div>
{formData.scenario && (
<>
{scenarios.find((s) => s.id === formData.scenario)?.type === "social" && (
<div>
<Label></Label>
<div className="flex gap-2 mt-2">
<Button
variant="outline"
className="flex-1 justify-start"
onClick={() => setIsAccountDialogOpen(true)}
>
{selectedAccounts.length > 0 ? `已选择 ${selectedAccounts.length} 个账号` : "选择账号"}
</Button>
<Button variant="outline" size="icon" onClick={() => setIsQRCodeOpen(true)}>
<QrCode className="h-4 w-4" />
</Button>
</div>
{selectedAccounts.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{selectedAccounts.map((account) => (
<div key={account.id} className="flex items-center bg-gray-100 rounded-full px-3 py-1">
<img
src={account.avatar || "/placeholder.svg"}
alt={account.nickname}
className="w-4 h-4 rounded-full mr-2"
/>
<span className="text-sm">{account.nickname}</span>
<Button
variant="ghost"
size="sm"
className="ml-2 p-0"
onClick={() => handleRemoveAccount(account.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* 电话获客特殊设置 */}
{formData.scenario === "phone" && (
<Card className="p-4 border-blue-100 bg-blue-50/50 mt-4">
<div className="flex items-center justify-between mb-3">
<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>
)}
{scenarios.find((s) => s.id === formData.scenario)?.type === "material" && (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<Button variant="outline" onClick={() => setIsMaterialDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 海报展示区域 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{materials.map((material) => (
<div
key={material.id}
className={`relative cursor-pointer rounded-lg overflow-hidden group ${
selectedMaterials.find((m) => m.id === material.id)
? "ring-2 ring-blue-600"
: "hover:ring-2 hover:ring-blue-600"
}`}
onClick={() => handleMaterialSelect(material)}
>
<img
src={material.preview || "/placeholder.svg"}
alt={material.name}
className="w-full aspect-[9/16] object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
handlePreviewImage(material.preview)
}}
>
<Maximize2 className="h-4 w-4 text-white" />
</Button>
</div>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
<div className="text-sm truncate">{material.name}</div>
</div>
</div>
))}
</div>
{selectedMaterials.length > 0 && (
<div className="mt-4">
<Label></Label>
<div className="mt-2">
<div className="relative w-full max-w-[200px]">
<img
src={selectedMaterials[0].preview || "/placeholder.svg"}
alt={selectedMaterials[0].name}
className="w-full aspect-[9/16] object-cover rounded-lg cursor-pointer"
onClick={() => handlePreviewImage(selectedMaterials[0].preview)}
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={() => handleRemoveMaterial(selectedMaterials[0].id)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div>
)}
{scenarios.find((s) => s.id === formData.scenario)?.id === "order" && (
<div>
<div className="flex items-center justify-between mb-4">
<Label></Label>
<div className="flex gap-2">
<Button variant="outline" onClick={handleDownloadTemplate}>
<Download className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => setIsImportDialogOpen(true)}>
<Upload className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
{importedTags.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium mb-2"> {importedTags.length} </h4>
<div className="max-h-[300px] overflow-auto border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importedTags.slice(0, 5).map((tag, index) => (
<TableRow key={index}>
<TableCell>{tag.phone}</TableCell>
<TableCell>{tag.wechat}</TableCell>
<TableCell>{tag.source}</TableCell>
<TableCell>{tag.orderAmount}</TableCell>
</TableRow>
))}
{importedTags.length > 5 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-gray-500">
{importedTags.length - 5}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
</>
)}
<div className="flex items-center justify-between">
<Label htmlFor="enabled"></Label>
<Switch
id="enabled"
checked={formData.enabled}
onCheckedChange={(checked) => onChange({ ...formData, enabled: checked })}
/>
</div>
<Button className="w-full h-12 text-base" onClick={onNext}>
</Button>
</div>
</Card>
{/* 账号选择对话框 */}
<Dialog open={isAccountDialogOpen} onOpenChange={setIsAccountDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="mt-4 max-h-[400px] overflow-y-auto">
<div className="space-y-2">
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center space-x-3 p-3 hover:bg-gray-100 rounded-lg cursor-pointer"
onClick={() => handleAccountSelect(account)}
>
<img src={account.avatar || "/placeholder.svg"} alt="" className="w-10 h-10 rounded-full" />
<span className="flex-1">{account.nickname}</span>
{selectedAccounts.find((a) => a.id === account.id) && (
<div className="w-4 h-4 rounded-full bg-blue-600" />
)}
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
{/* 二维码对话框 */}
<Dialog open={isQRCodeOpen} onOpenChange={setIsQRCodeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center p-6">
<div className="w-64 h-64 bg-gray-100 rounded-lg flex items-center justify-center">
<img src="/placeholder.svg?height=256&width=256" alt="二维码" className="w-full h-full" />
</div>
<p className="mt-4 text-sm text-gray-600">APP扫码</p>
</div>
</DialogContent>
</Dialog>
{/* 图片预览对话框 */}
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex justify-center items-center p-4">
<img src={previewImage || "/placeholder.svg"} alt="预览" className="max-h-[80vh] object-contain" />
</div>
</DialogContent>
</Dialog>
{/* 电话获客设置对话框 */}
<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>
{/* 订单导入对话框 */}
<Dialog open={isImportDialogOpen} onOpenChange={setIsImportDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-4">
<Input type="file" accept=".csv" onChange={handleFileImport} className="flex-1" />
</div>
<div className="max-h-[400px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importedTags.map((tag, index) => (
<TableRow key={index}>
<TableCell>{tag.phone}</TableCell>
<TableCell>{tag.wechat}</TableCell>
<TableCell>{tag.source}</TableCell>
<TableCell>{tag.orderAmount}</TableCell>
<TableCell>{tag.orderDate}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsImportDialogOpen(false)}>
</Button>
<Button
onClick={() => {
onChange({ ...formData, importedTags })
setIsImportDialogOpen(false)
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,278 @@
"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,555 @@
"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: "minutes",
},
],
},
])
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 = "minutes"
} 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

@@ -0,0 +1,205 @@
"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 { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Plus, X, Edit2, AlertCircle } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
interface TagSettingsProps {
formData: any
onChange: (data: any) => void
onNext?: () => void
onPrev?: () => void
}
interface Tag {
id: string
name: string
keywords: string[]
}
export function TagSettings({ formData, onChange, onNext, onPrev }: TagSettingsProps) {
const [tags, setTags] = useState<Tag[]>(formData.tags || [])
const [isAddTagDialogOpen, setIsAddTagDialogOpen] = useState(false)
const [editingTag, setEditingTag] = useState<Tag | null>(null)
const [newTagName, setNewTagName] = useState("")
const [newTagKeywords, setNewTagKeywords] = useState("")
const [hasWarnings, setHasWarnings] = useState(false)
// 当标签更新时更新formData
useEffect(() => {
onChange({ ...formData, tags })
}, [tags, onChange, formData])
// 检查是否有标签
useEffect(() => {
setHasWarnings(tags.length === 0)
}, [tags])
const handleAddTag = () => {
if (!newTagName.trim()) return
const keywordsArray = newTagKeywords
.split("\n")
.map((k) => k.trim())
.filter((k) => k !== "")
if (editingTag) {
// 编辑现有标签
setTags(
tags.map((tag) => (tag.id === editingTag.id ? { ...tag, name: newTagName, keywords: keywordsArray } : tag)),
)
} else {
// 添加新标签
setTags([
...tags,
{
id: Date.now().toString(),
name: newTagName,
keywords: keywordsArray,
},
])
}
// 重置表单
setNewTagName("")
setNewTagKeywords("")
setEditingTag(null)
setIsAddTagDialogOpen(false)
}
const handleEditTag = (tag: Tag) => {
setEditingTag(tag)
setNewTagName(tag.name)
setNewTagKeywords(tag.keywords.join("\n"))
setIsAddTagDialogOpen(true)
}
const handleDeleteTag = (tagId: string) => {
setTags(tags.filter((tag) => tag.id !== tagId))
}
const handleNext = () => {
// 确保onNext是一个函数
if (typeof onNext === "function") {
onNext()
}
}
const handlePrev = () => {
// 确保onPrev是一个函数
if (typeof onPrev === "function") {
onPrev()
}
}
const handleCancel = () => {
setNewTagName("")
setNewTagKeywords("")
setEditingTag(null)
setIsAddTagDialogOpen(false)
}
return (
<Card className="p-6">
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-4">
<Label className="text-base font-medium"></Label>
<Button onClick={() => setIsAddTagDialogOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{tags.length === 0 ? (
<div className="border rounded-md p-8 text-center text-gray-500">
"添加标签"
</div>
) : (
<div className="space-y-3">
{tags.map((tag) => (
<div key={tag.id} className="border rounded-md p-3 flex justify-between items-center">
<div>
<Badge className="mb-2">{tag.name}</Badge>
<div className="text-sm text-gray-500">
{tag.keywords.length > 0 ? `关键词: ${tag.keywords.join(", ")}` : "无关键词"}
</div>
</div>
<div className="flex space-x-2">
<Button variant="ghost" size="sm" onClick={() => handleEditTag(tag)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteTag(tag.id)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{hasWarnings && (
<Alert variant="warning" className="mt-4 bg-amber-50 border-amber-200">
<AlertCircle className="h-4 w-4 text-amber-500" />
<AlertDescription>便</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={handlePrev}>
</Button>
<Button onClick={handleNext}></Button>
</div>
</div>
<Dialog open={isAddTagDialogOpen} onOpenChange={setIsAddTagDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingTag ? "编辑标签" : "添加标签"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="bg-green-50 p-3 rounded-md text-sm text-green-700">
/
</div>
<div className="space-y-2">
<Input
placeholder="请输入标签名称(最长6位字符)"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value.slice(0, 6))}
/>
</div>
<div className="space-y-2">
<Textarea
placeholder="(非必填项) 请输入关键词,一行代表一个关键词"
rows={5}
value={newTagKeywords}
onChange={(e) => setNewTagKeywords(e.target.value)}
/>
</div>
</div>
<DialogFooter className="sm:justify-end">
<Button type="button" variant="outline" onClick={handleCancel}>
</Button>
<Button type="button" onClick={handleAddTag}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { ChevronRight, Settings, Bell, LogOut } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useRouter } from "next/navigation"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
const menuItems = [
{ href: "/devices", label: "设备管理" },
{ href: "/wechat-accounts", label: "微信号管理" },
{ href: "/traffic-pool", label: "流量池" },
{ href: "/content", label: "内容库" },
]
export default function ProfilePage() {
const router = useRouter()
const [isAuthenticated, setIsAuthenticated] = useState(true) // 模拟认证状态
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
const [accountId] = useState(() => Math.floor(10000000 + Math.random() * 90000000).toString())
const handleLogout = () => {
setIsAuthenticated(false)
setShowLogoutDialog(false)
router.push("/login")
}
if (!isAuthenticated) {
router.push("/login")
return null
}
return (
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white pb-16">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
<div className="flex justify-between items-center p-4">
<h1 className="text-xl font-semibold text-blue-600"></h1>
<div className="flex items-center space-x-2">
<Button variant="ghost" size="icon">
<Settings className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon">
<Bell className="w-5 h-5" />
</Button>
</div>
</div>
</header>
<div className="p-4 space-y-6">
{/* 用户信息卡片 */}
<Card className="p-6">
<div className="flex items-center space-x-4">
<Avatar className="w-20 h-20">
<AvatarImage src="https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=400&h=400&auto=format&fit=crop" />
<AvatarFallback>KR</AvatarFallback>
</Avatar>
<div className="flex-1">
<h2 className="text-xl font-semibold text-blue-600"></h2>
<p className="text-gray-500">: {accountId}</p>
<div className="mt-2">
<Button variant="outline" size="sm">
</Button>
</div>
</div>
</div>
</Card>
{/* 功能菜单 */}
<Card className="divide-y">
{menuItems.map((item) => (
<div
key={item.href || item.label}
className="p-4 flex items-center justify-between cursor-pointer hover:bg-gray-50"
onClick={() => (item.href ? router.push(item.href) : null)}
>
<div className="flex items-center">
{item.icon && <span className="mr-2">{item.icon}</span>}
<span>{item.label}</span>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</div>
))}
</Card>
{/* 退出登录按钮 */}
<Button
variant="ghost"
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 mt-6"
onClick={() => setShowLogoutDialog(true)}
>
<LogOut className="w-5 h-5 mr-2" />
退
</Button>
</div>
{/* 退出登录确认对话框 */}
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>退</DialogTitle>
<DialogDescription>退退使</DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={() => setShowLogoutDialog(false)}>
</Button>
<Button variant="destructive" onClick={handleLogout}>
退
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Plus } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation"
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
}
return channelMap[channel] || channel
}
interface Customer {
id: string
nickname: string
avatar: string
tags: string[]
acquiredTime: string
source: string
}
export default function AcquiredCustomersPage({ params }: { params: { channel: string } }) {
const router = useRouter()
const channelName = getChannelName(params.channel)
const [customers] = useState<Customer[]>(
Array.from({ length: 31 }, (_, i) => ({
id: `customer-${i + 1}`,
nickname: `用户${i + 1}`,
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
tags: ["直播间", "高互动", Math.random() > 0.5 ? "潜在客户" : "意向客户"],
acquiredTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
source: Math.random() > 0.5 ? "直播间" : "评论区",
})),
)
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
const totalPages = Math.ceil(customers.length / itemsPerPage)
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
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 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>
<div className="p-4 space-y-4">
{currentCustomers.map((customer) => (
<Card key={customer.id} className="p-4">
<div className="flex items-start space-x-4">
<img
src={customer.avatar || "/placeholder.svg"}
alt={customer.nickname}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">{customer.nickname}</h3>
<span className="text-sm text-gray-500">{customer.acquiredTime}</span>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{customer.tags.map((tag, index) => (
<Badge key={index} variant="secondary">
{tag}
</Badge>
))}
</div>
<div className="mt-2 text-sm text-gray-500">{customer.source}</div>
</div>
</div>
</Card>
))}
{totalPages > 1 && (
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-500">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Plus } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation"
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
}
return channelMap[channel] || channel
}
interface AddedCustomer {
id: string
nickname: string
avatar: string
tags: string[]
addedTime: string
source: string
wechatId: string
}
export default function AddedCustomersPage({ params }: { params: { channel: string } }) {
const router = useRouter()
const { channel } = params // Extract channel from params
const channelName = getChannelName(channel)
const [customers] = useState<AddedCustomer[]>(
Array.from({ length: 25 }, (_, i) => ({
id: `customer-${i + 1}`,
nickname: `用户${i + 1}`,
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
tags: ["已添加", Math.random() > 0.5 ? "高意向" : "待跟进", "直播间"],
addedTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
source: Math.random() > 0.5 ? "直播间" : "评论区",
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
})),
)
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
const totalPages = Math.ceil(customers.length / itemsPerPage)
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
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 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>
<div className="p-4 space-y-4">
{currentCustomers.map((customer) => (
<Card key={customer.id} className="p-4">
<div className="flex items-start space-x-4">
<img
src={customer.avatar || "/placeholder.svg"}
alt={customer.nickname}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">{customer.nickname}</h3>
<span className="text-sm text-gray-500">{customer.addedTime}</span>
</div>
<div className="mt-1 text-sm text-gray-500">{customer.wechatId}</div>
<div className="mt-2 flex flex-wrap gap-2">
{customer.tags.map((tag, index) => (
<Badge key={index} variant="secondary">
{tag}
</Badge>
))}
</div>
<div className="mt-2 text-sm text-gray-500">{customer.source}</div>
</div>
</div>
</Card>
))}
{totalPages > 1 && (
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-500">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,463 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Copy, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
haibao: "海报",
phone: "电话",
}
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 [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,
),
)
}
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">
<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>
</div>
</header>
<div className="p-4 max-w-7xl mx-auto">
<Tabs defaultValue="api-keys" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="api-keys">API密钥</TabsTrigger>
<TabsTrigger value="webhooks">Webhook</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" />
</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" />
<Button
variant="outline"
size="icon"
onClick={() => {
navigator.clipboard.writeText(apiKey.key)
toast({
title: "已复制",
description: "API密钥已复制到剪贴板",
variant: "success",
})
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
{apiKey.lastUsed && <p className="text-sm text-gray-500">使: {apiKey.lastUsed}</p>}
</div>
</CardContent>
</Card>
))}
{apiKeys.length === 0 && (
<div className="text-center py-8 bg-white rounded-lg shadow-sm">
<p className="text-gray-500">API密钥</p>
</div>
)}
</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>
</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>
<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>
</div>
</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>
<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>
)}
</div>
</CardContent>
</Card>
))}
{webhooks.length === 0 && (
<div className="text-center py-8 bg-white rounded-lg shadow-sm">
<p className="text-gray-500">Webhook</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
{/* 创建API密钥对话框 */}
<Dialog open={showNewApiKeyDialog} onOpenChange={setShowNewApiKeyDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>API密钥</DialogTitle>
<DialogDescription>API密钥用于访问{channelName}</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)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewApiKeyDialog(false)}>
</Button>
<Button onClick={handleCreateApiKey}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 创建Webhook对话框 */}
<Dialog open={showNewWebhookDialog} onOpenChange={setShowNewWebhookDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Webhook</DialogTitle>
<DialogDescription>Webhook用于接收{channelName}</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 })}
/>
</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>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewWebhookDialog(false)}>
</Button>
<Button onClick={handleCreateWebhook}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

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

View File

@@ -0,0 +1,266 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card } from "@/components/ui/card"
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"
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) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
}
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",
})
}
}
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">
<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>
</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="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()}>
</Button>
<Button onClick={handleSave} disabled={selectedDevices.length === 0}>
({selectedDevices.length})
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
"use client"
import { useState, useEffect } from "react"
import { ChevronLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { BasicSettings } from "../../../new/steps/BasicSettings"
import { FriendRequestSettings } from "../../../new/steps/FriendRequestSettings"
import { MessageSettings } from "../../../new/steps/MessageSettings"
import { TagSettings } from "../../../new/steps/TagSettings"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
const steps = [
{ id: 1, title: "步骤一", subtitle: "基础设置" },
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
{ id: 3, title: "步骤三", subtitle: "消息设置" },
{ id: 4, title: "步骤四", subtitle: "流量标签设置" },
]
export default function EditAcquisitionPlan({ params }: { params: { channel: string; id: string } }) {
const router = useRouter()
const [currentStep, setCurrentStep] = useState(1)
const [loading, setLoading] = useState(true)
const [formData, setFormData] = useState({
planName: "",
accounts: [],
dailyLimit: 10,
enabled: true,
remarkType: "phone",
remarkKeyword: "",
greeting: "",
addFriendTimeStart: "09:00",
addFriendTimeEnd: "18:00",
addFriendInterval: 1,
maxDailyFriends: 20,
messageInterval: 1,
messageContent: "",
})
useEffect(() => {
// 模拟从API获取计划数据
const fetchPlanData = async () => {
try {
// 这里应该是实际的API调用
const mockData = {
planName: "测试计划",
accounts: ["account1"],
dailyLimit: 15,
enabled: true,
remarkType: "phone",
remarkKeyword: "测试",
greeting: "你好",
addFriendTimeStart: "09:00",
addFriendTimeEnd: "18:00",
addFriendInterval: 2,
maxDailyFriends: 25,
messageInterval: 2,
messageContent: "欢迎",
}
setFormData(mockData)
setLoading(false)
} catch (error) {
toast({
title: "加载失败",
description: "获取计划数据失败,请重试",
variant: "destructive",
})
setLoading(false)
}
}
fetchPlanData()
}, [])
const handleSave = async () => {
try {
// 这里应该是实际的API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
toast({
title: "保存成功",
description: "获客计划已更新",
})
router.push(`/scenarios/${params.channel}`)
} catch (error) {
toast({
title: "保存失败",
description: "更新计划失败,请重试",
variant: "destructive",
})
}
}
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))
}
}
}
const isStepValid = () => {
switch (currentStep) {
case 1:
if (!formData.planName.trim() || formData.accounts.length === 0) {
toast({
title: "请完善信息",
description: "请填写计划名称并选择至少一个账号",
variant: "destructive",
})
return false
}
return true
case 2:
if (!formData.greeting.trim()) {
toast({
title: "请完善信息",
description: "请填写好友申请信息",
variant: "destructive",
})
return false
}
return true
case 3:
if (!formData.messageContent.trim()) {
toast({
title: "请完善信息",
description: "请填写消息内容",
variant: "destructive",
})
return false
}
return true
case 4:
return true
default:
return true
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-600">...</p>
</div>
</div>
)
}
const renderStepContent = () => {
switch (currentStep) {
case 1:
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} isEdit />
case 2:
return (
<FriendRequestSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
)
case 3:
return <MessageSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
case 4:
return <TagSettings formData={formData} onChange={setFormData} 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 h-14 px-4">
<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>
</header>
<div className="flex-1 flex flex-col">
<div className="px-4 py-6">
<div className="relative flex justify-between">
{steps.map((step) => (
<div
key={step.id}
className={cn(
"flex flex-col items-center relative z-10",
currentStep >= step.id ? "text-blue-600" : "text-gray-400",
)}
>
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center border-2 transition-colors",
currentStep >= step.id
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-300 bg-white text-gray-400",
)}
>
{step.id}
</div>
<div className="text-xs mt-1">{step.title}</div>
<div className="text-xs mt-0.5 font-medium">{step.subtitle}</div>
</div>
))}
<div className="absolute top-4 left-0 right-0 h-0.5 bg-gray-200 -z-10">
<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>
</div>
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
<div className="sticky bottom-0 left-0 right-0 bg-white border-t p-4">
<div className="flex justify-between max-w-[390px] mx-auto">
{currentStep > 1 && (
<Button variant="outline" onClick={handlePrev}>
</Button>
)}
<Button className={cn("min-w-[120px]", currentStep === 1 ? "w-full" : "ml-auto")} onClick={handleNext}>
{currentStep === steps.length ? "保存" : "下一步"}
</Button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,321 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Copy, Link, HelpCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
import { ScenarioAcquisitionCard } from "@/app/components/acquisition/ScenarioAcquisitionCard"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
haibao: "海报",
phone: "电话",
gongzhonghao: "公众号",
weixinqun: "微信群",
payment: "付款码",
api: "API",
}
return channelMap[channel] || channel
}
interface Task {
id: string
name: string
status: "running" | "paused" | "completed"
stats: {
devices: number
acquired: number
added: number
}
lastUpdated: string
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
}
interface DeviceStats {
active: number
}
// API文档提示组件
function ApiDocumentationTooltip() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">
API将外部系统的客户数据直接导入到存客宝
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export default function ChannelPage({ params }: { params: { channel: string } }) {
const router = useRouter()
const channel = params.channel
const channelName = getChannelName(params.channel)
const initialTasks = [
{
id: "1",
name: `${channelName}直播获客计划`,
status: "running",
stats: {
devices: 5,
acquired: 31,
added: 25,
},
lastUpdated: "2024-02-09 15:30",
executionTime: "2024-02-09 17:24:10",
nextExecutionTime: "2024-02-09 17:25:36",
trend: Array.from({ length: 7 }, (_, i) => ({
date: `2月${String(i + 1)}`,
customers: Math.floor(Math.random() * 30) + 30,
})),
},
{
id: "2",
name: `${channelName}评论区获客计划`,
status: "paused",
stats: {
devices: 3,
acquired: 15,
added: 12,
},
lastUpdated: "2024-02-09 14:00",
executionTime: "2024-02-09 16:30:00",
nextExecutionTime: "2024-02-09 16:45:00",
trend: Array.from({ length: 7 }, (_, i) => ({
date: `2月${String(i + 1)}`,
customers: Math.floor(Math.random() * 20) + 20,
})),
},
]
const [tasks, setTasks] = useState<Task[]>(initialTasks)
const [deviceStats, setDeviceStats] = useState<DeviceStats>({
active: 5,
})
const [showApiDialog, setShowApiDialog] = useState(false)
const [currentApiSettings, setCurrentApiSettings] = useState({
apiKey: "",
webhookUrl: "",
taskId: "",
})
const handleEditPlan = (taskId: string) => {
router.push(`/scenarios/${channel}/edit/${taskId}`)
}
const handleCopyPlan = (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId)
if (taskToCopy) {
const newTask = {
...taskToCopy,
id: `${Date.now()}`,
name: `${taskToCopy.name} (副本)`,
status: "paused" as const,
}
setTasks([...tasks, newTask])
toast({
title: "计划已复制",
description: `已成功复制"${taskToCopy.name}"`,
variant: "success",
})
}
}
const handleDeletePlan = (taskId: string) => {
const taskToDelete = tasks.find((t) => t.id === taskId)
if (taskToDelete) {
setTasks(tasks.filter((t) => t.id !== taskId))
toast({
title: "计划已删除",
description: `已成功删除"${taskToDelete.name}"`,
variant: "success",
})
}
}
const handleStatusChange = (taskId: string, newStatus: "running" | "paused") => {
setTasks(tasks.map((task) => (task.id === taskId ? { ...task, status: newStatus } : task)))
toast({
title: newStatus === "running" ? "计划已启动" : "计划已暂停",
description: `${newStatus === "running" ? "启动" : "暂停"}获客计划`,
variant: "success",
})
}
const handleOpenApiSettings = (taskId: string) => {
const task = tasks.find((t) => t.id === taskId)
if (task) {
setCurrentApiSettings({
apiKey: `api_${taskId}_${Math.random().toString(36).substring(2, 10)}`,
webhookUrl: `${window.location.origin}/api/scenarios/${channel}/${taskId}/webhook`,
taskId,
})
setShowApiDialog(true)
}
}
const handleCopyApiUrl = (url: string, withParams = false) => {
let copyUrl = url
if (withParams) {
copyUrl = `${url}?name=张三&phone=13800138000&source=外部系统&remark=测试数据`
}
navigator.clipboard.writeText(copyUrl)
toast({
title: "已复制",
description: withParams ? "接口地址(含示例参数)已复制到剪贴板" : "接口地址已复制到剪贴板",
variant: "success",
})
}
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()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-xl font-semibold text-blue-600">{channelName}</h1>
</div>
</div>
</header>
<div className="p-4 max-w-7xl mx-auto">
{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>
))
) : (
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
<div className="text-gray-400 mb-4"></div>
</div>
)}
</div>
{/* API接口设置对话框 */}
<Dialog open={showApiDialog} onOpenChange={setShowApiDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-2">
<DialogTitle></DialogTitle>
<ApiDocumentationTooltip />
</div>
<DialogDescription>使</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="api-key">API密钥</Label>
<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: "success",
})
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="webhook-url"></Label>
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl, true)}
>
</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>
<p className="text-xs text-gray-500">GET/POST请求namephone</p>
</div>
<div className="space-y-2">
<Label></Label>
<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>
</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>
</DialogContent>
</Dialog>
</div>
)
}

View File

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

View File

@@ -0,0 +1,236 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ChevronLeft, Search, Filter, RefreshCw, Plus } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
interface TrafficUser {
id: string
avatar: string
nickname: string
wechatId: string
phone: string
region: string
note: string
status: "pending" | "added" | "failed"
addTime: string
source: string
tags: string[]
}
export default function ChannelTrafficPage({
params,
searchParams,
}: {
params: { channel: string }
searchParams: { type?: string }
}) {
const router = useRouter()
const [users, setUsers] = useState<TrafficUser[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState(searchParams.type || "all")
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
}
return channelMap[channel] || channel
}
const channelName = getChannelName(params.channel)
useEffect(() => {
const fetchUsers = async () => {
// 模拟API调用
const mockUsers = Array.from({ length: 50 }, (_, i) => ({
id: `user-${i + 1}`,
avatar: "/placeholder.svg?height=40&width=40",
nickname: `用户${i + 1}`,
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () =>
Math.floor(Math.random() * 10),
).join("")}`,
region: ["广东", "浙江", "江苏", "北京", "上海"][Math.floor(Math.random() * 5)],
note: ["感兴趣", "需要了解", "想购买", "咨询价格"][Math.floor(Math.random() * 4)],
status: searchParams.type === "added" ? "added" : ["pending", "added", "failed"][Math.floor(Math.random() * 3)],
addTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
source: params.channel,
tags: ["意向客户", "高活跃度", "新用户"][Math.floor(Math.random() * 3)].split(" "),
}))
setUsers(mockUsers)
}
fetchUsers()
}, [params.channel, searchParams.type])
const filteredUsers = users.filter((user) => {
const matchesSearch =
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.phone.includes(searchQuery)
const matchesStatus = statusFilter === "all" || user.status === statusFilter
return matchesSearch && matchesStatus
})
const paginatedUsers = filteredUsers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage)
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">
<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-xl font-semibold text-blue-600">{channelName}</h1>
</div>
<Button
variant="default"
onClick={() => router.push(`/scenarios/${params.channel}/new`)}
className="flex items-center gap-1"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索用户"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center justify-between">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="added"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
{paginatedUsers.map((user) => (
<Card key={user.id} className="p-4">
<div className="flex items-start space-x-4">
<img
src={user.avatar || "/placeholder.svg"}
alt={user.nickname}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium truncate">{user.nickname}</div>
<Badge
variant={
user.status === "added" ? "success" : user.status === "failed" ? "destructive" : "secondary"
}
>
{user.status === "added" ? "已添加" : user.status === "failed" ? "已失败" : "待处理"}
</Badge>
</div>
<div className="mt-1 text-sm text-gray-500">
<div>: {user.wechatId}</div>
<div>: {user.phone}</div>
<div>: {user.region}</div>
<div>: {user.addTime}</div>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{user.tags.map((tag, index) => (
<Badge key={index} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
</div>
</Card>
))}
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage((prev) => Math.max(1, prev - 1))
}}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
isActive={currentPage === page}
onClick={(e) => {
e.preventDefault()
setCurrentPage(page)
}}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,374 @@
"use client"
import { useState } from "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"
import Link from "next/link"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { toast } from "@/components/ui/use-toast"
import { useRouter } from "next/navigation"
import type { Device } from "@/components/device-grid"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge" // Import Badge component
interface Task {
id: string
name: string
status: "running" | "paused" | "completed"
stats: {
devices: number
customersPerDevice: number
totalCalls: number
successCalls: number
errorCalls: number
successRate: number
}
lastUpdated: string
executionTime: string
nextExecutionTime: string
trend: { date: string; calls: number; success: number }[]
deviceList: Device[]
apiInfo: {
endpoint: string
method: string
token: string
}
}
// 生成随机数据的辅助函数
function generateRandomStats() {
const devices = Math.floor(Math.random() * 16) + 5
const customersPerDevice = Math.floor(Math.random() * 11) + 10
const totalCalls = Math.floor(Math.random() * 1000) + 500
const successCalls = Math.floor(totalCalls * (Math.random() * 0.3 + 0.6))
const errorCalls = totalCalls - successCalls
return {
devices,
customersPerDevice,
totalCalls,
successCalls,
errorCalls,
successRate: Math.round((successCalls / totalCalls) * 100),
}
}
export default function ApiPage() {
const [tasks, setTasks] = useState<Task[]>([
{
id: "1",
name: "APP加友接口",
status: "running",
stats: generateRandomStats(),
lastUpdated: "2024-02-09 15:30",
executionTime: "2024-02-09 17:24:10",
nextExecutionTime: "2024-02-09 17:25:36",
trend: Array.from({ length: 7 }, (_, i) => ({
date: `2024-02-${String(i + 1).padStart(2, "0")}`,
calls: Math.floor(Math.random() * 100) + 50,
success: Math.floor(Math.random() * 80) + 40,
})),
deviceList: [],
apiInfo: {
endpoint: "https://api.ckb.quwanzhi.com/api/open/task/addFriend",
method: "POST",
token: "ckb_token_xxxxx",
},
},
])
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const [isApiDocsOpen, setIsApiDocsOpen] = useState(false)
const router = useRouter()
const toggleTaskStatus = (taskId: string) => {
setTasks(
tasks.map((task) => {
if (task.id === taskId) {
return {
...task,
status: task.status === "running" ? "paused" : "running",
}
}
return task
}),
)
}
const copyTask = (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId)
if (taskToCopy) {
const newTask = {
...taskToCopy,
id: `${Date.now()}`,
name: `${taskToCopy.name} (副本)`,
stats: generateRandomStats(),
status: "paused" as const,
lastUpdated: new Date().toLocaleString(),
}
setTasks([...tasks, newTask])
toast({
title: "复制成功",
description: "已创建计划副本",
})
}
}
const handleTaskClick = (taskId: string) => {
router.push(`/scenarios/api/${taskId}/edit`)
}
const selectedTask = tasks.find((task) => task.id === selectedTaskId)
return (
<div className="flex-1 bg-gradient-to-b from-violet-50 to-white">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
<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">
<Button className="bg-violet-600 hover:bg-violet-700">
<Plus className="h-4 w-4 mr-2" />
</Button>
</Link>
</div>
</div>
</header>
<div className="p-4">
<div className="mb-6">
<div className="flex items-center space-x-4 mb-4">
<div className="flex-1 relative">
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
<Input className="pl-9" placeholder="搜索计划名称" />
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-4">
{tasks.map((task) => (
<Card
key={task.id}
className="p-6 hover:shadow-lg transition-all cursor-pointer"
onClick={() => handleTaskClick(task.id)}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Button
variant="ghost"
size="sm"
className={`px-2 py-1 text-xs rounded-full ${
task.status === "running"
? "bg-green-50 text-green-600"
: task.status === "paused"
? "bg-yellow-50 text-yellow-600"
: "bg-gray-50 text-gray-600"
}`}
onClick={(e) => {
e.stopPropagation()
toggleTaskStatus(task.id)
}}
>
{task.status === "running" ? "进行中" : task.status === "paused" ? "已暂停" : "已完成"}
</Button>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setIsApiDocsOpen(true)
}}
>
<Code className="w-4 h-4 mr-2" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" onClick={(e) => e.stopPropagation()}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => router.push(`/scenarios/api/${task.id}/edit`)}>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyTask(task.id)}>
<Copy className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem className="text-red-600"></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="text-center p-3 bg-violet-50 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="text-lg font-semibold text-violet-600">{task.stats.totalCalls}</div>
</div>
<div className="text-center p-3 bg-violet-50 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="text-lg font-semibold text-violet-600">{task.stats.successCalls}</div>
</div>
<div className="text-center p-3 bg-violet-50 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="text-lg font-semibold text-violet-600">{task.stats.errorCalls}</div>
</div>
<div className="text-center p-3 bg-violet-50 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="text-lg font-semibold text-violet-600">{task.stats.successRate}%</div>
</div>
</div>
<div className="space-y-4">
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={task.trend}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="calls" name="调用次数" stroke="#8b5cf6" strokeWidth={2} />
<Line type="monotone" dataKey="success" name="成功次数" stroke="#22c55e" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-between text-sm border-t pt-4">
<div className="flex items-center space-x-2 text-gray-500">
<Clock className="w-4 h-4" />
<span>: {task.executionTime}</span>
</div>
<div className="flex items-center space-x-2 text-gray-500">
<Clock className="w-4 h-4" />
<span>: {task.nextExecutionTime}</span>
</div>
</div>
</div>
</Card>
))}
</div>
</div>
<Dialog open={isApiDocsOpen} onOpenChange={setIsApiDocsOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>API </DialogTitle>
</DialogHeader>
<Tabs defaultValue="overview" className="mt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="endpoints"></TabsTrigger>
<TabsTrigger value="examples"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="p-4">
<div className="space-y-4">
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-gray-600">REST标准token作为鉴权</p>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm font-medium"></p>
<code className="text-sm text-violet-600">https://api.ckb.quwanzhi.com</code>
</div>
</div>
</TabsContent>
<TabsContent value="endpoints" className="p-4">
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4"></h3>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Badge variant="outline">POST</Badge> {/* Badge component used here */}
<code className="text-sm">/api/open/task/addFriend</code>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm font-medium mb-2"></p>
<table className="w-full text-sm">
<thead>
<tr className="text-left">
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>phone</td>
<td>string</td>
<td></td>
<td></td>
</tr>
<tr>
<td>tags</td>
<td>string</td>
<td></td>
<td></td>
</tr>
<tr>
<td>taskId</td>
<td>int</td>
<td></td>
<td>ID</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="examples" className="p-4">
<div className="space-y-4">
<h3 className="text-lg font-medium"></h3>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
<pre className="text-sm">
{JSON.stringify(
{
phone: "18956545898",
taskId: 593,
tags: "90后,女生,美女",
},
null,
2,
)}
</pre>
</div>
<h3 className="text-lg font-medium"></h3>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
<pre className="text-sm">
{JSON.stringify(
{
code: 10000,
data: null,
message: "操作成功",
},
null,
2,
)}
</pre>
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
import { ExpandableAcquisitionCard } from "@/components/acquisition/ExpandableAcquisitionCard"
import Link from "next/link"
import { DeviceTreeChart } from "@/app/components/acquisition/DeviceTreeChart"
interface Task {
id: string
name: string
status: "running" | "paused" | "completed"
stats: {
devices: number
acquired: number
added: number
}
lastUpdated: string
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
dailyData: { date: string; acquired: number; added: number }[]
}
export default function DouyinAcquisitionPage() {
const router = useRouter()
const channel = "douyin"
const [tasks, setTasks] = useState<Task[]>([
{
id: "1",
name: "抖音直播获客计划",
status: "running",
stats: {
devices: 3,
acquired: 45,
added: 32,
},
lastUpdated: "2024-03-18 15:30",
executionTime: "2024-03-18 15:30",
nextExecutionTime: "预计30分钟后",
trend: Array.from({ length: 7 }, (_, i) => ({
date: `3月${String(i + 12)}`,
customers: Math.floor(Math.random() * 10) + 5,
})),
dailyData: [
{ date: "3/12", acquired: 12, added: 8 },
{ date: "3/13", acquired: 15, added: 10 },
{ date: "3/14", acquired: 8, added: 6 },
{ date: "3/15", acquired: 10, added: 7 },
{ date: "3/16", acquired: 14, added: 11 },
{ date: "3/17", acquired: 9, added: 7 },
{ date: "3/18", acquired: 11, added: 9 },
],
},
])
const handleCopyPlan = (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId)
if (taskToCopy) {
const newTask = {
...taskToCopy,
id: `${Date.now()}`,
name: `${taskToCopy.name} (副本)`,
status: "paused" as const,
}
setTasks([...tasks, newTask])
toast({
title: "计划已复制",
description: `已成功复制"${taskToCopy.name}"`,
})
}
}
const handleDeletePlan = (taskId: string) => {
const taskToDelete = tasks.find((t) => t.id === taskId)
if (taskToDelete) {
setTasks(tasks.filter((t) => t.id !== taskId))
toast({
title: "计划已删除",
description: `已成功删除"${taskToDelete.name}"`,
})
}
}
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()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-xl font-semibold text-blue-600"></h1>
</div>
<Link href="/scenarios/douyin/new" className="ml-auto">
<Button className="flex items-center gap-1">
<Plus className="h-4 w-4" />
</Button>
</Link>
</div>
</header>
<div className="p-4 max-w-7xl mx-auto">
{tasks.map((task) => (
<ExpandableAcquisitionCard
key={task.id}
task={task}
channel={channel}
onCopy={handleCopyPlan}
onDelete={handleDeletePlan}
/>
))}
<DeviceTreeChart />
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More