diff --git a/.next/trace b/.next/trace new file mode 100644 index 00000000..447f1a2c --- /dev/null +++ b/.next/trace @@ -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"}] diff --git a/Cunkebao/.babelrc b/Cunkebao/.babelrc new file mode 100644 index 00000000..beaa8513 --- /dev/null +++ b/Cunkebao/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"] +} + diff --git a/Cunkebao/.gitignore b/Cunkebao/.gitignore index 1dcef2d9..f650315f 100644 --- a/Cunkebao/.gitignore +++ b/Cunkebao/.gitignore @@ -1,2 +1,27 @@ -node_modules -.env \ No newline at end of file +# 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 \ No newline at end of file diff --git a/Cunkebao/api/devices.ts b/Cunkebao/api/devices.ts new file mode 100644 index 00000000..c0b5642d --- /dev/null +++ b/Cunkebao/api/devices.ts @@ -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> { + 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> { + 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> { + const response = await fetch(`${API_BASE}/${id}`) + return response.json() + }, + + // 查询设备列表 + async query(params: QueryDeviceParams): Promise>> { + 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> { + const response = await fetch(`${API_BASE}/${id}`, { + method: "DELETE", + }) + return response.json() + }, + + // 重启设备 + async restart(id: string): Promise> { + const response = await fetch(`${API_BASE}/${id}/restart`, { + method: "POST", + }) + return response.json() + }, + + // 解绑设备 + async unbind(id: string): Promise> { + const response = await fetch(`${API_BASE}/${id}/unbind`, { + method: "POST", + }) + return response.json() + }, + + // 获取设备统计数据 + async getStats(id: string): Promise> { + const response = await fetch(`${API_BASE}/${id}/stats`) + return response.json() + }, + + // 获取设备任务记录 + async getTaskRecords(id: string, page = 1, pageSize = 20): Promise>> { + const response = await fetch(`${API_BASE}/${id}/tasks?page=${page}&pageSize=${pageSize}`) + return response.json() + }, + + // 批量更新设备标签 + async updateTags(ids: string[], tags: string[]): Promise> { + 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 { + 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>> { + const response = await fetch(`${API_BASE}/status`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ deviceIds: ids }), + }) + return response.json() + }, +} + diff --git a/Cunkebao/api/route.ts b/Cunkebao/api/route.ts new file mode 100644 index 00000000..f82d21d8 --- /dev/null +++ b/Cunkebao/api/route.ts @@ -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 = { + 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 }, + ) + } +} + diff --git a/Cunkebao/api/scenarios.ts b/Cunkebao/api/scenarios.ts new file mode 100644 index 00000000..d09b797b --- /dev/null +++ b/Cunkebao/api/scenarios.ts @@ -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> { + 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> { + 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> { + const response = await fetch(`${API_BASE}/${id}`) + return response.json() + }, + + // 查询场景列表 + async query(params: QueryScenarioParams): Promise>> { + 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> { + const response = await fetch(`${API_BASE}/${id}`, { + method: "DELETE", + }) + return response.json() + }, + + // 启动场景 + async start(id: string): Promise> { + const response = await fetch(`${API_BASE}/${id}/start`, { + method: "POST", + }) + return response.json() + }, + + // 暂停场景 + async pause(id: string): Promise> { + const response = await fetch(`${API_BASE}/${id}/pause`, { + method: "POST", + }) + return response.json() + }, + + // 获取场景统计数据 + async getStats(id: string): Promise> { + const response = await fetch(`${API_BASE}/${id}/stats`) + return response.json() + }, + + // 获取获客记录 + async getRecords(id: string, page = 1, pageSize = 20): Promise>> { + 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 { + 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> { + const response = await fetch(`${API_BASE}/${id}/tags`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ customerIds, tags }), + }) + return response.json() + }, +} + diff --git a/Cunkebao/app/api/acquisition/[planId]/orders/route.ts b/Cunkebao/app/api/acquisition/[planId]/orders/route.ts new file mode 100644 index 00000000..57754319 --- /dev/null +++ b/Cunkebao/app/api/acquisition/[planId]/orders/route.ts @@ -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 }) + } +} + diff --git a/Cunkebao/app/api/auth.ts b/Cunkebao/app/api/auth.ts new file mode 100644 index 00000000..c17c48c5 --- /dev/null +++ b/Cunkebao/app/api/auth.ts @@ -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 + } +} + diff --git a/Cunkebao/app/api/devices/route.ts b/Cunkebao/app/api/devices/route.ts new file mode 100644 index 00000000..6de78c12 --- /dev/null +++ b/Cunkebao/app/api/devices/route.ts @@ -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 = { + 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 }, + ) + } +} + diff --git a/Cunkebao/app/api/docs/scenarios/[channel]/[id]/page.tsx b/Cunkebao/app/api/docs/scenarios/[channel]/[id]/page.tsx new file mode 100644 index 00000000..4d123b1d --- /dev/null +++ b/Cunkebao/app/api/docs/scenarios/[channel]/[id]/page.tsx @@ -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(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 ( +
+
+
+
+ +

{apiGuide.title}

+
+
+
+ +
+ + + 接口说明 + {apiGuide.description} + + +
+
+ +

+ 此接口用于将外部系统收集的客户信息直接导入到存客宝的获客计划中。您需要使用API密钥进行身份验证。 +

+
+ +
+

+ 安全提示:{" "} + 请妥善保管您的API密钥,不要在客户端代码中暴露它。建议在服务器端使用该接口。 +

+
+
+
+
+ + + {apiGuide.endpoints.map((endpoint, index) => ( + + +
+ {endpoint.method} + {endpoint.url} +
+
+ +
+

{endpoint.description}

+ +
+

请求头

+
+ {endpoint.headers.map((header, i) => ( +
+ + {header.required ? "*" : ""} + {header.name} + +
+

{header.value}

+

{header.description}

+
+
+ ))} +
+
+ +
+

请求参数

+
+ {endpoint.parameters.map((param, i) => ( +
+ + {param.required ? "*" : ""} + {param.name} + +
+

+ {param.type} +

+

{param.description}

+
+
+ ))} +
+
+ +
+

响应示例

+
+                      {JSON.stringify(endpoint.response, null, 2)}
+                    
+
+
+
+
+ ))} +
+ + + + 代码示例 + 以下是不同编程语言的接口调用示例 + + + + + {apiGuide.examples.map((example) => ( + + {example.title} + + ))} + + + {apiGuide.examples.map((example) => ( + +
+
{example.code}
+ +
+
+ ))} +
+
+
+ +
+

集成指南

+ + + + 集简云平台集成 + + +
    +
  1. 登录集简云平台
  2. +
  3. 导航至"应用集成" > "外部接口"
  4. +
  5. 选择"添加新接口",输入存客宝接口信息
  6. +
  7. 配置回调参数,将"X-API-KEY"设置为您的API密钥
  8. +
  9. + 设置接口URL为: + + <code>{apiGuide.endpoints[0].url}</code> + +
  10. +
  11. 映射必要字段(name, phone等)
  12. +
  13. 保存并启用集成
  14. +
+
+
+ + + + 问题排查 + + +
+

接口认证失败

+

+ 请确保X-API-KEY正确无误,此密钥区分大小写。如需重置密钥,请联系管理员。 +

+
+ +
+

数据格式错误

+

+ 确保所有必填字段已提供,并且字段类型正确。特别是电话号码格式需符合标准。 +

+
+ +
+

请求频率限制

+

+ 单个API密钥每分钟最多可发送30个请求,超过限制将被暂时限制。对于大批量数据,请使用批量接口。 +

+
+
+
+
+
+
+ ) +} + diff --git a/Cunkebao/app/api/scenarios/route.ts b/Cunkebao/app/api/scenarios/route.ts new file mode 100644 index 00000000..f82d21d8 --- /dev/null +++ b/Cunkebao/app/api/scenarios/route.ts @@ -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 = { + 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 }, + ) + } +} + diff --git a/Cunkebao/app/api/users/route.ts b/Cunkebao/app/api/users/route.ts new file mode 100644 index 00000000..31046e37 --- /dev/null +++ b/Cunkebao/app/api/users/route.ts @@ -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() + +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, + }, + }) +} + diff --git a/Cunkebao/app/clientLayout.tsx b/Cunkebao/app/clientLayout.tsx new file mode 100644 index 00000000..512d186b --- /dev/null +++ b/Cunkebao/app/clientLayout.tsx @@ -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 ( +
+ {children} + {showBottomNav && } + {showBottomNav && } +
+ ) +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + + {children} + + + + + ) +} + diff --git a/Cunkebao/app/components/AIRewriteModal.tsx b/Cunkebao/app/components/AIRewriteModal.tsx new file mode 100644 index 00000000..ffec94ba --- /dev/null +++ b/Cunkebao/app/components/AIRewriteModal.tsx @@ -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 ( +
+ +
+

AI 内容改写

+ +
+
+
+

原始内容:

+

{originalContent}

+
+
+

改写后内容:

+

{rewrittenContent || '点击"开始改写"按钮生成内容'}

+
+
+
+ + +
+
+
+ ) +} + diff --git a/Cunkebao/app/components/AuthProvider.tsx b/Cunkebao/app/components/AuthProvider.tsx new file mode 100644 index 00000000..f57f4beb --- /dev/null +++ b/Cunkebao/app/components/AuthProvider.tsx @@ -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({ + isAuthenticated: false, + token: null, + login: () => {}, + logout: () => {}, +}) + +export const useAuth = () => useContext(AuthContext) + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [token, setToken] = useState(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 {children} +} + diff --git a/Cunkebao/app/components/BindDouyinQRCode.tsx b/Cunkebao/app/components/BindDouyinQRCode.tsx new file mode 100644 index 00000000..41b56809 --- /dev/null +++ b/Cunkebao/app/components/BindDouyinQRCode.tsx @@ -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 ( + <> + + + + + 绑定抖音号 + +
+
+ 抖音二维码 +
+

请使用抖音APP扫描二维码进行绑定

+
+
+
+ + ) +} + diff --git a/Cunkebao/app/components/BottomNav.tsx b/Cunkebao/app/components/BottomNav.tsx new file mode 100644 index 00000000..cc223551 --- /dev/null +++ b/Cunkebao/app/components/BottomNav.tsx @@ -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 ( + + ) +} + diff --git a/Cunkebao/app/components/Charts.tsx b/Cunkebao/app/components/Charts.tsx new file mode 100644 index 00000000..9a2ff235 --- /dev/null +++ b/Cunkebao/app/components/Charts.tsx @@ -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 ( + + + + + + + + + + ) +} + +export function BarChart() { + return ( + + + + + + + + + + ) +} + +export function TrendChart({ data, dataKey = "value", height = 300 }) { + return ( +
+ + + + + + + + + +
+ ) +} + diff --git a/Cunkebao/app/components/ChatMessage.tsx b/Cunkebao/app/components/ChatMessage.tsx new file mode 100644 index 00000000..45b239f6 --- /dev/null +++ b/Cunkebao/app/components/ChatMessage.tsx @@ -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 ( +
+ {!isUser && ( + +
+ AI +
+
+ )} + +
+

{content}

+
+ {timestamp.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+
+ + {isUser && avatar && ( + + User + + )} +
+ ) +} + diff --git a/Cunkebao/app/components/CircleSync/ContentSelector.tsx b/Cunkebao/app/components/CircleSync/ContentSelector.tsx new file mode 100644 index 00000000..76218921 --- /dev/null +++ b/Cunkebao/app/components/CircleSync/ContentSelector.tsx @@ -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([]) + + return ( + +
+

选择内容库

+ +
+ +
+
+ } /> +
+
+ + + + + 选择 + 内容库名称 + 类型 + 内容数量 + 操作 + + + + {mockLibraries.map((library) => ( + + + { + if (e.target.checked) { + setSelectedLibraries([...selectedLibraries, library.id]) + } else { + setSelectedLibraries(selectedLibraries.filter((id) => id !== library.id)) + } + }} + /> + + {library.name} + {library.type} + {library.count} + + + + + ))} + +
+ +
+ + +
+
+ ) +} + diff --git a/Cunkebao/app/components/CircleSync/DeviceSelector.tsx b/Cunkebao/app/components/CircleSync/DeviceSelector.tsx new file mode 100644 index 00000000..55fa79f2 --- /dev/null +++ b/Cunkebao/app/components/CircleSync/DeviceSelector.tsx @@ -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([]) + + return ( + +
+

选择推送设备

+ +
+ +
+
+ } + /> +
+
+ + + + + 选择 + 设备IMEI/备注/手机号 + 在线状态 + 加友状态 + 操作 + + + + {mockDevices.map((device) => ( + + + { + if (e.target.checked) { + setSelectedDevices([...selectedDevices, device.id]) + } else { + setSelectedDevices(selectedDevices.filter((id) => id !== device.id)) + } + }} + /> + + {device.imei} + + + {device.status === "online" ? "在线" : "离线"} + + + + + {device.friendStatus} + + + + + + + ))} + +
+ +
+ + +
+
+ ) +} + diff --git a/Cunkebao/app/components/CircleSync/TaskSetup.tsx b/Cunkebao/app/components/CircleSync/TaskSetup.tsx new file mode 100644 index 00000000..52b959bd --- /dev/null +++ b/Cunkebao/app/components/CircleSync/TaskSetup.tsx @@ -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 ( + +
+

朋友圈同步任务

+
+ + +
+
+ +
+
+ + +
+ +
+ +
+ setStartTime(e.target.value)} className="w-32" /> + + setEndTime(e.target.value)} className="w-32" /> +
+
+ +
+ +
+ + {syncCount} + + 条朋友圈 +
+
+ +
+ +
+ + +
+
+
+ +
+ {step > 1 ? ( + + ) : ( +
+ )} + +
+ + ) +} + diff --git a/Cunkebao/app/components/DeviceSelector.tsx b/Cunkebao/app/components/DeviceSelector.tsx new file mode 100644 index 00000000..cc876b07 --- /dev/null +++ b/Cunkebao/app/components/DeviceSelector.tsx @@ -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([]) + const [selectedDevices, setSelectedDevices] = useState(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 ( +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + +
+ +
+ + +
+ +
+ {paginatedDevices.map((device) => ( + +
+ handleDeviceSelect(device.id)} + /> +
+
+
{device.name}
+
+ {device.status === "online" ? "在线" : "离线"} +
+
+
IMEI: {device.imei}
+
+ {device.wechatAccounts.map((account) => ( +
+
+ {account.nickname} + {account.wechatId} +
+
+
+
+ 今日可添加: + {account.remainingAdds} + + + + + + +

每日最多添加 {account.maxDailyAdds} 个好友

+
+
+
+
+ + {account.todayAdded}/{account.maxDailyAdds} + +
+ +
+
+ ))} +
+ {!excludeUsedDevices && device.usedInPlans > 0 && ( +
已用于 {device.usedInPlans} 个计划
+ )} +
+
+
+ ))} +
+ + + + + { + e.preventDefault() + setCurrentPage((prev) => Math.max(1, prev - 1)) + }} + /> + + {Array.from({ length: Math.ceil(filteredDevices.length / devicesPerPage) }, (_, i) => i + 1).map((page) => ( + + { + e.preventDefault() + setCurrentPage(page) + }} + > + {page} + + + ))} + + { + e.preventDefault() + setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1)) + }} + /> + + + +
+ ) +} + diff --git a/Cunkebao/app/components/ErrorBoundary.tsx b/Cunkebao/app/components/ErrorBoundary.tsx new file mode 100644 index 00000000..fec4f469 --- /dev/null +++ b/Cunkebao/app/components/ErrorBoundary.tsx @@ -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 { + 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 ( + +

出错了

+

抱歉,应用程序遇到了一个错误。

+

{this.state.error?.message}

+ +
+ ) + } + + return this.props.children + } +} + +export default ErrorBoundary + diff --git a/Cunkebao/app/components/FileUploader.tsx b/Cunkebao/app/components/FileUploader.tsx new file mode 100644 index 00000000..6ef40946 --- /dev/null +++ b/Cunkebao/app/components/FileUploader.tsx @@ -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(null) + const [uploadProgress, setUploadProgress] = useState(0) + const [error, setError] = useState(null) + const inputRef = useRef(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) => { + 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 ( +
+ {!selectedFile ? ( +
+ + +

拖拽文件到此处,或

+ +

+ 支持 {acceptedTypes.replace(/\./g, "")} 格式,最大 {maxSize}MB +

+
+ ) : ( +
+
+
{selectedFile.name}
+ +
+ +
{uploadProgress}%
+
+ )} + + {error &&
{error}
} +
+ ) +} + diff --git a/Cunkebao/app/components/LayoutWrapper.tsx b/Cunkebao/app/components/LayoutWrapper.tsx new file mode 100644 index 00000000..30d84d72 --- /dev/null +++ b/Cunkebao/app/components/LayoutWrapper.tsx @@ -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 ( +
+ {children} + {showBottomNav && } + {showBottomNav && } +
+ ) +} + diff --git a/Cunkebao/app/components/SpeechToTextProcessor.tsx b/Cunkebao/app/components/SpeechToTextProcessor.tsx new file mode 100644 index 00000000..9f76b7c9 --- /dev/null +++ b/Cunkebao/app/components/SpeechToTextProcessor.tsx @@ -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(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 +} + diff --git a/Cunkebao/app/components/TrafficTeamSettings.tsx b/Cunkebao/app/components/TrafficTeamSettings.tsx new file mode 100644 index 00000000..a26c3261 --- /dev/null +++ b/Cunkebao/app/components/TrafficTeamSettings.tsx @@ -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([]) + const [isAddTeamOpen, setIsAddTeamOpen] = useState(false) + const [editingTeam, setEditingTeam] = useState(null) + const [newTeam, setNewTeam] = useState>({ + 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 ( + +
+
+

打粉团队设置

+ +
+ +
+ + + + 团队名称 + 佣金比例 + 操作 + + + + {teams.length === 0 ? ( + + + 暂无数据 + + + ) : ( + teams.map((team) => ( + + {team.name} + {team.commission}% + +
+ + +
+
+
+ )) + )} +
+
+
+
+ + + + + {editingTeam ? "编辑团队" : "添加团队"} + +
+
+ + setNewTeam({ ...newTeam, name: e.target.value })} + placeholder="请输入团队名称" + /> +
+
+ + setNewTeam({ ...newTeam, commission: Number(e.target.value) })} + placeholder="请输入佣金比例" + /> +
+
+ + + + +
+
+
+ ) +} + diff --git a/Cunkebao/app/components/VoiceRecognition.tsx b/Cunkebao/app/components/VoiceRecognition.tsx new file mode 100644 index 00000000..013a17ee --- /dev/null +++ b/Cunkebao/app/components/VoiceRecognition.tsx @@ -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 ( +
+
+
+
+
+
+
+
+
+

正在聆听...

+

请说出您的问题或指令,语音识别将自动结束

+ +
+
+
+ ) +} + diff --git a/Cunkebao/app/components/acquisition/AcquisitionPlanChart.tsx b/Cunkebao/app/components/acquisition/AcquisitionPlanChart.tsx new file mode 100644 index 00000000..b354b8fb --- /dev/null +++ b/Cunkebao/app/components/acquisition/AcquisitionPlanChart.tsx @@ -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([]) + + // 生成更真实的数据 + 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
暂无数据
+ } + + return ( +
+ + + + + + + + + + + + + + + + + {value}} + /> + + + + +
+ ) +} + diff --git a/Cunkebao/app/components/acquisition/DailyAcquisitionChart.tsx b/Cunkebao/app/components/acquisition/DailyAcquisitionChart.tsx new file mode 100644 index 00000000..f1e1d26f --- /dev/null +++ b/Cunkebao/app/components/acquisition/DailyAcquisitionChart.tsx @@ -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 ( +
+ + + + + + + + + + + +
+ ) +} + diff --git a/Cunkebao/app/components/acquisition/DeviceTreeChart.tsx b/Cunkebao/app/components/acquisition/DeviceTreeChart.tsx new file mode 100644 index 00000000..daf48e40 --- /dev/null +++ b/Cunkebao/app/components/acquisition/DeviceTreeChart.tsx @@ -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 ( + + +
+ 获客趋势 + + + 本周 + 本月 + + +
+
+ + + + + + + + + + + + +
+ ) +} + diff --git a/Cunkebao/app/components/acquisition/ExpandableAcquisitionCard.tsx b/Cunkebao/app/components/acquisition/ExpandableAcquisitionCard.tsx new file mode 100644 index 00000000..9764ee9a --- /dev/null +++ b/Cunkebao/app/components/acquisition/ExpandableAcquisitionCard.tsx @@ -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 ( +
+ +
+
+

{task.name}

+ + {task.status === "running" ? "进行中" : "已暂停"} + +
+
+ + + + + + handleEdit(task.id)}> + + 编辑计划 + + onCopy(task.id)}> + + 复制计划 + + {onOpenSettings && ( + onOpenSettings(task.id)}> + + 计划接口 + + )} + onDelete(task.id)} className="text-red-600"> + + 删除计划 + + + +
+
+ + + +
+ + + + + + + + + +
+ +
+
+ + 上次执行:{task.lastUpdated} +
+
+ + 下次执行:{task.nextExecutionTime} +
+
+
+ + {expanded && task.dailyData && ( +
+

每日获客数据

+
+ +
+
+ )} + +
+ +
+
+ ) +} + +// 计算通过率 +function calculatePassRate(acquired: number, added: number) { + if (acquired === 0) return 0 + return Math.round((added / acquired) * 100) +} + diff --git a/Cunkebao/app/components/acquisition/NewAcquisitionPlanForm.tsx b/Cunkebao/app/components/acquisition/NewAcquisitionPlanForm.tsx new file mode 100644 index 00000000..cf91166d --- /dev/null +++ b/Cunkebao/app/components/acquisition/NewAcquisitionPlanForm.tsx @@ -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 ( +
+
+ {/* 步骤指示器 */} +
+ {[1, 2, 3, 4].map((step) => ( +
= step ? "text-primary" : "text-gray-400"}`} + > +
= step ? "bg-primary text-white" : "bg-gray-100 text-gray-400" + }`} + > + {step} +
+
+ {step === 1 && "基础设置"} + {step === 2 && "好友设置"} + {step === 3 && "消息设置"} + {step === 4 && "流量标签"} +
+
+ ))} +
+
+ + +
+

基本信息

+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, description: e.target.value })} + /> +
+
+
+ + + +
+ + +
+ + ) +} + diff --git a/Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx b/Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx new file mode 100644 index 00000000..df5138b1 --- /dev/null +++ b/Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx @@ -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(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 ( + +
+
+

{task.name}

+ + {task.status === "running" ? "进行中" : "已暂停"} + +
+
+ + + {menuOpen && ( +
+ + + {onOpenSettings && ( + + )} + +
+ )} +
+
+ + + +
+
+ + 上次执行:{task.lastUpdated} +
+
+ + 下次执行:{task.nextExecutionTime} +
+
+
+ ) +} + +// 计算通过率 +function calculatePassRate(acquired: number, added: number) { + if (acquired === 0) return 0 + return Math.round((added / acquired) * 100) +} + diff --git a/Cunkebao/app/components/device-grid.tsx b/Cunkebao/app/components/device-grid.tsx new file mode 100644 index 00000000..9b5a5fe8 --- /dev/null +++ b/Cunkebao/app/components/device-grid.tsx @@ -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(null) + + const handleSelectAll = () => { + if (selectedDevices.length === devices.length) { + onSelect?.([]) + } else { + onSelect?.(devices.map((d) => d.id)) + } + } + + return ( +
+ {selectable && ( +
+
+ 0} + onCheckedChange={handleSelectAll} + /> + 全选 +
+ 已选择 {selectedDevices.length} 个设备 +
+ )} + +
+ {devices.map((device) => ( + { + if (selectable) { + const newSelection = selectedDevices.includes(device.id) + ? selectedDevices.filter((id) => id !== device.id) + : [...selectedDevices, device.id] + onSelect?.(newSelection) + } else { + setSelectedDevice(device) + } + }} + > +
+ {selectable && ( + e.stopPropagation()} + /> + )} +
+
+
{device.name}
+ + {device.status === "online" ? "在线" : "离线"} + +
+ +
+
+ + {device.battery}% +
+
+ + {device.friendCount} +
+
+ + {device.messageCount} +
+
+ + +{device.todayAdded} +
+
+ +
+
IMEI: {device.imei}
+
微信号: {device.wechatId}
+
+ + + {device.addFriendStatus === "normal" ? "加友正常" : "加友异常"} + +
+
+
+ ))} +
+ + setSelectedDevice(null)}> + + + 设备详情 + + {selectedDevice && ( +
+
+
+
+ +
+
+

{selectedDevice.name}

+

IMEI: {selectedDevice.imei}

+
+
+ + {selectedDevice.status === "online" ? "在线" : "离线"} + +
+ +
+
+
电池电量
+
+ + {selectedDevice.battery}% +
+
+
+
好友数量
+
+ + {selectedDevice.friendCount} +
+
+
+
今日新增
+
+ + +{selectedDevice.todayAdded} +
+
+
+
消息数量
+
+ + {selectedDevice.messageCount} +
+
+
+ +
+
微信账号
+
{selectedDevice.wechatId}
+
+ +
+
最后活跃
+
{selectedDevice.lastActive}
+
+ +
+
加友状态
+ + {selectedDevice.addFriendStatus === "normal" ? "加友正常" : "加友异常"} + +
+
+ )} +
+
+
+ ) +} + diff --git a/Cunkebao/app/components/device-selection-dialog.tsx b/Cunkebao/app/components/device-selection-dialog.tsx new file mode 100644 index 00000000..9e434c81 --- /dev/null +++ b/Cunkebao/app/components/device-selection-dialog.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + const [tagFilter, setTagFilter] = useState("all") + const [selectedDeviceIds, setSelectedDeviceIds] = useState([]) + 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 ( + + + + 选择设备 + + +
+ {/* 搜索和筛选区域 */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + +
+ + {/* 分类标签页 */} + + + 全部 + 在线 + 离线 + 未使用 + + + + {/* 筛选器 */} +
+ + + {allTags.length > 0 && ( + + )} + + +
+
+ + {/* 设备列表 */} + + {loading ? ( +
+
+
+ ) : filteredDevices.length === 0 ? ( +
+ {searchQuery || statusFilter !== "all" || tagFilter !== "all" || activeTab !== "all" + ? "没有符合条件的设备" + : "暂无设备数据"} +
+ ) : ( +
+ {filteredDevices.map((device) => ( + +
+ handleSelectDevice(device.id)} + id={`device-${device.id}`} + /> +
+
+ +
+ {device.status === "online" ? "在线" : "离线"} +
+
+
IMEI: {device.imei}
+ + {/* 微信账号信息 */} +
+ {device.wechatAccounts.map((account) => ( +
+
+ {account.nickname} + {account.wechatId} +
+
+
+ 今日可添加:{account.remainingAdds} + + {account.todayAdded}/{account.maxDailyAdds} + +
+
+
+ ))} +
+ + {/* 标签展示 */} + {device.tags && device.tags.length > 0 && ( +
+ {device.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {device.usedInPlans > 0 && ( +
已用于 {device.usedInPlans} 个计划
+ )} +
+
+
+ ))} +
+ )} +
+
+ + +
+ 已选择 {selectedDeviceIds.length} 个设备 +
+
+ + +
+
+
+
+ ) +} + diff --git a/Cunkebao/app/components/poster-selector.tsx b/Cunkebao/app/components/poster-selector.tsx new file mode 100644 index 00000000..a9584970 --- /dev/null +++ b/Cunkebao/app/components/poster-selector.tsx @@ -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 ( + + + + 选择海报 + + +
+

点击下方海报使用该模板

+
+ {templates.map((template) => ( +
{ + onSelect(template) + onOpenChange(false) + }} + > +
+ {template.title} +
+
+ +
+
+
{template.title}
+
{template.type}类型
+
+
+ ))} +
+
+ +
+ + +
+
+
+ ) +} + diff --git a/Cunkebao/app/components/traffic-pool-selector.tsx b/Cunkebao/app/components/traffic-pool-selector.tsx new file mode 100644 index 00000000..5b63e7a6 --- /dev/null +++ b/Cunkebao/app/components/traffic-pool-selector.tsx @@ -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([]) + 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([]) + + // 初始化已选用户 + 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 ( + + + + 选择流量池用户 + + +
+ {/* 搜索和筛选区域 */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ + {/* 分类标签页 */} + + + 全部 + 潜在客户 + 已转化 + 已流失 + + + + {/* 筛选器 */} +
+ + + + + +
+
+ + {/* 用户列表 */} + + {loading ? ( +
+
+
+ ) : filteredUsers.length === 0 ? ( +
+ {searchQuery || activeCategory !== "all" || sourceFilter !== "all" || tagFilter !== "all" + ? "没有符合条件的用户" + : "暂无用户数据"} +
+ ) : ( +
+ {filteredUsers.map((user) => ( + +
+ handleSelectUser(user.id)} + id={`user-${user.id}`} + /> +
+
+ +
+ {user.status === "added" ? "已添加" : user.status === "pending" ? "待处理" : "已失败"} +
+
+
微信号: {user.wechatId}
+
来源: {user.source}
+ + {/* 标签展示 */} +
+ {user.tags.map((tag) => ( + + {tag.name} + + ))} +
+
+
+
+ ))} +
+ )} +
+
+ + +
+ 已选择 {selectedUserIds.length} 个用户 +
+
+ + +
+
+
+
+ ) +} + diff --git a/Cunkebao/app/components/ui/accordion.tsx b/Cunkebao/app/components/ui/accordion.tsx new file mode 100644 index 00000000..9ab24898 --- /dev/null +++ b/Cunkebao/app/components/ui/accordion.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } + diff --git a/Cunkebao/app/components/ui/avatar.tsx b/Cunkebao/app/components/ui/avatar.tsx new file mode 100644 index 00000000..78f22e31 --- /dev/null +++ b/Cunkebao/app/components/ui/avatar.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } + diff --git a/Cunkebao/app/components/ui/badge.tsx b/Cunkebao/app/components/ui/badge.tsx new file mode 100644 index 00000000..1e46550d --- /dev/null +++ b/Cunkebao/app/components/ui/badge.tsx @@ -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, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
+} + +export { Badge, badgeVariants } + diff --git a/Cunkebao/app/components/ui/button.tsx b/Cunkebao/app/components/ui/button.tsx new file mode 100644 index 00000000..43591d59 --- /dev/null +++ b/Cunkebao/app/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ 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.displayName = "Button" + +export { Button, buttonVariants } + diff --git a/Cunkebao/app/components/ui/calendar.tsx b/Cunkebao/app/components/ui/calendar.tsx new file mode 100644 index 00000000..82c36f25 --- /dev/null +++ b/Cunkebao/app/components/ui/calendar.tsx @@ -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 + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } + diff --git a/Cunkebao/app/components/ui/card.tsx b/Cunkebao/app/components/ui/card.tsx new file mode 100644 index 00000000..0906be54 --- /dev/null +++ b/Cunkebao/app/components/ui/card.tsx @@ -0,0 +1,44 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>

, +) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } + diff --git a/Cunkebao/app/components/ui/checkbox.tsx b/Cunkebao/app/components/ui/checkbox.tsx new file mode 100644 index 00000000..29b66aac --- /dev/null +++ b/Cunkebao/app/components/ui/checkbox.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } + diff --git a/Cunkebao/app/components/ui/collapsible.tsx b/Cunkebao/app/components/ui/collapsible.tsx new file mode 100644 index 00000000..ef6b9f9b --- /dev/null +++ b/Cunkebao/app/components/ui/collapsible.tsx @@ -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 } + diff --git a/Cunkebao/app/components/ui/dialog.tsx b/Cunkebao/app/components/ui/dialog.tsx new file mode 100644 index 00000000..f17698be --- /dev/null +++ b/Cunkebao/app/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} + diff --git a/Cunkebao/app/components/ui/dropdown-menu.tsx b/Cunkebao/app/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..12b3500f --- /dev/null +++ b/Cunkebao/app/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} + diff --git a/Cunkebao/app/components/ui/input.tsx b/Cunkebao/app/components/ui/input.tsx new file mode 100644 index 00000000..79a855f9 --- /dev/null +++ b/Cunkebao/app/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ) +}) +Input.displayName = "Input" + +export { Input } + diff --git a/Cunkebao/app/components/ui/label.tsx b/Cunkebao/app/components/ui/label.tsx new file mode 100644 index 00000000..3d3ee5b1 --- /dev/null +++ b/Cunkebao/app/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } + diff --git a/Cunkebao/app/components/ui/pagination.tsx b/Cunkebao/app/components/ui/pagination.tsx new file mode 100644 index 00000000..26324aea --- /dev/null +++ b/Cunkebao/app/components/ui/pagination.tsx @@ -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">) => ( +