refactor: overhaul UI for streamlined user experience
Redesign navigation, home overview, user portrait, and valuation pages with improved functionality and responsive design. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# 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
|
||||
130
api/devices.ts
Normal file
130
api/devices.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
Device,
|
||||
DeviceStats,
|
||||
DeviceTaskRecord,
|
||||
PaginatedResponse,
|
||||
QueryDeviceParams,
|
||||
CreateDeviceParams,
|
||||
UpdateDeviceParams,
|
||||
DeviceStatus, // Added DeviceStatus import
|
||||
} from "@/types/device"
|
||||
|
||||
const API_BASE = "/api/devices"
|
||||
|
||||
// 设备管理API
|
||||
export const deviceApi = {
|
||||
// 创建设备
|
||||
async create(params: CreateDeviceParams): Promise<ApiResponse<Device>> {
|
||||
const response = await fetch(`${API_BASE}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 更新设备
|
||||
async update(params: UpdateDeviceParams): Promise<ApiResponse<Device>> {
|
||||
const response = await fetch(`${API_BASE}/${params.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取设备详情
|
||||
async getById(id: string): Promise<ApiResponse<Device>> {
|
||||
const response = await fetch(`${API_BASE}/${id}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 查询设备列表
|
||||
async query(params: QueryDeviceParams): Promise<ApiResponse<PaginatedResponse<Device>>> {
|
||||
const queryString = new URLSearchParams({
|
||||
...params,
|
||||
tags: params.tags ? JSON.stringify(params.tags) : "",
|
||||
dateRange: params.dateRange ? JSON.stringify(params.dateRange) : "",
|
||||
}).toString()
|
||||
|
||||
const response = await fetch(`${API_BASE}?${queryString}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 删除设备
|
||||
async delete(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 重启设备
|
||||
async restart(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/restart`, {
|
||||
method: "POST",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 解绑设备
|
||||
async unbind(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/unbind`, {
|
||||
method: "POST",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取设备统计数据
|
||||
async getStats(id: string): Promise<ApiResponse<DeviceStats>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/stats`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取设备任务记录
|
||||
async getTaskRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<DeviceTaskRecord>>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/tasks?page=${page}&pageSize=${pageSize}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 批量更新设备标签
|
||||
async updateTags(ids: string[], tags: string[]): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/tags`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ deviceIds: ids, tags }),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 批量导出设备数据
|
||||
async exportDevices(ids: string[]): Promise<Blob> {
|
||||
const response = await fetch(`${API_BASE}/export`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ deviceIds: ids }),
|
||||
})
|
||||
return response.blob()
|
||||
},
|
||||
|
||||
// 检查设备在线状态
|
||||
async checkStatus(ids: string[]): Promise<ApiResponse<Record<string, DeviceStatus>>> {
|
||||
const response = await fetch(`${API_BASE}/status`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ deviceIds: ids }),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
73
api/route.ts
Normal file
73
api/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { CreateScenarioParams, QueryScenarioParams, ScenarioBase, ApiResponse } from "@/types/scenario"
|
||||
|
||||
// 获客场景路由处理
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body: CreateScenarioParams = await request.json()
|
||||
|
||||
// TODO: 实现创建场景的具体逻辑
|
||||
const scenario: ScenarioBase = {
|
||||
id: "generated-id",
|
||||
...body,
|
||||
status: "draft",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
creator: "current-user-id",
|
||||
}
|
||||
|
||||
const response: ApiResponse<ScenarioBase> = {
|
||||
code: 0,
|
||||
message: "创建成功",
|
||||
data: scenario,
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "创建失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params: QueryScenarioParams = {
|
||||
type: searchParams.get("type") as any,
|
||||
status: searchParams.get("status") as any,
|
||||
keyword: searchParams.get("keyword") || undefined,
|
||||
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
|
||||
page: Number(searchParams.get("page")) || 1,
|
||||
pageSize: Number(searchParams.get("pageSize")) || 20,
|
||||
}
|
||||
|
||||
// TODO: 实现查询场景列表的具体逻辑
|
||||
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
message: "查询成功",
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalPages: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "查询失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
111
api/scenarios.ts
Normal file
111
api/scenarios.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
CreateScenarioParams,
|
||||
UpdateScenarioParams,
|
||||
QueryScenarioParams,
|
||||
ScenarioBase,
|
||||
ScenarioStats,
|
||||
AcquisitionRecord,
|
||||
PaginatedResponse,
|
||||
} from "@/types/scenario"
|
||||
|
||||
const API_BASE = "/api/scenarios"
|
||||
|
||||
// 获客场景API
|
||||
export const scenarioApi = {
|
||||
// 创建场景
|
||||
async create(params: CreateScenarioParams): Promise<ApiResponse<ScenarioBase>> {
|
||||
const response = await fetch(`${API_BASE}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 更新场景
|
||||
async update(params: UpdateScenarioParams): Promise<ApiResponse<ScenarioBase>> {
|
||||
const response = await fetch(`${API_BASE}/${params.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取场景详情
|
||||
async getById(id: string): Promise<ApiResponse<ScenarioBase>> {
|
||||
const response = await fetch(`${API_BASE}/${id}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 查询场景列表
|
||||
async query(params: QueryScenarioParams): Promise<ApiResponse<PaginatedResponse<ScenarioBase>>> {
|
||||
const queryString = new URLSearchParams({
|
||||
...params,
|
||||
dateRange: params.dateRange ? JSON.stringify(params.dateRange) : "",
|
||||
}).toString()
|
||||
|
||||
const response = await fetch(`${API_BASE}?${queryString}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 删除场景
|
||||
async delete(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 启动场景
|
||||
async start(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/start`, {
|
||||
method: "POST",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 暂停场景
|
||||
async pause(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/pause`, {
|
||||
method: "POST",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取场景统计数据
|
||||
async getStats(id: string): Promise<ApiResponse<ScenarioStats>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/stats`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取获客记录
|
||||
async getRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<AcquisitionRecord>>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/records?page=${page}&pageSize=${pageSize}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 导出获客记录
|
||||
async exportRecords(id: string, dateRange?: { start: string; end: string }): Promise<Blob> {
|
||||
const queryString = dateRange ? `?start=${dateRange.start}&end=${dateRange.end}` : ""
|
||||
const response = await fetch(`${API_BASE}/${id}/records/export${queryString}`)
|
||||
return response.blob()
|
||||
},
|
||||
|
||||
// 批量更新标签
|
||||
async updateTags(id: string, customerIds: string[], tags: string[]): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/tags`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ customerIds, tags }),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
71
app/ClientLayout.tsx
Normal file
71
app/ClientLayout.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import "./globals.css"
|
||||
import { Inter } from "next/font/google"
|
||||
import { useState, useEffect } from "react"
|
||||
import Sidebar from "./components/Sidebar"
|
||||
import MobileHeader from "./components/MobileHeader"
|
||||
import MobileSidebar from "./components/MobileSidebar"
|
||||
import BottomNav from "./components/BottomNav"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
|
||||
export default function ClientLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>用户数据资产中台</title>
|
||||
<meta name="description" content="基于苹果毛玻璃设计的用户数据资产中台" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
{/* 背景装饰 */}
|
||||
<div className="fixed inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 via-white to-purple-50" />
|
||||
<div className="absolute top-0 left-0 w-72 h-72 md:w-96 md:h-96 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-pulse" />
|
||||
<div className="absolute top-0 right-0 w-72 h-72 md:w-96 md:h-96 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-pulse animation-delay-2000" />
|
||||
<div className="absolute bottom-0 left-1/2 w-72 h-72 md:w-96 md:h-96 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-pulse animation-delay-4000" />
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-screen">
|
||||
{/* 桌面端侧边栏 */}
|
||||
{!isMobile && <Sidebar />}
|
||||
|
||||
{/* 移动端侧边栏 */}
|
||||
{isMobile && <MobileSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />}
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className={`flex-1 ${isMobile ? "pb-20" : "p-6"}`}>
|
||||
{/* 移动端头部 */}
|
||||
{isMobile && <MobileHeader onMenuToggle={() => setSidebarOpen(true)} />}
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className={`glass-card min-h-full ${isMobile ? "mx-2 mb-4" : ""}`}>{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 移动端底部导航 */}
|
||||
{isMobile && <BottomNav />}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
786
app/ai-assistant/page.tsx
Normal file
786
app/ai-assistant/page.tsx
Normal file
@@ -0,0 +1,786 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Sparkles, BarChart, TrendingUp, Users, Mail, Clock, Download, ChevronRight } from "lucide-react"
|
||||
|
||||
export default function AIAssistantPage() {
|
||||
const [activeTab, setActiveTab] = useState("data-analysis")
|
||||
const [selectedUserGroup, setSelectedUserGroup] = useState("")
|
||||
const [isUserGroupDialogOpen, setIsUserGroupDialogOpen] = useState(false)
|
||||
const [emailAddress, setEmailAddress] = useState("")
|
||||
const [scheduleReport, setScheduleReport] = useState(false)
|
||||
|
||||
// 分析流程步骤
|
||||
const [analysisStep, setAnalysisStep] = useState(1)
|
||||
const [analysisType, setAnalysisType] = useState("")
|
||||
|
||||
// 营销策略步骤
|
||||
const [strategyStep, setStrategyStep] = useState(1)
|
||||
const [strategyType, setStrategyType] = useState("")
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">AI 智能助手</h1>
|
||||
<p className="text-muted-foreground">利用AI技术分析数据并提供营销策略</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="data-analysis">数据分析</TabsTrigger>
|
||||
<TabsTrigger value="marketing-strategy">营销策略</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="data-analysis" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 数据分析</CardTitle>
|
||||
<CardDescription>使用AI分析用户数据并生成洞察报告</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 第1步:选择用户分群 */}
|
||||
<div className={`space-y-4 ${analysisStep !== 1 ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-white text-sm mr-2">
|
||||
1
|
||||
</div>
|
||||
选择用户分群
|
||||
</h3>
|
||||
{analysisStep > 1 ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setAnalysisStep(1)}>
|
||||
修改
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setIsUserGroupDialogOpen(true)}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
选择用户分群
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUserGroup && (
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium">已选择用户分群:</span>
|
||||
<Badge className="bg-blue-100 text-blue-800">{selectedUserGroup}</Badge>
|
||||
</div>
|
||||
{analysisStep === 1 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAnalysisStep(2)
|
||||
}}
|
||||
disabled={!selectedUserGroup}
|
||||
>
|
||||
下一步 <ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 第2步:选择分析类型 */}
|
||||
<div
|
||||
className={`space-y-4 ${analysisStep !== 2 ? (analysisStep > 2 ? "opacity-60" : "opacity-40") : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-white text-sm mr-2">
|
||||
2
|
||||
</div>
|
||||
选择分析类型
|
||||
</h3>
|
||||
{analysisStep > 2 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setAnalysisStep(2)}>
|
||||
修改
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analysisStep >= 2 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card
|
||||
className={`border-2 cursor-pointer hover:shadow-md transition-all ${analysisType === "behavior" ? "border-primary" : "border-transparent"}`}
|
||||
onClick={() => setAnalysisType("behavior")}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">用户行为分析</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
分析用户的行为模式、偏好和习惯
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{analysisType === "behavior" && <Badge className="bg-primary/10 text-primary">已选择</Badge>}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card
|
||||
className={`border-2 cursor-pointer hover:shadow-md transition-all ${analysisType === "value" ? "border-primary" : "border-transparent"}`}
|
||||
onClick={() => setAnalysisType("value")}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">用户估值分析</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">分析用户的价值分布和潜在价值</CardContent>
|
||||
<CardFooter>
|
||||
{analysisType === "value" && <Badge className="bg-primary/10 text-primary">已选择</Badge>}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card
|
||||
className={`border-2 cursor-pointer hover:shadow-md transition-all ${analysisType === "churn" ? "border-primary" : "border-transparent"}`}
|
||||
onClick={() => setAnalysisType("churn")}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">用户流失预警</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
预测可能流失的用户并提供挽留建议
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{analysisType === "churn" && <Badge className="bg-primary/10 text-primary">已选择</Badge>}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysisStep === 2 && analysisType && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setAnalysisStep(3)}>
|
||||
下一步 <ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 第3步:分析参数设置 */}
|
||||
<div
|
||||
className={`space-y-4 ${analysisStep !== 3 ? (analysisStep > 3 ? "opacity-60" : "opacity-40") : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-white text-sm mr-2">
|
||||
3
|
||||
</div>
|
||||
分析参数设置
|
||||
</h3>
|
||||
{analysisStep > 3 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setAnalysisStep(3)}>
|
||||
修改
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analysisStep >= 3 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time-range">时间范围</Label>
|
||||
<Select defaultValue="30days">
|
||||
<SelectTrigger id="time-range">
|
||||
<SelectValue placeholder="选择时间范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7days">最近7天</SelectItem>
|
||||
<SelectItem value="30days">最近30天</SelectItem>
|
||||
<SelectItem value="90days">最近90天</SelectItem>
|
||||
<SelectItem value="custom">自定义范围</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="analysis-depth">分析深度</Label>
|
||||
<Select defaultValue="medium">
|
||||
<SelectTrigger id="analysis-depth">
|
||||
<SelectValue placeholder="选择分析深度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">基础分析</SelectItem>
|
||||
<SelectItem value="medium">中等深度</SelectItem>
|
||||
<SelectItem value="deep">深度分析</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysisStep === 3 && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setAnalysisStep(4)}>
|
||||
下一步 <ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 第4步:报告设置 */}
|
||||
<div className={`space-y-4 ${analysisStep !== 4 ? "opacity-40" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-white text-sm mr-2">
|
||||
4
|
||||
</div>
|
||||
报告设置
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{analysisStep >= 4 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label htmlFor="email">发送分析报告至</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="输入邮箱地址"
|
||||
value={emailAddress}
|
||||
onChange={(e) => setEmailAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="schedule-report" checked={scheduleReport} onCheckedChange={setScheduleReport} />
|
||||
<Label htmlFor="schedule-report">定期发送报告</Label>
|
||||
</div>
|
||||
{scheduleReport && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule-frequency">发送频率</Label>
|
||||
<Select defaultValue="weekly">
|
||||
<SelectTrigger id="schedule-frequency">
|
||||
<SelectValue placeholder="选择发送频率" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">每日</SelectItem>
|
||||
<SelectItem value="weekly">每周</SelectItem>
|
||||
<SelectItem value="monthly">每月</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule-time">发送时间</Label>
|
||||
<Select defaultValue="morning">
|
||||
<SelectTrigger id="schedule-time">
|
||||
<SelectValue placeholder="选择发送时间" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="morning">上午 9:00</SelectItem>
|
||||
<SelectItem value="noon">中午 12:00</SelectItem>
|
||||
<SelectItem value="evening">下午 6:00</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setAnalysisStep(1)
|
||||
setAnalysisType("")
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button disabled={analysisStep < 4 || !emailAddress}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
开始分析
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>分析结果预览</CardTitle>
|
||||
<CardDescription>AI生成的数据分析结果</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-96 flex items-center justify-center">
|
||||
{selectedUserGroup && analysisStep === 4 ? (
|
||||
<div className="text-center space-y-4">
|
||||
<BarChart className="h-16 w-16 mx-auto text-primary" />
|
||||
<p className="text-lg font-medium">分析结果将在这里显示</p>
|
||||
<p className="text-muted-foreground">点击"开始分析"按钮生成分析报告</p>
|
||||
<div className="flex justify-center gap-2 pt-4">
|
||||
<Button variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载报告
|
||||
</Button>
|
||||
<Button>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
发送报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Users className="h-16 w-16 mx-auto mb-4" />
|
||||
<p className="text-lg">请完成所有分析步骤</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setIsUserGroupDialogOpen(true)}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
开始设置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="marketing-strategy" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 营销策略</CardTitle>
|
||||
<CardDescription>使用AI生成针对性的营销策略和建议</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 第1步:选择目标用户分群 */}
|
||||
<div className={`space-y-4 ${strategyStep !== 1 ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-white text-sm mr-2">
|
||||
1
|
||||
</div>
|
||||
选择目标用户分群
|
||||
</h3>
|
||||
{strategyStep > 1 ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setStrategyStep(1)}>
|
||||
修改
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setIsUserGroupDialogOpen(true)}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
选择用户分群
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUserGroup && (
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium">已选择用户分群:</span>
|
||||
<Badge className="bg-blue-100 text-blue-800">{selectedUserGroup}</Badge>
|
||||
</div>
|
||||
{strategyStep === 1 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setStrategyStep(2)
|
||||
}}
|
||||
disabled={!selectedUserGroup}
|
||||
>
|
||||
下一步 <ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 第2步:选择策略类型 */}
|
||||
<div
|
||||
className={`space-y-4 ${strategyStep !== 2 ? (strategyStep > 2 ? "opacity-60" : "opacity-40") : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-white text-sm mr-2">
|
||||
2
|
||||
</div>
|
||||
选择策略类型
|
||||
</h3>
|
||||
{strategyStep > 2 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setStrategyStep(2)}>
|
||||
修改
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{strategyStep >= 2 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card
|
||||
className={`border-2 cursor-pointer hover:shadow-md transition-all ${strategyType === "sales" ? "border-primary" : "border-transparent"}`}
|
||||
onClick={() => setStrategyType("sales")}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">销售预测</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
预测未来销售趋势并提供增长建议
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{strategyType === "sales" && <Badge className="bg-primary/10 text-primary">已选择</Badge>}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card
|
||||
className={`border-2 cursor-pointer hover:shadow-md transition-all ${strategyType === "reach" ? "border-primary" : "border-transparent"}`}
|
||||
onClick={() => setStrategyType("reach")}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">用户触达策略</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
生成针对性的用户触达和转化策略
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{strategyType === "reach" && <Badge className="bg-primary/10 text-primary">已选择</Badge>}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card
|
||||
className={`border-2 cursor-pointer hover:shadow-md transition-all ${strategyType === "content" ? "border-primary" : "border-transparent"}`}
|
||||
onClick={() => setStrategyType("content")}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">内容营销策略</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
生成针对目标用户的内容营销策略
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{strategyType === "content" && <Badge className="bg-primary/10 text-primary">已选择</Badge>}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{strategyStep === 2 && strategyType && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setStrategyStep(3)}>
|
||||
下一步 <ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 第3步:策略参数设置 */}
|
||||
<div className={`space-y-4 ${strategyStep !== 3 ? "opacity-40" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-white text-sm mr-2">
|
||||
3
|
||||
</div>
|
||||
策略参数设置
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{strategyStep >= 3 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="strategy-goal">营销目标</Label>
|
||||
<Select defaultValue="conversion">
|
||||
<SelectTrigger id="strategy-goal">
|
||||
<SelectValue placeholder="选择营销目标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="awareness">提升品牌认知</SelectItem>
|
||||
<SelectItem value="engagement">增加用户互动</SelectItem>
|
||||
<SelectItem value="conversion">提高转化率</SelectItem>
|
||||
<SelectItem value="retention">提升用户留存</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="strategy-budget">预算范围</Label>
|
||||
<Select defaultValue="medium">
|
||||
<SelectTrigger id="strategy-budget">
|
||||
<SelectValue placeholder="选择预算范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">低预算</SelectItem>
|
||||
<SelectItem value="medium">中等预算</SelectItem>
|
||||
<SelectItem value="high">高预算</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="strategy-notes">补充说明</Label>
|
||||
<Textarea
|
||||
id="strategy-notes"
|
||||
placeholder="请输入任何补充说明或特殊要求..."
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label htmlFor="email-strategy">发送策略报告至</Label>
|
||||
<Input
|
||||
id="email-strategy"
|
||||
type="email"
|
||||
placeholder="输入邮箱地址"
|
||||
value={emailAddress}
|
||||
onChange={(e) => setEmailAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="schedule-strategy" checked={scheduleReport} onCheckedChange={setScheduleReport} />
|
||||
<Label htmlFor="schedule-strategy">定期更新策略</Label>
|
||||
</div>
|
||||
{scheduleReport && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-frequency">更新频率</Label>
|
||||
<Select defaultValue="monthly">
|
||||
<SelectTrigger id="update-frequency">
|
||||
<SelectValue placeholder="选择更新频率" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="weekly">每周</SelectItem>
|
||||
<SelectItem value="biweekly">每两周</SelectItem>
|
||||
<SelectItem value="monthly">每月</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStrategyStep(1)
|
||||
setStrategyType("")
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button disabled={strategyStep < 3 || !emailAddress}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
生成策略
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>策略建议预览</CardTitle>
|
||||
<CardDescription>AI生成的营销策略建议</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-96 flex items-center justify-center">
|
||||
{selectedUserGroup && strategyStep === 3 ? (
|
||||
<div className="text-center space-y-4">
|
||||
<TrendingUp className="h-16 w-16 mx-auto text-primary" />
|
||||
<p className="text-lg font-medium">策略建议将在这里显示</p>
|
||||
<p className="text-muted-foreground">点击"生成策略"按钮生成营销策略</p>
|
||||
<div className="flex justify-center gap-2 pt-4">
|
||||
<Button variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载策略
|
||||
</Button>
|
||||
<Button>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
发送策略
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Users className="h-16 w-16 mx-auto mb-4" />
|
||||
<p className="text-lg">请完成所有策略步骤</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setIsUserGroupDialogOpen(true)}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
开始设置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<UserGroupSelectionDialog
|
||||
isOpen={isUserGroupDialogOpen}
|
||||
setIsOpen={setIsUserGroupDialogOpen}
|
||||
onSelect={setSelectedUserGroup}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 用户分群选择对话框
|
||||
function UserGroupSelectionDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
onSelect,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
onSelect: (group: string) => void
|
||||
}) {
|
||||
// 模拟用户分群数据
|
||||
const userGroups = [
|
||||
{
|
||||
id: "1",
|
||||
name: "高价值用户",
|
||||
description: "消费能力强,购买频率高的用户",
|
||||
count: 12456,
|
||||
category: "用户资产",
|
||||
lastUpdated: "2023-07-20",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "活跃用户",
|
||||
description: "近30天内有活动的用户",
|
||||
count: 45678,
|
||||
category: "用户资产",
|
||||
lastUpdated: "2023-07-20",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "地摊商户",
|
||||
description: "从事地摊相关业务的用户",
|
||||
count: 8765,
|
||||
category: "地摊相关",
|
||||
lastUpdated: "2023-07-19",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "樊登读书会员",
|
||||
description: "樊登读书平台的会员用户",
|
||||
count: 15678,
|
||||
category: "樊登读书",
|
||||
lastUpdated: "2023-07-18",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "魔兽玩家",
|
||||
description: "魔兽世界游戏的活跃玩家",
|
||||
count: 23456,
|
||||
category: "魔兽世界",
|
||||
lastUpdated: "2023-07-17",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "流失风险用户",
|
||||
description: "有流失风险的高价值用户",
|
||||
count: 5678,
|
||||
category: "用户资产",
|
||||
lastUpdated: "2023-07-16",
|
||||
},
|
||||
]
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedCategory, setSelectedCategory] = useState("all")
|
||||
|
||||
// 过滤用户分群
|
||||
const filteredGroups = userGroups.filter((group) => {
|
||||
const matchesSearch =
|
||||
group.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
group.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesCategory = selectedCategory === "all" || group.category === selectedCategory
|
||||
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
// 获取所有分类
|
||||
const categories = ["all", ...new Set(userGroups.map((group) => group.category))]
|
||||
|
||||
const handleSelect = (group: string) => {
|
||||
onSelect(group)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择用户分群</DialogTitle>
|
||||
<DialogDescription>从用户画像中选择要分析的用户分群</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="搜索用户分群..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue placeholder="选择场景分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category === "all" ? "所有场景" : category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-2">
|
||||
{filteredGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="p-3 border rounded-lg hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleSelect(group.name)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{group.description}</div>
|
||||
</div>
|
||||
<Badge className="bg-blue-100 text-blue-800">{group.category}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
{group.count.toLocaleString()} 用户
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
更新于 {group.lastUpdated}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpen(false)}>创建新分群</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
3
app/api-interface/loading.tsx
Normal file
3
app/api-interface/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
152
app/api-interface/page.tsx
Normal file
152
app/api-interface/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface ApiInterface {
|
||||
id: string
|
||||
name: string
|
||||
endpoint: string
|
||||
method: string
|
||||
status: "active" | "inactive" | "deprecated"
|
||||
stats: {
|
||||
totalCalls: number
|
||||
successCalls: number
|
||||
errorCalls: number
|
||||
successRate: number
|
||||
avgResponseTime: number
|
||||
}
|
||||
lastUpdated: string
|
||||
trend: { date: string; calls: number; success: number; responseTime: number }[]
|
||||
description: string
|
||||
authentication: string
|
||||
}
|
||||
|
||||
export default function ApiInterfacePage() {
|
||||
const router = useRouter()
|
||||
const [apis, setApis] = useState<ApiInterface[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "用户数据查询接口",
|
||||
endpoint: "/api/users/query",
|
||||
method: "GET",
|
||||
status: "active",
|
||||
stats: {
|
||||
totalCalls: 1245632,
|
||||
successCalls: 1220578,
|
||||
errorCalls: 25054,
|
||||
successRate: 98,
|
||||
avgResponseTime: 120,
|
||||
},
|
||||
lastUpdated: "2023-07-15 14:30",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2023-07-${String(i + 9).padStart(2, "0")}`,
|
||||
calls: Math.floor(Math.random() * 20000) + 150000,
|
||||
success: Math.floor(Math.random() * 18000) + 145000,
|
||||
responseTime: Math.floor(Math.random() * 50) + 100,
|
||||
})),
|
||||
description: "查询用户基本信息、标签和行为数据",
|
||||
authentication: "API Key",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "用户标签更新接口",
|
||||
endpoint: "/api/users/tags/update",
|
||||
method: "POST",
|
||||
status: "active",
|
||||
stats: {
|
||||
totalCalls: 856421,
|
||||
successCalls: 845789,
|
||||
errorCalls: 10632,
|
||||
successRate: 99,
|
||||
avgResponseTime: 150,
|
||||
},
|
||||
lastUpdated: "2023-07-15 13:45",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2023-07-${String(i + 9).padStart(2, "0")}`,
|
||||
calls: Math.floor(Math.random() * 15000) + 100000,
|
||||
success: Math.floor(Math.random() * 14000) + 98000,
|
||||
responseTime: Math.floor(Math.random() * 40) + 130,
|
||||
})),
|
||||
description: "更新用户标签信息",
|
||||
authentication: "OAuth 2.0",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "用户行为分析接口",
|
||||
endpoint: "/api/analytics/user-behavior",
|
||||
method: "GET",
|
||||
status: "active",
|
||||
stats: {
|
||||
totalCalls: 542367,
|
||||
successCalls: 538945,
|
||||
errorCalls: 3422,
|
||||
successRate: 99.5,
|
||||
avgResponseTime: 180,
|
||||
},
|
||||
lastUpdated: "2023-07-15 12:20",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2023-07-${String(i + 9).padStart(2, "0")}`,
|
||||
calls: Math.floor(Math.random() * 10000) + 70000,
|
||||
success: Math.floor(Math.random() * 9800) + 69000,
|
||||
responseTime: Math.floor(Math.random() * 30) + 170,
|
||||
})),
|
||||
description: "获取用户行为分析数据",
|
||||
authentication: "API Key",
|
||||
},
|
||||
])
|
||||
|
||||
const [isApiDocsOpen, setIsApiDocsOpen] = useState(false)
|
||||
const [selectedApiId, setSelectedApiId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const handleViewDocs = (apiId: string) => {
|
||||
setSelectedApiId(apiId)
|
||||
setIsApiDocsOpen(true)
|
||||
}
|
||||
|
||||
const copyApi = (apiId: string) => {
|
||||
const apiToCopy = apis.find((api) => api.id === apiId)
|
||||
if (apiToCopy) {
|
||||
const newApi = {
|
||||
...apiToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${apiToCopy.name} (副本)`,
|
||||
status: "inactive" as const,
|
||||
lastUpdated: new Date().toLocaleString(),
|
||||
}
|
||||
setApis([...apis, newApi])
|
||||
toast({
|
||||
title: "复制成功",
|
||||
description: "已创建接口副本",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectedApi = apis.find((api) => api.id === selectedApiId)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/data-platform")}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">API接口已整合</h1>
|
||||
</div>
|
||||
|
||||
<Card className="border shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>API接口已整合到数据中台</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">API接口功能已经整合到数据中台中,请前往数据中台页面查看和管理API接口。</p>
|
||||
<Button onClick={() => router.push("/data-platform")}>前往数据中台</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
app/api/acquisition/[planId]/orders/route.ts
Normal file
19
app/api/acquisition/[planId]/orders/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
68
app/api/auth.ts
Normal file
68
app/api/auth.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
25
app/api/database-structure/route.ts
Normal file
25
app/api/database-structure/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getDatabases, getDatabaseStructure } from "@/lib/db-connector"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const database = searchParams.get("database")
|
||||
|
||||
if (database) {
|
||||
// 获取指定数据库的结构
|
||||
const structure = await getDatabaseStructure(database)
|
||||
return NextResponse.json({ success: true, data: structure })
|
||||
} else {
|
||||
// 获取所有数据库列表
|
||||
const databases = await getDatabases()
|
||||
return NextResponse.json({ success: true, data: databases })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("数据库结构查询失败:", error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "数据库结构查询失败", error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
81
app/api/devices/route.ts
Normal file
81
app/api/devices/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type {
|
||||
CreateDeviceParams,
|
||||
QueryDeviceParams,
|
||||
Device,
|
||||
ApiResponse,
|
||||
DeviceStatus, // Ensure DeviceStatus is imported correctly
|
||||
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, // Using DeviceStatus from the import
|
||||
lastOnlineTime: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response: ApiResponse<Device> = {
|
||||
code: 0,
|
||||
message: "创建成功",
|
||||
data: device,
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "创建失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params: QueryDeviceParams = {
|
||||
keyword: searchParams.get("keyword") || undefined,
|
||||
status: (searchParams.get("status") as DeviceStatus) || undefined, // Using DeviceStatus from the import
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
272
app/api/users/route.ts
Normal file
272
app/api/users/route.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { TrafficUser } from "@/types/traffic"
|
||||
|
||||
// 中文名字生成器数据
|
||||
const familyNames = [
|
||||
"张",
|
||||
"王",
|
||||
"李",
|
||||
"赵",
|
||||
"陈",
|
||||
"刘",
|
||||
"杨",
|
||||
"黄",
|
||||
"周",
|
||||
"吴",
|
||||
"朱",
|
||||
"孙",
|
||||
"马",
|
||||
"胡",
|
||||
"郭",
|
||||
"林",
|
||||
"何",
|
||||
"高",
|
||||
"梁",
|
||||
"郑",
|
||||
"罗",
|
||||
"宋",
|
||||
"谢",
|
||||
"唐",
|
||||
"韩",
|
||||
"曹",
|
||||
"许",
|
||||
"邓",
|
||||
"萧",
|
||||
"冯",
|
||||
]
|
||||
const givenNames1 = [
|
||||
"志",
|
||||
"建",
|
||||
"文",
|
||||
"明",
|
||||
"永",
|
||||
"春",
|
||||
"秀",
|
||||
"金",
|
||||
"水",
|
||||
"玉",
|
||||
"国",
|
||||
"立",
|
||||
"德",
|
||||
"海",
|
||||
"和",
|
||||
"荣",
|
||||
"伟",
|
||||
"新",
|
||||
"英",
|
||||
"佳",
|
||||
]
|
||||
const givenNames2 = [
|
||||
"华",
|
||||
"平",
|
||||
"军",
|
||||
"强",
|
||||
"辉",
|
||||
"敏",
|
||||
"峰",
|
||||
"磊",
|
||||
"超",
|
||||
"艳",
|
||||
"娜",
|
||||
"霞",
|
||||
"燕",
|
||||
"娟",
|
||||
"静",
|
||||
"丽",
|
||||
"涛",
|
||||
"洋",
|
||||
"勇",
|
||||
"龙",
|
||||
]
|
||||
|
||||
// 生成固定的用户数据池
|
||||
const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
|
||||
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
|
||||
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
|
||||
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
|
||||
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
|
||||
|
||||
// 生成随机时间(在过去7天内)
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 7))
|
||||
|
||||
return {
|
||||
id: `${Date.now()}-${i}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
|
||||
nickname: fullName,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
|
||||
region: [
|
||||
"广东深圳",
|
||||
"浙江杭州",
|
||||
"江苏苏州",
|
||||
"北京",
|
||||
"上海",
|
||||
"四川成都",
|
||||
"湖北武汉",
|
||||
"福建厦门",
|
||||
"山东青岛",
|
||||
"河南郑州",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
note: [
|
||||
"咨询产品价格",
|
||||
"对产品很感兴趣",
|
||||
"准备购买",
|
||||
"需要更多信息",
|
||||
"想了解优惠活动",
|
||||
"询问产品规格",
|
||||
"要求产品demo",
|
||||
"索要产品目录",
|
||||
"询问售后服务",
|
||||
"要求上门演示",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
|
||||
addTime: date.toISOString(),
|
||||
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页"][Math.floor(Math.random() * 6)],
|
||||
assignedTo: "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
|
||||
tags: [],
|
||||
}
|
||||
})
|
||||
|
||||
// 计算今日新增数量
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const todayUsers = userPool.filter((user) => new Date(user.addTime) >= todayStart)
|
||||
|
||||
// 生成微信好友数据池
|
||||
const generateWechatFriends = (wechatId: string, count: number) => {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
|
||||
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
|
||||
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
|
||||
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
|
||||
|
||||
// 生成随机时间(在过去30天内)
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 30))
|
||||
|
||||
return {
|
||||
id: `wechat-${wechatId}-${i}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
|
||||
nickname: fullName,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
|
||||
region: [
|
||||
"广东深圳",
|
||||
"浙江杭州",
|
||||
"江苏苏州",
|
||||
"北京",
|
||||
"上海",
|
||||
"四川成都",
|
||||
"湖北武汉",
|
||||
"福建厦门",
|
||||
"山东青岛",
|
||||
"河南郑州",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
note: [
|
||||
"咨询产品价格",
|
||||
"对产品很感兴趣",
|
||||
"准备购买",
|
||||
"需要更多信息",
|
||||
"想了解优惠活动",
|
||||
"询问产品规格",
|
||||
"要求产品demo",
|
||||
"索要产品目录",
|
||||
"询问售后服务",
|
||||
"要求上门演示",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
|
||||
addTime: date.toISOString(),
|
||||
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页", "微信好友"][
|
||||
Math.floor(Math.random() * 7)
|
||||
],
|
||||
assignedTo: "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
|
||||
tags: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 微信好友数据缓存
|
||||
const wechatFriendsCache = new Map<string, TrafficUser[]>()
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = Number.parseInt(searchParams.get("page") || "1")
|
||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "10")
|
||||
const search = searchParams.get("search") || ""
|
||||
const category = searchParams.get("category") || "all"
|
||||
const source = searchParams.get("source") || "all"
|
||||
const status = searchParams.get("status") || "all"
|
||||
const startDate = searchParams.get("startDate")
|
||||
const endDate = searchParams.get("endDate")
|
||||
const wechatSource = searchParams.get("wechatSource") || ""
|
||||
|
||||
let filteredUsers = [...userPool]
|
||||
|
||||
// 如果有微信来源参数,生成或获取微信好友数据
|
||||
if (wechatSource) {
|
||||
if (!wechatFriendsCache.has(wechatSource)) {
|
||||
// 生成150-300个随机好友
|
||||
const friendCount = Math.floor(Math.random() * (300 - 150)) + 150
|
||||
wechatFriendsCache.set(wechatSource, generateWechatFriends(wechatSource, friendCount))
|
||||
}
|
||||
filteredUsers = wechatFriendsCache.get(wechatSource) || []
|
||||
}
|
||||
|
||||
// 应用过滤条件
|
||||
filteredUsers = filteredUsers.filter((user) => {
|
||||
const matchesSearch = search
|
||||
? user.nickname.toLowerCase().includes(search.toLowerCase()) ||
|
||||
user.wechatId.toLowerCase().includes(search.toLowerCase()) ||
|
||||
user.phone.includes(search)
|
||||
: true
|
||||
|
||||
const matchesCategory = category === "all" ? true : user.category === category
|
||||
const matchesSource = source === "all" ? true : user.source === source
|
||||
const matchesStatus = status === "all" ? true : user.status === status
|
||||
|
||||
const matchesDate =
|
||||
startDate && endDate
|
||||
? new Date(user.addTime) >= new Date(startDate) && new Date(user.addTime) <= new Date(endDate)
|
||||
: true
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSource && matchesStatus && matchesDate
|
||||
})
|
||||
|
||||
// 按添加时间倒序排序
|
||||
filteredUsers.sort((a, b) => new Date(b.addTime).getTime() - new Date(a.addTime).getTime())
|
||||
|
||||
// 计算分页
|
||||
const total = filteredUsers.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const start = (page - 1) * pageSize
|
||||
const end = start + pageSize
|
||||
const users = filteredUsers.slice(start, end)
|
||||
|
||||
// 计算分类统计
|
||||
const categoryStats = {
|
||||
potential: userPool.filter((user) => user.category === "potential").length,
|
||||
customer: userPool.filter((user) => user.category === "customer").length,
|
||||
lost: userPool.filter((user) => user.category === "lost").length,
|
||||
}
|
||||
|
||||
// 模拟网络延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
return NextResponse.json({
|
||||
users,
|
||||
pagination: {
|
||||
total,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
pageSize,
|
||||
},
|
||||
stats: {
|
||||
total: wechatSource ? filteredUsers.length : userPool.length,
|
||||
todayNew: wechatSource ? Math.floor(filteredUsers.length * 0.1) : todayUsers.length,
|
||||
categoryStats,
|
||||
},
|
||||
})
|
||||
}
|
||||
86
app/components/AIRewriteModal.tsx
Normal file
86
app/components/AIRewriteModal.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"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 [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleRewrite = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
// 调用实际的AI改写API
|
||||
const response = await fetch("/api/ai/rewrite", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content: originalContent }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("AI改写请求失败")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setRewrittenContent(data.rewrittenContent)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "改写过程中发生错误")
|
||||
console.error("AI改写错误:", err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">AI 内容改写</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">原始内容:</h3>
|
||||
<p className="text-sm text-gray-600">{originalContent}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">改写后内容:</h3>
|
||||
<p className="text-sm text-gray-600">{rewrittenContent || '点击"开始改写"按钮生成内容'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end space-x-2">
|
||||
{error && <p className="text-sm text-red-500 mr-auto">{error}</p>}
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleRewrite} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="mr-2">处理中</span>
|
||||
<span className="animate-spin">⟳</span>
|
||||
</>
|
||||
) : (
|
||||
"开始改写"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
app/components/AuthProvider.tsx
Normal file
66
app/components/AuthProvider.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean
|
||||
token: string | null
|
||||
login: (token: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
})
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// 客户端检查token
|
||||
if (typeof window !== "undefined") {
|
||||
const storedToken = localStorage.getItem("token")
|
||||
if (storedToken) {
|
||||
setToken(storedToken)
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
setIsAuthenticated(false)
|
||||
// 暂时禁用重定向逻辑,允许访问所有页面
|
||||
// 将来需要恢复登录验证时,取消下面注释
|
||||
/*
|
||||
if (pathname !== "/login") {
|
||||
router.push("/login")
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = (newToken: string) => {
|
||||
localStorage.setItem("token", newToken)
|
||||
setToken(newToken)
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("token")
|
||||
setToken(null)
|
||||
setIsAuthenticated(false)
|
||||
// 登出后不强制跳转到登录页
|
||||
// router.push("/login")
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={{ isAuthenticated, token, login, logout }}>{children}</AuthContext.Provider>
|
||||
}
|
||||
31
app/components/BindDouyinQRCode.tsx
Normal file
31
app/components/BindDouyinQRCode.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { QrCode } from "lucide-react"
|
||||
|
||||
export function BindDouyinQRCode() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(true)}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>绑定抖音号</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center p-4">
|
||||
<div className="w-64 h-64 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<img src="/placeholder.svg?height=256&width=256" alt="抖音二维码" className="w-full h-full" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-600">请使用抖音APP扫描二维码进行绑定</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
app/components/BottomNav.tsx
Normal file
40
app/components/BottomNav.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Home, Users, BarChart3, Database, Settings } from "lucide-react"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", icon: Home, label: "首页" },
|
||||
{ href: "/user-portrait", icon: Users, label: "用户画像" },
|
||||
{ href: "/user-value", icon: BarChart3, label: "用户估值" },
|
||||
{ href: "/data-platform", icon: Database, label: "数据中台" },
|
||||
{ href: "/settings", icon: Settings, label: "设置" },
|
||||
]
|
||||
|
||||
export default function BottomNav() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 glass-nav safe-area-bottom z-50 mx-2 mb-2">
|
||||
<div className="flex justify-around items-center py-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center justify-center px-3 py-2 rounded-xl transition-all duration-300 min-w-0 flex-1 ${
|
||||
isActive ? "glass-heavy text-blue-600 scale-105" : "text-gray-600 hover:glass-light hover:text-blue-500"
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`h-5 w-5 mb-1 ${isActive ? "text-blue-600" : ""}`} />
|
||||
<span className="text-xs font-medium truncate">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
60
app/components/Charts.tsx
Normal file
60
app/components/Charts.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
LineChart as RechartsLineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts"
|
||||
import { BarChart as RechartsBarChart, Bar } from "recharts"
|
||||
|
||||
const lineData = [
|
||||
{ name: "周一", 新增微信号: 12 },
|
||||
{ name: "周二", 新增微信号: 19 },
|
||||
{ name: "周三", 新增微信号: 3 },
|
||||
{ name: "周四", 新增微信号: 5 },
|
||||
{ name: "周五", 新增微信号: 2 },
|
||||
{ name: "周六", 新增微信号: 3 },
|
||||
{ name: "周日", 新增微信号: 10 },
|
||||
]
|
||||
|
||||
const barData = [
|
||||
{ name: "周一", 新增好友: 120 },
|
||||
{ name: "周二", 新增好友: 190 },
|
||||
{ name: "周三", 新增好友: 30 },
|
||||
{ name: "周四", 新增好友: 50 },
|
||||
{ name: "周五", 新增好友: 20 },
|
||||
{ name: "周六", 新增好友: 30 },
|
||||
{ name: "周日", 新增好友: 100 },
|
||||
]
|
||||
|
||||
export function LineChart() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<RechartsLineChart data={lineData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="新增微信号" stroke="#8884d8" />
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarChart() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<RechartsBarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="新增好友" fill="#82ca9d" />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
105
app/components/CircleSync/ContentSelector.tsx
Normal file
105
app/components/CircleSync/ContentSelector.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "../ui/card"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { ChevronLeft, Search, Plus } from "lucide-react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const mockLibraries: ContentLibrary[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "卡若朋友圈",
|
||||
type: "朋友圈",
|
||||
count: 307,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "业务推广内容",
|
||||
type: "朋友圈",
|
||||
count: 156,
|
||||
},
|
||||
]
|
||||
|
||||
export function ContentSelector({ onPrev, onFinish }) {
|
||||
const [selectedLibraries, setSelectedLibraries] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<Card className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">选择内容库</h2>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建内容库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="搜索内容库" className="w-full" prefix={<Search className="w-4 h-4 text-gray-400" />} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">选择</TableHead>
|
||||
<TableHead>内容库名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>内容数量</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockLibraries.map((library) => (
|
||||
<TableRow key={library.id}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedLibraries.includes(library.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedLibraries([...selectedLibraries, library.id])
|
||||
} else {
|
||||
setSelectedLibraries(selectedLibraries.filter((id) => id !== library.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{library.name}</TableCell>
|
||||
<TableCell>{library.type}</TableCell>
|
||||
<TableCell>{library.count}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm">
|
||||
预览内容
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onFinish}
|
||||
disabled={selectedLibraries.length === 0}
|
||||
className="bg-green-500 hover:bg-green-600"
|
||||
>
|
||||
完成设置
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
122
app/components/CircleSync/DeviceSelector.tsx
Normal file
122
app/components/CircleSync/DeviceSelector.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "../ui/card"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"
|
||||
import { ChevronLeft, ChevronRight, Search, Plus } from "lucide-react"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
imei: string
|
||||
status: "online" | "offline"
|
||||
friendStatus: string
|
||||
}
|
||||
|
||||
const mockDevices: Device[] = [
|
||||
{
|
||||
id: "1",
|
||||
imei: "123456789012345",
|
||||
status: "online",
|
||||
friendStatus: "正常",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
imei: "987654321098765",
|
||||
status: "offline",
|
||||
friendStatus: "异常",
|
||||
},
|
||||
]
|
||||
|
||||
export function DeviceSelector({ onNext, onPrev }) {
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<Card className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">选择推送设备</h2>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加设备
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注/手机号"
|
||||
className="w-full"
|
||||
prefix={<Search className="w-4 h-4 text-gray-400" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">选择</TableHead>
|
||||
<TableHead>设备IMEI/备注/手机号</TableHead>
|
||||
<TableHead>在线状态</TableHead>
|
||||
<TableHead>加友状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockDevices.map((device) => (
|
||||
<TableRow key={device.id}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDevices([...selectedDevices, device.id])
|
||||
} else {
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== device.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{device.imei}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
|
||||
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
|
||||
device.friendStatus === "正常" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{device.friendStatus}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm">
|
||||
查看详情
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={selectedDevices.length === 0}>
|
||||
下一步
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
100
app/components/CircleSync/TaskSetup.tsx
Normal file
100
app/components/CircleSync/TaskSetup.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "../ui/card"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { Switch } from "../ui/switch"
|
||||
import { Label } from "../ui/label"
|
||||
import { ChevronLeft, ChevronRight, Plus, Minus } from "lucide-react"
|
||||
|
||||
interface TaskSetupProps {
|
||||
onNext?: () => void
|
||||
onPrev?: () => void
|
||||
step: number
|
||||
}
|
||||
|
||||
export function TaskSetup({ onNext, onPrev, step }: TaskSetupProps) {
|
||||
const [syncCount, setSyncCount] = useState(5)
|
||||
const [startTime, setStartTime] = useState("06:00")
|
||||
const [endTime, setEndTime] = useState("23:59")
|
||||
const [isEnabled, setIsEnabled] = useState(true)
|
||||
const [accountType, setAccountType] = useState("business") // business or personal
|
||||
|
||||
return (
|
||||
<Card className="p-6 max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-xl font-semibold">朋友圈同步任务</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="task-enabled">是否启用</Label>
|
||||
<Switch id="task-enabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4">
|
||||
<Label>任务名称</Label>
|
||||
<Input placeholder="请输入任务名称" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Label>允许发布的时间段</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
|
||||
<span>至</span>
|
||||
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Label>每日同步数量</Label>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="outline" size="icon" onClick={() => setSyncCount(Math.max(1, syncCount - 1))}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-12 text-center">{syncCount}</span>
|
||||
<Button variant="outline" size="icon" onClick={() => setSyncCount(syncCount + 1)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-gray-500">条朋友圈</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Label>账号类型</Label>
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
variant={accountType === "business" ? "default" : "outline"}
|
||||
onClick={() => setAccountType("business")}
|
||||
className="w-24"
|
||||
>
|
||||
业务号
|
||||
</Button>
|
||||
<Button
|
||||
variant={accountType === "personal" ? "default" : "outline"}
|
||||
onClick={() => setAccountType("personal")}
|
||||
className="w-24"
|
||||
>
|
||||
人设号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
{step > 1 ? (
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
上一步
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button onClick={onNext}>
|
||||
下一步
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
297
app/components/DeviceSelector.tsx
Normal file
297
app/components/DeviceSelector.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } 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"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
|
||||
interface WechatAccount {
|
||||
wechatId: string
|
||||
nickname: string
|
||||
remainingAdds: number
|
||||
maxDailyAdds: number
|
||||
todayAdded: number
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
imei: string
|
||||
name: string
|
||||
status: "online" | "offline"
|
||||
wechatAccounts: WechatAccount[]
|
||||
usedInPlans: number
|
||||
}
|
||||
|
||||
interface DeviceSelectorProps {
|
||||
onSelect: (selectedDevices: string[]) => void
|
||||
initialSelectedDevices?: string[]
|
||||
excludeUsedDevices?: boolean
|
||||
}
|
||||
|
||||
export function DeviceSelector({
|
||||
onSelect,
|
||||
initialSelectedDevices = [],
|
||||
excludeUsedDevices = true,
|
||||
}: DeviceSelectorProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(initialSelectedDevices)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const devicesPerPage = 10
|
||||
const [filteredDevices, setFilteredDevices] = useState<Device[]>([])
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredDevices.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 120, // 估计每个设备卡片的高度
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
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()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
filterDevices()
|
||||
}, [debouncedSearchQuery, statusFilter, excludeUsedDevices, devices])
|
||||
|
||||
const filterDevices = () => {
|
||||
const filtered = devices.filter((device) => {
|
||||
const matchesSearch =
|
||||
device.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
device.imei.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
device.wechatAccounts.some((account) =>
|
||||
account.wechatId.toLowerCase().includes(debouncedSearchQuery.toLowerCase()),
|
||||
)
|
||||
const matchesStatus = statusFilter === "all" || device.status === statusFilter
|
||||
const matchesUsage = !excludeUsedDevices || device.usedInPlans === 0
|
||||
return matchesSearch && matchesStatus && matchesUsage
|
||||
})
|
||||
setFilteredDevices(filtered)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
toast({
|
||||
title: "刷新成功",
|
||||
description: "设备列表已更新",
|
||||
})
|
||||
}
|
||||
|
||||
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDevices.length === paginatedDevices.length) {
|
||||
setSelectedDevices([])
|
||||
} else {
|
||||
setSelectedDevices(paginatedDevices.map((device) => device.id))
|
||||
}
|
||||
onSelect(selectedDevices)
|
||||
}
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
const updatedSelection = selectedDevices.includes(deviceId)
|
||||
? selectedDevices.filter((id) => id !== deviceId)
|
||||
: [...selectedDevices, deviceId]
|
||||
setSelectedDevices(updatedSelection)
|
||||
onSelect(updatedSelection)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注/微信号"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={handleSelectAll}>
|
||||
{selectedDevices.length === paginatedDevices.length ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref={parentRef} className="h-[500px] overflow-auto">
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const device = filteredDevices[virtualRow.index]
|
||||
return (
|
||||
<div
|
||||
key={device.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Card key={device.id} className="p-3 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onCheckedChange={() => handleDeviceSelect(device.id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium truncate">{device.name}</div>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{device.wechatAccounts.map((account) => (
|
||||
<div key={account.wechatId} className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{account.nickname}</span>
|
||||
<span className="text-gray-500">{account.wechatId}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>今日可添加:</span>
|
||||
<span className="font-medium">{account.remainingAdds}</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertCircle className="h-4 w-4 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>每日最多添加 {account.maxDailyAdds} 个好友</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{account.todayAdded}/{account.maxDailyAdds}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(account.todayAdded / account.maxDailyAdds) * 100} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!excludeUsedDevices && device.usedInPlans > 0 && (
|
||||
<div className="text-sm text-orange-500 mt-2">已用于 {device.usedInPlans} 个计划</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: Math.ceil(filteredDevices.length / devicesPerPage) }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={currentPage === page}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage(page)
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
app/components/ErrorBoundary.tsx
Normal file
84
app/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// 记录错误到日志服务
|
||||
this.logError(error, errorInfo)
|
||||
}
|
||||
|
||||
logError = async (error: Error, errorInfo: ErrorInfo) => {
|
||||
try {
|
||||
await fetch("/api/log-error", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Failed to log error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-6 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-amber-500 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">出现了一些问题</h2>
|
||||
<p className="text-gray-600 mb-6 max-w-md">应用遇到了错误。我们已记录此问题并将尽快修复。</p>
|
||||
<div className="space-x-4">
|
||||
<Button onClick={this.handleRetry}>重试</Button>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
刷新页面
|
||||
</Button>
|
||||
</div>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<div className="mt-6 p-4 bg-gray-100 rounded-md text-left overflow-auto max-w-full">
|
||||
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap">{this.state.error?.stack}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
49
app/components/Header.tsx
Normal file
49
app/components/Header.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Bell, Search, Moon, Sun } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
export default function Header() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-card px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center w-full max-w-md">
|
||||
<Search className="h-4 w-4 text-muted-foreground mr-2" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索用户、标签、活动..."
|
||||
className="border-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">切换主题</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>浅色</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>深色</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>系统</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="icon">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
72
app/components/MobileHeader.tsx
Normal file
72
app/components/MobileHeader.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Menu, Search, Bell, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface MobileHeaderProps {
|
||||
onMenuToggle: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function MobileHeader({ onMenuToggle, title = "数据资产中台" }: MobileHeaderProps) {
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
return (
|
||||
<header className="glass-nav safe-area-top sticky top-0 z-50 mx-2 mt-2 mb-4">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
{!showSearch ? (
|
||||
<>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={onMenuToggle} className="glass-light rounded-xl">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent truncate">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSearch(true)}
|
||||
className="glass-light rounded-xl"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="glass-light rounded-xl">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3 w-full">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索用户、标签、活动..."
|
||||
className="glass-input border-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setShowSearch(false)
|
||||
setSearchQuery("")
|
||||
}}
|
||||
className="glass-light rounded-xl"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
233
app/components/MobileSidebar.tsx
Normal file
233
app/components/MobileSidebar.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Users,
|
||||
Target,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Tag,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
} from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface MobileSidebarProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
"user-portrait": true,
|
||||
"user-value": false,
|
||||
})
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden"
|
||||
} else {
|
||||
document.body.style.overflow = "unset"
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "unset"
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "概览",
|
||||
href: "/",
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
title: "数据集成",
|
||||
href: "/data-integration",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
primary: true,
|
||||
tag: "核心",
|
||||
},
|
||||
{
|
||||
title: "用户画像",
|
||||
href: "/user-portrait",
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
primary: true,
|
||||
expandable: true,
|
||||
section: "user-portrait",
|
||||
children: [
|
||||
{
|
||||
title: "用户词",
|
||||
href: "/user-portrait/user-keywords",
|
||||
icon: <Tag className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "标签管理",
|
||||
href: "/user-portrait/tags",
|
||||
icon: <Tag className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "用户估值",
|
||||
href: "/user-value",
|
||||
icon: <Target className="h-5 w-5" />,
|
||||
primary: true,
|
||||
expandable: true,
|
||||
section: "user-value",
|
||||
children: [
|
||||
{
|
||||
title: "估值模型",
|
||||
href: "/user-value/model",
|
||||
icon: <BarChart3 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "用户升级路径",
|
||||
href: "/user-value/upgrade-paths",
|
||||
icon: <TrendingUp className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const getTagColor = (tag: string) => {
|
||||
switch (tag) {
|
||||
case "核心":
|
||||
return "glass-light text-orange-700 border-orange-200"
|
||||
default:
|
||||
return "glass-light text-gray-700 border-gray-200"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/20 backdrop-blur-sm z-40 transition-opacity duration-300",
|
||||
isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed left-0 top-0 h-full w-80 glass-nav safe-area-top safe-area-left z-50 transition-transform duration-300 ease-out",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/20">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg glass-light flex items-center justify-center">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
用户数字资产中台
|
||||
</h1>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="glass-light rounded-xl">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<nav className="space-y-2 px-4">
|
||||
{navItems.map((item) => (
|
||||
<div key={item.href}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-300 group",
|
||||
pathname === item.href
|
||||
? "glass-heavy text-blue-700 shadow-glass"
|
||||
: "glass-light text-gray-700 hover:glass-heavy hover:text-blue-600",
|
||||
)}
|
||||
>
|
||||
<Link href={item.href} onClick={onClose} className="flex items-center flex-1">
|
||||
<div className="transition-colors duration-300">{item.icon}</div>
|
||||
<div className="flex-1 flex items-center justify-between ml-3">
|
||||
<span className="font-medium">{item.title}</span>
|
||||
{item.tag && (
|
||||
<Badge className={cn("text-xs px-2 py-1 rounded-lg", getTagColor(item.tag))}>{item.tag}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{item.expandable && (
|
||||
<button
|
||||
onClick={() => toggleSection(item.section!)}
|
||||
className="ml-2 p-1 hover:bg-white/20 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{expandedSections[item.section!] ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 子菜单 */}
|
||||
{item.children && expandedSections[item.section!] && (
|
||||
<div className="ml-6 mt-2 space-y-1">
|
||||
{item.children.map((subItem) => (
|
||||
<Link
|
||||
key={subItem.href}
|
||||
href={subItem.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 text-sm rounded-lg transition-all duration-300",
|
||||
pathname === subItem.href
|
||||
? "glass-light text-blue-600 shadow-glass-sm"
|
||||
: "text-gray-600 hover:glass-light hover:text-blue-500",
|
||||
)}
|
||||
>
|
||||
<div className="mr-3 text-gray-400 transition-colors duration-300">{subItem.icon}</div>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 底部设置 */}
|
||||
<div className="border-t border-white/20 p-4">
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center px-4 py-2 text-xs font-medium rounded-lg transition-all duration-300 w-full",
|
||||
pathname === "/settings"
|
||||
? "glass-heavy text-blue-700 shadow-glass"
|
||||
: "glass-light text-gray-600 hover:glass-heavy hover:text-blue-500",
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4 transition-colors duration-300" />
|
||||
<span className="ml-2">设置</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
230
app/components/Sidebar.tsx
Normal file
230
app/components/Sidebar.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Users,
|
||||
Target,
|
||||
ChevronLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Tag,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
} from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
"user-portrait": true,
|
||||
"user-value": false,
|
||||
})
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
|
||||
// 重新设计的导航结构
|
||||
const navItems = [
|
||||
{
|
||||
title: "概览",
|
||||
href: "/",
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
title: "数据集成",
|
||||
href: "/data-integration",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
primary: true,
|
||||
tag: "核心",
|
||||
},
|
||||
{
|
||||
title: "用户画像",
|
||||
href: "/user-portrait",
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
primary: true,
|
||||
expandable: true,
|
||||
section: "user-portrait",
|
||||
children: [
|
||||
{
|
||||
title: "用户词",
|
||||
href: "/user-portrait/user-keywords",
|
||||
icon: <Tag className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "标签管理",
|
||||
href: "/user-portrait/tags",
|
||||
icon: <Tag className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "用户估值",
|
||||
href: "/user-value",
|
||||
icon: <Target className="h-5 w-5" />,
|
||||
primary: true,
|
||||
expandable: true,
|
||||
section: "user-value",
|
||||
children: [
|
||||
{
|
||||
title: "估值模型",
|
||||
href: "/user-value/model",
|
||||
icon: <BarChart3 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "用户升级路径",
|
||||
href: "/user-value/upgrade-paths",
|
||||
icon: <TrendingUp className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const getTagColor = (tag: string) => {
|
||||
switch (tag) {
|
||||
case "核心":
|
||||
return "glass-light text-orange-700 border-orange-200"
|
||||
default:
|
||||
return "glass-light text-gray-700 border-gray-200"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-screen glass-nav m-4 transition-all duration-300 ease-in-out",
|
||||
expanded ? "w-72" : "w-20",
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center h-16 px-6 border-b border-white/20">
|
||||
{expanded ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg glass-light flex items-center justify-center">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
用户数字资产中台
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto">
|
||||
<div className="w-8 h-8 rounded-lg glass-light flex items-center justify-center">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<nav className="space-y-2 px-4">
|
||||
{navItems.map((item) => (
|
||||
<div key={item.href}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-300 group",
|
||||
pathname === item.href
|
||||
? "glass-heavy text-blue-700 shadow-glass"
|
||||
: "glass-light text-gray-700 hover:glass-heavy hover:text-blue-600 hover:scale-105",
|
||||
)}
|
||||
>
|
||||
<Link href={item.href} className={cn("flex items-center flex-1", !expanded && "justify-center")}>
|
||||
<div className="transition-colors duration-300">{item.icon}</div>
|
||||
{expanded && (
|
||||
<div className="flex-1 flex items-center justify-between ml-3">
|
||||
<span className="font-medium">{item.title}</span>
|
||||
{item.tag && (
|
||||
<Badge className={cn("text-xs px-2 py-1 rounded-lg", getTagColor(item.tag))}>{item.tag}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
{expanded && item.expandable && (
|
||||
<button
|
||||
onClick={() => toggleSection(item.section!)}
|
||||
className="ml-2 p-1 hover:bg-white/20 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{expandedSections[item.section!] ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 子菜单 */}
|
||||
{expanded && item.children && expandedSections[item.section!] && (
|
||||
<div className="ml-6 mt-2 space-y-1">
|
||||
{item.children.map((subItem) => (
|
||||
<Link
|
||||
key={subItem.href}
|
||||
href={subItem.href}
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 text-sm rounded-lg transition-all duration-300",
|
||||
pathname === subItem.href
|
||||
? "glass-light text-blue-600 shadow-glass-sm"
|
||||
: "text-gray-600 hover:glass-light hover:text-blue-500",
|
||||
)}
|
||||
>
|
||||
<div className="mr-3 text-gray-400 transition-colors duration-300">{subItem.icon}</div>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 底部设置和折叠按钮 */}
|
||||
<div className="mt-auto border-t border-white/20">
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
"flex items-center px-4 py-2 mx-4 my-2 text-xs font-medium rounded-lg transition-all duration-300",
|
||||
pathname === "/settings"
|
||||
? "glass-heavy text-blue-700 shadow-glass"
|
||||
: "glass-light text-gray-600 hover:glass-heavy hover:text-blue-500",
|
||||
)}
|
||||
>
|
||||
<div className={cn("transition-colors duration-300", !expanded && "mx-auto")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</div>
|
||||
{expanded && <span className="ml-2">设置</span>}
|
||||
</Link>
|
||||
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="w-full flex items-center justify-center p-3 rounded-xl glass-button hover:scale-105 transition-all duration-300"
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn(
|
||||
"h-5 w-5 transform transition-transform duration-300",
|
||||
expanded ? "rotate-0" : "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
app/components/SpeechToTextProcessor.tsx
Normal file
71
app/components/SpeechToTextProcessor.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface SpeechToTextProcessorProps {
|
||||
audioUrl: string
|
||||
onTranscriptReady: (transcript: string) => void
|
||||
onQuestionExtracted: (question: string) => void
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function SpeechToTextProcessor({
|
||||
audioUrl,
|
||||
onTranscriptReady,
|
||||
onQuestionExtracted,
|
||||
enabled = true,
|
||||
}: SpeechToTextProcessorProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !audioUrl) return
|
||||
|
||||
const processAudio = async () => {
|
||||
try {
|
||||
setIsProcessing(true)
|
||||
setError(null)
|
||||
|
||||
// 模拟API调用延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// 模拟转录结果
|
||||
const mockTranscript = `
|
||||
客服: 您好,这里是XX公司客服,请问有什么可以帮到您?
|
||||
客户: 请问贵公司的产品有什么特点?
|
||||
客服: 我们的产品主要有以下几个特点:首先,质量非常可靠;其次,价格比较有竞争力;第三,售后服务非常完善。
|
||||
客户: 那你们的价格是怎么样的?
|
||||
客服: 我们有多种套餐可以选择,基础版每月只需99元,高级版每月299元,具体可以根据您的需求来选择。
|
||||
客户: 好的,我了解了,谢谢。
|
||||
客服: 不客气,如果您有兴趣,我可以添加您的微信,给您发送更详细的产品资料。
|
||||
客户: 可以的,谢谢。
|
||||
客服: 好的,稍后我会添加您为好友,再次感谢您的咨询。
|
||||
`
|
||||
onTranscriptReady(mockTranscript)
|
||||
|
||||
// 提取首句问题
|
||||
const questionMatch = mockTranscript.match(/客户: (.*?)\n/)
|
||||
if (questionMatch && questionMatch[1]) {
|
||||
onQuestionExtracted(questionMatch[1])
|
||||
} else {
|
||||
onQuestionExtracted("未识别到有效问题")
|
||||
}
|
||||
|
||||
setIsProcessing(false)
|
||||
} catch (err) {
|
||||
setError("处理音频时出错")
|
||||
setIsProcessing(false)
|
||||
toast({
|
||||
title: "处理失败",
|
||||
description: "语音转文字处理失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
processAudio()
|
||||
}, [audioUrl, enabled, onTranscriptReady, onQuestionExtracted])
|
||||
|
||||
return null // 这是一个功能性组件,不渲染任何UI
|
||||
}
|
||||
149
app/components/TrafficTeamSettings.tsx
Normal file
149
app/components/TrafficTeamSettings.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { 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) {
|
||||
const [teams, setTeams] = useState<TrafficTeam[]>(formData.trafficTeams || [])
|
||||
const [isAddTeamOpen, setIsAddTeamOpen] = useState(false)
|
||||
const [editingTeam, setEditingTeam] = useState<TrafficTeam | null>(null)
|
||||
const [newTeam, setNewTeam] = useState<Partial<TrafficTeam>>({
|
||||
name: "",
|
||||
commission: 0,
|
||||
})
|
||||
|
||||
const handleAddTeam = () => {
|
||||
if (!newTeam.name) return
|
||||
|
||||
if (editingTeam) {
|
||||
setTeams(teams.map((team) => (team.id === editingTeam.id ? { ...team, ...newTeam } : team)))
|
||||
} else {
|
||||
setTeams([
|
||||
...teams,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
name: newTeam.name,
|
||||
commission: newTeam.commission || 0,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
setIsAddTeamOpen(false)
|
||||
setNewTeam({ name: "", commission: 0 })
|
||||
setEditingTeam(null)
|
||||
onChange({ ...formData, trafficTeams: teams })
|
||||
}
|
||||
|
||||
const handleEditTeam = (team: TrafficTeam) => {
|
||||
setEditingTeam(team)
|
||||
setNewTeam(team)
|
||||
setIsAddTeamOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteTeam = (teamId: string) => {
|
||||
setTeams(teams.filter((team) => team.id !== teamId))
|
||||
onChange({ ...formData, trafficTeams: teams.filter((team) => team.id !== teamId) })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold">打粉团队设置</h2>
|
||||
<Button onClick={() => setIsAddTeamOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加团队
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>团队名称</TableHead>
|
||||
<TableHead>佣金比例</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teams.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8 text-gray-500">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<TableRow key={team.id}>
|
||||
<TableCell>{team.name}</TableCell>
|
||||
<TableCell>{team.commission}%</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditTeam(team)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteTeam(team.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddTeamOpen} onOpenChange={setIsAddTeamOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTeam ? "编辑团队" : "添加团队"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>团队名称</Label>
|
||||
<Input
|
||||
value={newTeam.name}
|
||||
onChange={(e) => setNewTeam({ ...newTeam, name: e.target.value })}
|
||||
placeholder="请输入团队名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>佣金比例 (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newTeam.commission}
|
||||
onChange={(e) => setNewTeam({ ...newTeam, commission: Number(e.target.value) })}
|
||||
placeholder="请输入佣金比例"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddTeamOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddTeam}>{editingTeam ? "保存" : "添加"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
110
app/components/charts.tsx
Normal file
110
app/components/charts.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
LineChart as RechartsLineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
PieChart as RechartsPieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts"
|
||||
|
||||
interface ChartProps {
|
||||
data: any
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function LineChart({ data, height = 300 }: ChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsLineChart
|
||||
data={data.labels.map((label, i) => {
|
||||
const dataPoint = { name: label }
|
||||
data.datasets.forEach((dataset, j) => {
|
||||
dataPoint[dataset.label] = dataset.data[i]
|
||||
})
|
||||
return dataPoint
|
||||
})}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{data.datasets.map((dataset, i) => (
|
||||
<Line
|
||||
key={i}
|
||||
type="monotone"
|
||||
dataKey={dataset.label}
|
||||
stroke={dataset.borderColor}
|
||||
fill={dataset.backgroundColor}
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
))}
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarChart({ data, height = 300 }: ChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsBarChart
|
||||
data={data.labels.map((label, i) => {
|
||||
const dataPoint = { name: label }
|
||||
data.datasets.forEach((dataset, j) => {
|
||||
dataPoint[dataset.label] = dataset.data[i]
|
||||
})
|
||||
return dataPoint
|
||||
})}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{data.datasets.map((dataset, i) => (
|
||||
<Bar key={i} dataKey={dataset.label} fill={dataset.backgroundColor || "#8884d8"} />
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function PieChart({ data, height = 300 }: ChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data.labels.map((label, i) => ({
|
||||
name: label,
|
||||
value: data.datasets[0].data[i],
|
||||
}))}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{data.labels.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={data.datasets[0].backgroundColor[index] || `#${Math.floor(Math.random() * 16777215).toString(16)}`}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
206
app/components/device-grid.tsx
Normal file
206
app/components/device-grid.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Battery, Smartphone, MessageCircle, Users, Clock } from "lucide-react"
|
||||
|
||||
export interface Device {
|
||||
id: string
|
||||
imei: string
|
||||
name: string
|
||||
status: "online" | "offline"
|
||||
battery: number
|
||||
wechatId: string
|
||||
friendCount: number
|
||||
todayAdded: number
|
||||
messageCount: number
|
||||
lastActive: string
|
||||
addFriendStatus: "normal" | "abnormal"
|
||||
}
|
||||
|
||||
interface DeviceGridProps {
|
||||
devices: Device[]
|
||||
selectable?: boolean
|
||||
selectedDevices?: string[]
|
||||
onSelect?: (deviceIds: string[]) => void
|
||||
itemsPerRow?: number
|
||||
}
|
||||
|
||||
export function DeviceGrid({
|
||||
devices,
|
||||
selectable = false,
|
||||
selectedDevices = [],
|
||||
onSelect,
|
||||
itemsPerRow = 2,
|
||||
}: DeviceGridProps) {
|
||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDevices.length === devices.length) {
|
||||
onSelect?.([])
|
||||
} else {
|
||||
onSelect?.(devices.map((d) => d.id))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{selectable && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedDevices.length === devices.length && devices.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm">全选</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">已选择 {selectedDevices.length} 个设备</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`grid grid-cols-${itemsPerRow} gap-4`}>
|
||||
{devices.map((device) => (
|
||||
<Card
|
||||
key={device.id}
|
||||
className={`p-4 hover:shadow-md transition-all cursor-pointer ${
|
||||
selectedDevices.includes(device.id) ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (selectable) {
|
||||
const newSelection = selectedDevices.includes(device.id)
|
||||
? selectedDevices.filter((id) => id !== device.id)
|
||||
: [...selectedDevices, device.id]
|
||||
onSelect?.(newSelection)
|
||||
} else {
|
||||
setSelectedDevice(device)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
className="mt-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">{device.name}</div>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
|
||||
<span>{device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{device.friendCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span>{device.messageCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>+{device.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId}</div>
|
||||
</div>
|
||||
|
||||
<Badge variant={device.addFriendStatus === "normal" ? "outline" : "destructive"} className="mt-2">
|
||||
{device.addFriendStatus === "normal" ? "加友正常" : "加友异常"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!selectedDevice} onOpenChange={() => setSelectedDevice(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>设备详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedDevice && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-gray-100 rounded-lg">
|
||||
<Smartphone className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedDevice.name}</h3>
|
||||
<p className="text-sm text-gray-500">IMEI: {selectedDevice.imei}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={selectedDevice.status === "online" ? "success" : "secondary"}>
|
||||
{selectedDevice.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">电池电量</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Battery className={`w-5 h-5 ${selectedDevice.battery < 20 ? "text-red-500" : "text-green-500"}`} />
|
||||
<span className="font-medium">{selectedDevice.battery}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">好友数量</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-5 h-5 text-blue-500" />
|
||||
<span className="font-medium">{selectedDevice.friendCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">今日新增</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-5 h-5 text-green-500" />
|
||||
<span className="font-medium">+{selectedDevice.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">消息数量</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<MessageCircle className="w-5 h-5 text-purple-500" />
|
||||
<span className="font-medium">{selectedDevice.messageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-500">微信账号</div>
|
||||
<div className="font-medium">{selectedDevice.wechatId}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-500">最后活跃</div>
|
||||
<div className="font-medium">{selectedDevice.lastActive}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-500">加友状态</div>
|
||||
<Badge variant={selectedDevice.addFriendStatus === "normal" ? "outline" : "destructive"}>
|
||||
{selectedDevice.addFriendStatus === "normal" ? "加友正常" : "加友异常"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
230
app/components/device-selection-dialog.tsx
Normal file
230
app/components/device-selection-dialog.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Search, Filter, RefreshCw } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
imei: string
|
||||
status: "online" | "offline"
|
||||
wechatId: string
|
||||
friendCount: number
|
||||
battery: number
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
interface DeviceSelectionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedDevices: string[]
|
||||
onSelect: (deviceIds: string[]) => void
|
||||
excludeUsedDevices?: boolean
|
||||
planId?: string
|
||||
}
|
||||
|
||||
export function DeviceSelectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
excludeUsedDevices = false,
|
||||
planId,
|
||||
}: DeviceSelectionDialogProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [filteredDevices, setFilteredDevices] = useState<Device[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [selected, setSelected] = useState<string[]>(selectedDevices || [])
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const devicesPerPage = 5
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDevices = async () => {
|
||||
// 模拟API调用获取设备列表
|
||||
const mockDevices: Device[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `device-${i + 1}`,
|
||||
name: `设备 ${i + 1}`,
|
||||
imei: `sd${123123 + i}`,
|
||||
status: Math.random() > 0.2 ? "online" : "offline",
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
friendCount: Math.floor(Math.random() * 1000),
|
||||
battery: Math.floor(Math.random() * 100),
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
|
||||
}))
|
||||
|
||||
if (excludeUsedDevices && planId) {
|
||||
const availableDevices = mockDevices.filter((device) => !device.id.includes("-even"))
|
||||
setDevices(availableDevices)
|
||||
setFilteredDevices(availableDevices)
|
||||
} else {
|
||||
setDevices(mockDevices)
|
||||
setFilteredDevices(mockDevices)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
fetchDevices()
|
||||
setSelected(selectedDevices || [])
|
||||
}
|
||||
}, [open, selectedDevices, excludeUsedDevices, planId])
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = devices.filter((device) => {
|
||||
const matchesSearch =
|
||||
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.wechatId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesStatus = statusFilter === "all" || device.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
setFilteredDevices(filtered)
|
||||
setCurrentPage(1)
|
||||
}, [searchQuery, statusFilter, devices])
|
||||
|
||||
const handleSelect = (deviceId: string) => {
|
||||
let newSelected: string[]
|
||||
|
||||
if (selected.includes(deviceId)) {
|
||||
// 如果已选中,则取消选择
|
||||
newSelected = selected.filter((id) => id !== deviceId)
|
||||
} else {
|
||||
// 如果未选中,检查是否超过限制
|
||||
if (selected.length >= 5) {
|
||||
toast({
|
||||
title: "选择超出限制",
|
||||
description: "最多可选择5个设备",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
newSelected = [...selected, deviceId]
|
||||
}
|
||||
|
||||
setSelected(newSelected)
|
||||
onSelect(newSelected) // 直接触发选择回调
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
const refreshedDevices = devices.map((device) => ({
|
||||
...device,
|
||||
status: Math.random() > 0.2 ? "online" : "offline",
|
||||
battery: Math.floor(Math.random() * 100),
|
||||
}))
|
||||
setDevices(refreshedDevices)
|
||||
setFilteredDevices(refreshedDevices)
|
||||
}
|
||||
|
||||
// 分页逻辑
|
||||
const totalPages = Math.ceil(filteredDevices.length / devicesPerPage)
|
||||
const startIndex = (currentPage - 1) * devicesPerPage
|
||||
const currentDevices = filteredDevices.slice(startIndex, startIndex + devicesPerPage)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注/微信号"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-sm text-gray-500">已选择 {selected.length}/5 个设备</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{currentDevices.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">暂无符合条件的设备</div>
|
||||
) : (
|
||||
currentDevices.map((device) => (
|
||||
<Card
|
||||
key={device.id}
|
||||
className={`p-3 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selected.includes(device.id) ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
onClick={() => handleSelect(device.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium truncate">{device.name}</div>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
|
||||
<div className="text-sm text-gray-500">微信号: {device.wechatId}</div>
|
||||
<div className="flex items-center justify-between mt-1 text-sm">
|
||||
<span className="text-gray-500">好友数: {device.friendCount}</span>
|
||||
<span className="text-gray-500">电量: {device.battery}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center space-x-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="flex items-center px-2">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
84
app/components/poster-selector.tsx
Normal file
84
app/components/poster-selector.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
interface PosterTemplate {
|
||||
id: string
|
||||
title: string
|
||||
type: "领取" | "了解"
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
interface PosterSelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect: (template: PosterTemplate) => void
|
||||
}
|
||||
|
||||
const templates: PosterTemplate[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "点击领取",
|
||||
type: "领取",
|
||||
imageUrl: "/placeholder.svg?height=400&width=300",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "点击了解",
|
||||
type: "了解",
|
||||
imageUrl: "/placeholder.svg?height=400&width=300",
|
||||
},
|
||||
// ... 其他模板
|
||||
]
|
||||
|
||||
export function PosterSelector({ open, onOpenChange, onSelect }: PosterSelectorProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择海报</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm text-gray-500 mb-4">点击下方海报使用该模板</h3>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="group relative cursor-pointer"
|
||||
onClick={() => {
|
||||
onSelect(template)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={template.imageUrl || "/placeholder.svg"}
|
||||
alt={template.title}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 bg-black/50 group-hover:opacity-100 transition-opacity">
|
||||
<Check className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<div className="font-medium">{template.title}</div>
|
||||
<div className="text-sm text-gray-500">{template.type}类型</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button>新建海报</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
56
app/components/ui/accordion.tsx
Normal file
56
app/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="pb-4 pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
40
app/components/ui/avatar.tsx
Normal file
40
app/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
29
app/components/ui/badge.tsx
Normal file
29
app/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
60
app/components/ui/button.tsx
Normal file
60
app/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-xl text-sm font-medium transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"glass-button bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600 hover:scale-105 shadow-glass",
|
||||
destructive:
|
||||
"glass-button bg-gradient-to-r from-red-500 to-pink-500 text-white hover:from-red-600 hover:to-pink-600 hover:scale-105",
|
||||
outline: "glass-light border-2 border-white/30 hover:glass-heavy hover:scale-105",
|
||||
secondary: "glass-light text-gray-700 hover:glass-heavy hover:scale-105",
|
||||
ghost: "hover:glass-light hover:scale-105 rounded-xl",
|
||||
link: "text-blue-600 underline-offset-4 hover:underline hover:text-blue-700",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-6 py-2",
|
||||
sm: "h-9 rounded-lg px-4",
|
||||
lg: "h-12 rounded-xl px-8",
|
||||
icon: "h-11 w-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, children, ...props }, ref) => {
|
||||
// If asChild is true and the first child is a valid element, clone it with the button props
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
className: cn(buttonVariants({ variant, size, className })),
|
||||
ref,
|
||||
...props,
|
||||
...children.props,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
54
app/components/ui/calendar.tsx
Normal file
54
app/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import type * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside: "text-muted-foreground opacity-50",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
47
app/components/ui/card.tsx
Normal file
47
app/components/ui/card.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("glass-card transition-all duration-300 hover:shadow-glass-lg", className)} {...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => <p ref={ref} className={cn("text-sm text-gray-600", className)} {...props} />,
|
||||
)
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
|
||||
)
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
28
app/components/ui/checkbox.tsx
Normal file
28
app/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
11
app/components/ui/collapsible.tsx
Normal file
11
app/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"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 }
|
||||
97
app/components/ui/dialog.tsx
Normal file
97
app/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
181
app/components/ui/dropdown-menu.tsx
Normal file
181
app/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
22
app/components/ui/input.tsx
Normal file
22
app/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
19
app/components/ui/label.tsx
Normal file
19
app/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
81
app/components/ui/pagination.tsx
Normal file
81
app/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
|
||||
),
|
||||
)
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||
<span aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
6
app/components/ui/popover.tsx
Normal file
6
app/components/ui/popover.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
"use client"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger
|
||||
34
app/components/ui/preview-dialog.tsx
Normal file
34
app/components/ui/preview-dialog.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./dialog"
|
||||
import { Button } from "./button"
|
||||
import { Eye } from "lucide-react"
|
||||
|
||||
interface PreviewDialogProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function PreviewDialog({ children, title = "预览效果" }: PreviewDialogProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
预览
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-[360px] p-0">
|
||||
<DialogHeader className="p-4 border-b">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative bg-gray-50">
|
||||
<div className="w-full overflow-hidden">{children}</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
app/components/ui/progress.tsx
Normal file
25
app/components/ui/progress.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
38
app/components/ui/radio-group.tsx
Normal file
38
app/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
40
app/components/ui/scroll-area.tsx
Normal file
40
app/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
103
app/components/ui/select.tsx
Normal file
103
app/components/ui/select.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator }
|
||||
29
app/components/ui/switch.tsx
Normal file
29
app/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
72
app/components/ui/table.tsx
Normal file
72
app/components/ui/table.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
|
||||
)
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot ref={ref} className={cn("bg-primary font-medium text-primary-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
55
app/components/ui/tabs.tsx
Normal file
55
app/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
21
app/components/ui/textarea.tsx
Normal file
21
app/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
111
app/components/ui/toast.tsx
Normal file
111
app/components/ui/toast.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background",
|
||||
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
107
app/components/ui/tooltip.tsx
Normal file
107
app/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TooltipProps {
|
||||
children: React.ReactNode
|
||||
content: React.ReactNode
|
||||
className?: string
|
||||
delayDuration?: number
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}
|
||||
|
||||
const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
|
||||
({ children, content, className, delayDuration = 200, side = "top" }, ref) => {
|
||||
const [isVisible, setIsVisible] = React.useState(false)
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0 })
|
||||
const tooltipRef = React.useRef<HTMLDivElement>(null)
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout>()
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
const tooltipRect = tooltipRef.current?.getBoundingClientRect()
|
||||
|
||||
if (tooltipRect) {
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
switch (side) {
|
||||
case "top":
|
||||
top = rect.top - tooltipRect.height - 8
|
||||
left = rect.left + (rect.width - tooltipRect.width) / 2
|
||||
break
|
||||
case "bottom":
|
||||
top = rect.bottom + 8
|
||||
left = rect.left + (rect.width - tooltipRect.width) / 2
|
||||
break
|
||||
case "left":
|
||||
top = rect.top + (rect.height - tooltipRect.height) / 2
|
||||
left = rect.left - tooltipRect.width - 8
|
||||
break
|
||||
case "right":
|
||||
top = rect.top + (rect.height - tooltipRect.height) / 2
|
||||
left = rect.right + 8
|
||||
break
|
||||
}
|
||||
|
||||
setPosition({ top, left })
|
||||
setIsVisible(true)
|
||||
}
|
||||
}, delayDuration)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={ref}>
|
||||
{children}
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={cn(
|
||||
"fixed z-50 px-2 py-1 text-xs text-primary-foreground bg-primary rounded-md shadow-sm scale-90 animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
transition: "opacity 150ms ease-in-out, transform 150ms ease-in-out",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Tooltip.displayName = "Tooltip"
|
||||
|
||||
// 为了保持 API 兼容性,我们导出相同的组件名称
|
||||
export const TooltipProvider = ({ children }: { children: React.ReactNode }) => children
|
||||
export const TooltipTrigger = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((props, ref) => (
|
||||
<div ref={ref} {...props} />
|
||||
))
|
||||
TooltipTrigger.displayName = "TooltipTrigger"
|
||||
|
||||
export const TooltipContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((props, ref) => (
|
||||
<div ref={ref} {...props} />
|
||||
))
|
||||
TooltipContent.displayName = "TooltipContent"
|
||||
|
||||
export { Tooltip }
|
||||
186
app/components/ui/use-toast.ts
Normal file
186
app/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
42
app/content/[id]/materials/loading.tsx
Normal file
42
app/content/[id]/materials/loading.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
app/content/[id]/materials/new/page.tsx
Normal file
130
app/content/[id]/materials/new/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Plus, X } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
export default function NewMaterialPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [content, setContent] = useState("")
|
||||
const [newTag, setNewTag] = useState("")
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (newTag && !tags.includes(newTag)) {
|
||||
setTags([...tags, newTag])
|
||||
setNewTag("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setTags(tags.filter((tag) => tag !== tagToRemove))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!content) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入素材内容",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 模拟保存新素材
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "新素材已创建",
|
||||
})
|
||||
router.push(`/content/${params.id}/materials`)
|
||||
} catch (error) {
|
||||
console.error("Failed to create new material:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "创建新素材失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">新建素材</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<Card className="p-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="content">素材内容</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="请输入素材内容"
|
||||
className="mt-1"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tags">标签</Label>
|
||||
<div className="flex items-center mt-1">
|
||||
<Input
|
||||
id="tags"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="button" onClick={handleAddTag} className="ml-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center">
|
||||
{tag}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 ml-1 p-0"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
保存素材
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
331
app/content/[id]/materials/page.tsx
Normal file
331
app/content/[id]/materials/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
interface Material {
|
||||
id: string
|
||||
content: string
|
||||
tags: string[]
|
||||
aiAnalysis?: string
|
||||
}
|
||||
|
||||
export default function MaterialsPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [materials, setMaterials] = useState<Material[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const { ref, inView } = useInView()
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
||||
const [exportFormat, setExportFormat] = useState<"excel" | "csv" | "json">("excel")
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasMore && !isLoading) {
|
||||
loadMoreMaterials()
|
||||
}
|
||||
}, [inView, hasMore, isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMaterials = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// 模拟从API获取素材数据
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const mockMaterials: Material[] = [
|
||||
{
|
||||
id: "1",
|
||||
content: "今天的阳光真好,适合出去走走",
|
||||
tags: ["日常", "心情"],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
content: "新品上市,限时优惠,快来抢购!",
|
||||
tags: ["营销", "促销"],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
content: "学习新技能的第一天,感觉很充实",
|
||||
tags: ["学习", "成长"],
|
||||
},
|
||||
]
|
||||
setMaterials(mockMaterials)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch materials:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "获取素材数据失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
fetchMaterials()
|
||||
}, [])
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// 模拟从API获取更多素材数据
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const newMaterials: Material[] = [
|
||||
{
|
||||
id: String(materials.length + 1),
|
||||
content: `More content ${materials.length + 1}`,
|
||||
tags: ["more", "content"],
|
||||
},
|
||||
]
|
||||
|
||||
if (newMaterials.length === 0) {
|
||||
setHasMore(false)
|
||||
} else {
|
||||
setMaterials([...materials, ...newMaterials])
|
||||
setPage(page + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch more materials:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "获取更多素材数据失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewMaterial = () => {
|
||||
// 实现新建素材功能
|
||||
router.push(`/content/${params.id}/materials/new`)
|
||||
}
|
||||
|
||||
const handleAIAnalysis = async (material: Material) => {
|
||||
try {
|
||||
// 模拟AI分析过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
const analysis = "这是一条" + material.tags.join("、") + "相关的内容,情感倾向积极。"
|
||||
setMaterials(materials.map((m) => (m.id === material.id ? { ...m, aiAnalysis: analysis } : m)))
|
||||
setSelectedMaterial({ ...material, aiAnalysis: analysis })
|
||||
} catch (error) {
|
||||
console.error("AI analysis failed:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "AI分析失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMaterials = materials.filter(
|
||||
(material) =>
|
||||
material.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
material.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
)
|
||||
|
||||
const toggleSelectMaterial = (id: string) => {
|
||||
if (selectedMaterials.includes(id)) {
|
||||
setSelectedMaterials(selectedMaterials.filter((materialId) => materialId !== id))
|
||||
} else {
|
||||
setSelectedMaterials([...selectedMaterials, id])
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllMaterials = () => {
|
||||
if (selectedMaterials.length === filteredMaterials.length) {
|
||||
setSelectedMaterials([])
|
||||
} else {
|
||||
setSelectedMaterials(filteredMaterials.map((material) => material.id))
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
// 模拟导出过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
let fileName = `素材数据_${new Date().toISOString().split("T")[0]}`
|
||||
let fileExtension = ""
|
||||
|
||||
switch (exportFormat) {
|
||||
case "excel":
|
||||
fileName += ".xlsx"
|
||||
fileExtension = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
break
|
||||
case "csv":
|
||||
fileName += ".csv"
|
||||
fileExtension = "text/csv"
|
||||
break
|
||||
case "json":
|
||||
fileName += ".json"
|
||||
fileExtension = "application/json"
|
||||
break
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: `已成功导出为${fileName}`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error)
|
||||
toast({
|
||||
title: "导出失败",
|
||||
description: "导出素材数据失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center items-center h-screen">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">已采集素材</h1>
|
||||
</div>
|
||||
{selectedMaterials.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">已选择 {selectedMaterials.length} 项</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedMaterials([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm">
|
||||
批量删除
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
批量添加标签
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" disabled={isExporting}>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<span className="mr-2">导出中</span>
|
||||
<span className="animate-spin">⟳</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup value={exportFormat} onValueChange={(value) => setExportFormat(value as any)}>
|
||||
<DropdownMenuRadioItem value="excel">Excel格式</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="csv">CSV格式</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="json">JSON格式</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleExport}>开始导出</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button onClick={handleNewMaterial}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建素材
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索素材或标签..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{filteredMaterials.map((material) => (
|
||||
<div key={material.id} className="flex items-center justify-between bg-white p-3 rounded-lg shadow">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.includes(material.id)}
|
||||
onChange={() => toggleSelectMaterial(material.id)}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mb-2">{material.content}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{material.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Tag className="h-3 w-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={() => handleAIAnalysis(material)}>
|
||||
<BarChart className="h-4 w-4 mr-1" />
|
||||
AI分析
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 分析结果</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<p>{selectedMaterial?.aiAnalysis || "正在分析中..."}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredMaterials.length > 0 && hasMore && (
|
||||
<div ref={ref} className="py-4 text-center">
|
||||
{isLoading ? "加载中..." : "向下滚动加载更多"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
289
app/content/[id]/page.tsx
Normal file
289
app/content/[id]/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Save } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker"
|
||||
import { WechatFriendSelector } from "@/components/WechatFriendSelector"
|
||||
import { WechatGroupSelector } from "@/components/WechatGroupSelector"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string
|
||||
name: string
|
||||
sourceType: "friends" | "groups"
|
||||
keywordsInclude: string
|
||||
keywordsExclude: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
selectedFriends: any[]
|
||||
selectedGroups: any[]
|
||||
useAI: boolean
|
||||
aiPrompt: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function ContentLibraryPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [library, setLibrary] = useState<ContentLibrary | null>(null)
|
||||
const [isWechatFriendSelectorOpen, setIsWechatFriendSelectorOpen] = useState(false)
|
||||
const [isWechatGroupSelectorOpen, setIsWechatGroupSelectorOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLibrary = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// 模拟从API获取内容库数据
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const data = {
|
||||
id: params.id,
|
||||
name: "示例内容库",
|
||||
sourceType: "friends",
|
||||
keywordsInclude: "关键词1,关键词2",
|
||||
keywordsExclude: "排除词1,排除词2",
|
||||
startDate: "2024-01-01",
|
||||
endDate: "2024-12-31",
|
||||
selectedFriends: [
|
||||
{ id: "1", nickname: "张三", avatar: "/placeholder.svg?height=40&width=40" },
|
||||
{ id: "2", nickname: "李四", avatar: "/placeholder.svg?height=40&width=40" },
|
||||
],
|
||||
selectedGroups: [],
|
||||
useAI: true,
|
||||
aiPrompt: "AI提示词示例",
|
||||
enabled: true,
|
||||
}
|
||||
setLibrary(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch library data:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "获取内容库数据失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
fetchLibrary()
|
||||
}, [params.id])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!library) return
|
||||
try {
|
||||
// 模拟保存到API
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "内容库已保存",
|
||||
})
|
||||
// 这里应该调用一个函数来更新外部展示的数据
|
||||
// updateExternalDisplay(library)
|
||||
} catch (error) {
|
||||
console.error("Failed to save library:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存内容库失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center items-center h-screen">加载中...</div>
|
||||
}
|
||||
|
||||
if (!library) {
|
||||
return <div className="flex justify-center items-center h-screen">内容库不存在</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">内容库详情</h1>
|
||||
</div>
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-base required">
|
||||
内容库名称
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={library.name}
|
||||
onChange={(e) => setLibrary({ ...library, name: e.target.value })}
|
||||
placeholder="请输入内容库名称"
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">数据来源配置</Label>
|
||||
<Tabs
|
||||
value={library.sourceType}
|
||||
onValueChange={(value: "friends" | "groups") => setLibrary({ ...library, sourceType: value })}
|
||||
className="mt-1.5"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="friends">选择微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">选择聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="friends" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatFriendSelectorOpen(true)}>
|
||||
选择微信好友
|
||||
</Button>
|
||||
{library.selectedFriends.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{library.selectedFriends.map((friend) => (
|
||||
<div key={friend.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<span>{friend.nickname}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="groups" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatGroupSelectorOpen(true)}>
|
||||
选择聊天群
|
||||
</Button>
|
||||
{library.selectedGroups.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{library.selectedGroups.map((group) => (
|
||||
<div key={group.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="keywords">
|
||||
<AccordionTrigger>关键字设置</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="keywordsInclude" className="text-base">
|
||||
关键字匹配
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsInclude"
|
||||
value={library.keywordsInclude}
|
||||
onChange={(e) => setLibrary({ ...library, keywordsInclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="keywordsExclude" className="text-base">
|
||||
关键字排除
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsExclude"
|
||||
value={library.keywordsExclude}
|
||||
onChange={(e) => setLibrary({ ...library, keywordsExclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,匹配到关键字的,系统将不会采集。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base">是否启用AI</Label>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
当启用AI之后,该内容库下的所有内容,都会通过AI重新生成内容。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={library.useAI}
|
||||
onCheckedChange={(checked) => setLibrary({ ...library, useAI: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{library.useAI && (
|
||||
<div>
|
||||
<Label htmlFor="aiPrompt" className="text-base">
|
||||
AI 提示词
|
||||
</Label>
|
||||
<Textarea
|
||||
id="aiPrompt"
|
||||
value={library.aiPrompt}
|
||||
onChange={(e) => setLibrary({ ...library, aiPrompt: e.target.value })}
|
||||
placeholder="请输入 AI 提示词"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-base">时间限制</Label>
|
||||
<DateRangePicker
|
||||
className="mt-1.5"
|
||||
onChange={(range) => {
|
||||
if (range?.from) {
|
||||
setLibrary({
|
||||
...library,
|
||||
startDate: range.from.toISOString(),
|
||||
endDate: range.to?.toISOString() || "",
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base required">是否启用</Label>
|
||||
<Switch
|
||||
checked={library.enabled}
|
||||
onCheckedChange={(checked) => setLibrary({ ...library, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<WechatFriendSelector
|
||||
open={isWechatFriendSelectorOpen}
|
||||
onOpenChange={setIsWechatFriendSelectorOpen}
|
||||
selectedFriends={library.selectedFriends}
|
||||
onSelect={(friends) => setLibrary({ ...library, selectedFriends: friends })}
|
||||
/>
|
||||
|
||||
<WechatGroupSelector
|
||||
open={isWechatGroupSelectorOpen}
|
||||
onOpenChange={setIsWechatGroupSelectorOpen}
|
||||
selectedGroups={library.selectedGroups}
|
||||
onSelect={(groups) => setLibrary({ ...library, selectedGroups: groups })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
app/content/loading.tsx
Normal file
52
app/content/loading.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export default function ContentLoading() {
|
||||
return (
|
||||
<div className="container mx-auto p-4 space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
{Array(3)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<Card key={i} className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-6 w-32 mb-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Skeleton className="h-7 w-48" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array(5)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
app/content/new/device-selector.tsx
Normal file
116
app/content/new/device-selector.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Search, Plus } from "lucide-react"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
account: string
|
||||
status: "online" | "offline"
|
||||
}
|
||||
|
||||
const mockDevices: Device[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "iPhone 13 Pro",
|
||||
account: "wxid_abc123",
|
||||
status: "online",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Huawei P40",
|
||||
account: "wxid_xyz789",
|
||||
status: "offline",
|
||||
},
|
||||
]
|
||||
|
||||
interface DeviceSelectorProps {
|
||||
selectedDevices: string[]
|
||||
onChange: (devices: string[]) => void
|
||||
}
|
||||
|
||||
export function DeviceSelector({ selectedDevices, onChange }: DeviceSelectorProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedDevices.length === mockDevices.length) {
|
||||
onChange([])
|
||||
} else {
|
||||
onChange(mockDevices.map((device) => device.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDevice = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onChange(selectedDevices.filter((id) => id !== deviceId))
|
||||
} else {
|
||||
onChange([...selectedDevices, deviceId])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button className="ml-2" size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加设备
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox checked={selectedDevices.length === mockDevices.length} onCheckedChange={toggleSelectAll} />
|
||||
</TableHead>
|
||||
<TableHead>设备名称</TableHead>
|
||||
<TableHead>微信账号</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockDevices.map((device) => (
|
||||
<TableRow key={device.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onCheckedChange={() => toggleDevice(device.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{device.name}</TableCell>
|
||||
<TableCell>{device.account}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
|
||||
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
273
app/content/new/page.tsx
Normal file
273
app/content/new/page.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker"
|
||||
import { WechatFriendSelector } from "@/components/WechatFriendSelector"
|
||||
import { WechatGroupSelector } from "@/components/WechatGroupSelector"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
|
||||
interface WechatFriend {
|
||||
id: string
|
||||
nickname: string
|
||||
wechatId: string
|
||||
avatar: string
|
||||
gender: "male" | "female"
|
||||
customer: string
|
||||
}
|
||||
|
||||
interface WechatGroup {
|
||||
id: string
|
||||
name: string
|
||||
memberCount: number
|
||||
avatar: string
|
||||
owner: string
|
||||
customer: string
|
||||
}
|
||||
|
||||
export default function NewContentLibraryPage() {
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
sourceType: "friends" as "friends" | "groups",
|
||||
keywordsInclude: "",
|
||||
keywordsExclude: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
selectedFriends: [] as WechatFriend[],
|
||||
selectedGroups: [] as WechatGroup[],
|
||||
useAI: false,
|
||||
aiPrompt: "",
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const [isWechatFriendSelectorOpen, setIsWechatFriendSelectorOpen] = useState(false)
|
||||
const [isWechatGroupSelectorOpen, setIsWechatGroupSelectorOpen] = useState(false)
|
||||
|
||||
const removeFriend = (friendId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
selectedFriends: prev.selectedFriends.filter((friend) => friend.id !== friendId),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeGroup = (groupId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
selectedGroups: prev.selectedGroups.filter((group) => group.id !== groupId),
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">新建内容库</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-base required">
|
||||
内容库名称
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="请输入内容库名称"
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">数据来源配置</Label>
|
||||
<Tabs
|
||||
value={formData.sourceType}
|
||||
onValueChange={(value: "friends" | "groups") => setFormData({ ...formData, sourceType: value })}
|
||||
className="mt-1.5"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="friends">选择微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">选择聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="friends" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatFriendSelectorOpen(true)}>
|
||||
选择微信好友
|
||||
</Button>
|
||||
{formData.selectedFriends.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{formData.selectedFriends.map((friend) => (
|
||||
<div key={friend.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={friend.avatar || "/placeholder.svg"}
|
||||
alt={friend.nickname}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span>{friend.nickname}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeFriend(friend.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="groups" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatGroupSelectorOpen(true)}>
|
||||
选择聊天群
|
||||
</Button>
|
||||
{formData.selectedGroups.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{formData.selectedGroups.map((group) => (
|
||||
<div key={group.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={group.avatar || "/placeholder.svg"}
|
||||
alt={group.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeGroup(group.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="keywords">
|
||||
<AccordionTrigger>关键字设置</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="keywordsInclude" className="text-base">
|
||||
关键字匹配
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsInclude"
|
||||
value={formData.keywordsInclude}
|
||||
onChange={(e) => setFormData({ ...formData, keywordsInclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="keywordsExclude" className="text-base">
|
||||
关键字排除
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsExclude"
|
||||
value={formData.keywordsExclude}
|
||||
onChange={(e) => setFormData({ ...formData, keywordsExclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,匹配到关键字的,系统将不会采集。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base">是否启用AI</Label>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
当启用AI之后,该内容库下的所有内容,都会通过AI重新生成内容。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.useAI}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, useAI: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.useAI && (
|
||||
<div>
|
||||
<Label htmlFor="aiPrompt" className="text-base">
|
||||
AI 提示词
|
||||
</Label>
|
||||
<Textarea
|
||||
id="aiPrompt"
|
||||
value={formData.aiPrompt}
|
||||
onChange={(e) => setFormData({ ...formData, aiPrompt: e.target.value })}
|
||||
placeholder="请输入 AI 提示词"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-base">时间限制</Label>
|
||||
<DateRangePicker
|
||||
className="mt-1.5"
|
||||
onChange={(range) => {
|
||||
if (range?.from) {
|
||||
setFormData({
|
||||
...formData,
|
||||
startDate: range.from.toISOString(),
|
||||
endDate: range.to?.toISOString() || "",
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base required">是否启用</Label>
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={() => router.back()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatFriendSelector
|
||||
open={isWechatFriendSelectorOpen}
|
||||
onOpenChange={setIsWechatFriendSelectorOpen}
|
||||
selectedFriends={formData.selectedFriends}
|
||||
onSelect={(friends) => setFormData({ ...formData, selectedFriends: friends })}
|
||||
/>
|
||||
|
||||
<WechatGroupSelector
|
||||
open={isWechatGroupSelectorOpen}
|
||||
onOpenChange={setIsWechatGroupSelectorOpen}
|
||||
selectedGroups={formData.selectedGroups}
|
||||
onSelect={(groups) => setFormData({ ...formData, selectedGroups: groups })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
app/content/page.tsx
Normal file
213
app/content/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Filter, Search, RefreshCw, Plus, Edit, Trash2, Eye, MoreVertical } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import Image from "next/image"
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string
|
||||
name: string
|
||||
source: "friends" | "groups"
|
||||
targetAudience: {
|
||||
id: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
}[]
|
||||
creator: string
|
||||
itemCount: number
|
||||
lastUpdated: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function ContentLibraryPage() {
|
||||
const router = useRouter()
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([
|
||||
{
|
||||
id: "129",
|
||||
name: "微信好友广告",
|
||||
source: "friends",
|
||||
targetAudience: [
|
||||
{ id: "1", nickname: "张三", avatar: "/placeholder.svg?height=40&width=40" },
|
||||
{ id: "2", nickname: "李四", avatar: "/placeholder.svg?height=40&width=40" },
|
||||
{ id: "3", nickname: "王五", avatar: "/placeholder.svg?height=40&width=40" },
|
||||
],
|
||||
creator: "海尼",
|
||||
itemCount: 0,
|
||||
lastUpdated: "2024-02-09 12:30",
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "127",
|
||||
name: "开发群",
|
||||
source: "groups",
|
||||
targetAudience: [{ id: "4", nickname: "开发群1", avatar: "/placeholder.svg?height=40&width=40" }],
|
||||
creator: "karuo",
|
||||
itemCount: 0,
|
||||
lastUpdated: "2024-02-09 12:30",
|
||||
enabled: true,
|
||||
},
|
||||
])
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [activeTab, setActiveTab] = useState("all")
|
||||
|
||||
const handleCreateNew = () => {
|
||||
// 模拟创建新内容库
|
||||
const newId = Date.now().toString()
|
||||
const newLibrary = {
|
||||
id: newId,
|
||||
name: "新内容库",
|
||||
source: "friends" as const,
|
||||
targetAudience: [],
|
||||
creator: "当前用户",
|
||||
itemCount: 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
enabled: true,
|
||||
}
|
||||
setLibraries([newLibrary, ...libraries])
|
||||
router.push(`/content/${newId}`)
|
||||
}
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
router.push(`/content/${id}`)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
// 实现删除功能
|
||||
setLibraries(libraries.filter((lib) => lib.id !== id))
|
||||
}
|
||||
|
||||
const handleViewMaterials = (id: string) => {
|
||||
router.push(`/content/${id}/materials`)
|
||||
}
|
||||
|
||||
const filteredLibraries = libraries.filter(
|
||||
(library) =>
|
||||
(activeTab === "all" || library.source === activeTab) &&
|
||||
(library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
library.targetAudience.some((target) => target.nickname.toLowerCase().includes(searchQuery.toLowerCase()))),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">内容库</h1>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索内容库..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="friends">微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredLibraries.map((library) => (
|
||||
<Card key={library.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium">{library.name}</h3>
|
||||
<Badge variant={library.enabled ? "success" : "secondary"}>
|
||||
{library.enabled ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>来源:</span>
|
||||
<div className="flex -space-x-2 overflow-hidden">
|
||||
{library.targetAudience.slice(0, 3).map((target) => (
|
||||
<Image
|
||||
key={target.id}
|
||||
src={target.avatar || "/placeholder.svg"}
|
||||
alt={target.nickname}
|
||||
width={24}
|
||||
height={24}
|
||||
className="inline-block h-6 w-6 rounded-full ring-2 ring-white"
|
||||
/>
|
||||
))}
|
||||
{library.targetAudience.length > 3 && (
|
||||
<span className="flex items-center justify-center w-6 h-6 text-xs font-medium text-white bg-gray-400 rounded-full ring-2 ring-white">
|
||||
+{library.targetAudience.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>创建人:{library.creator}</div>
|
||||
<div>内容数量:{library.itemCount}</div>
|
||||
<div>更新时间:{library.lastUpdated}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(library.id)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(library.id)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleViewMaterials(library.id)}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看素材
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
508
app/conversion/page.tsx
Normal file
508
app/conversion/page.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { BarChart, LineChart } from "@/components/charts"
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
Download,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
MessageSquare,
|
||||
} from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
export default function ConversionPage() {
|
||||
const [timeRange, setTimeRange] = useState("30days")
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 space-y-6">
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">成交转化</h1>
|
||||
<p className="text-muted-foreground mt-1">促进用户交易与转化的功能集合</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="选择时间范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7days">最近7天</SelectItem>
|
||||
<SelectItem value="30days">最近30天</SelectItem>
|
||||
<SelectItem value="90days">最近90天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成交转化概览 */}
|
||||
<Card className="border-none shadow-md overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>成交转化概览</CardTitle>
|
||||
<CardDescription>用户交易与转化整体情况</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-none shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-blue-600">浏览转化率</div>
|
||||
<div className="text-2xl font-bold mt-2">45.2%</div>
|
||||
<div className="text-sm text-green-600 mt-1 flex items-center">
|
||||
<ArrowUpRight className="h-4 w-4 mr-1" />
|
||||
较上月提升 2.1%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-100 p-2 rounded-full">
|
||||
<Activity className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>目标</span>
|
||||
<span>50%</span>
|
||||
</div>
|
||||
<Progress value={90.4} className="h-1.5 bg-blue-100" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 border-none shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-green-600">购买转化率</div>
|
||||
<div className="text-2xl font-bold mt-2">12.5%</div>
|
||||
<div className="text-sm text-green-600 mt-1 flex items-center">
|
||||
<ArrowUpRight className="h-4 w-4 mr-1" />
|
||||
较上月提升 0.8%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-green-100 p-2 rounded-full">
|
||||
<ShoppingCart className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>目标</span>
|
||||
<span>15%</span>
|
||||
</div>
|
||||
<Progress value={83.3} className="h-1.5 bg-green-100" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-none shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-purple-600">场景转化率</div>
|
||||
<div className="text-2xl font-bold mt-2">18.3%</div>
|
||||
<div className="text-sm text-green-600 mt-1 flex items-center">
|
||||
<ArrowUpRight className="h-4 w-4 mr-1" />
|
||||
较上月提升 1.2%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-purple-100 p-2 rounded-full">
|
||||
<MessageSquare className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>目标</span>
|
||||
<span>20%</span>
|
||||
</div>
|
||||
<Progress value={91.5} className="h-1.5 bg-purple-100" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-amber-50 to-yellow-50 border-none shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-amber-600">客单价</div>
|
||||
<div className="text-2xl font-bold mt-2">¥245</div>
|
||||
<div className="text-sm text-green-600 mt-1 flex items-center">
|
||||
<ArrowUpRight className="h-4 w-4 mr-1" />
|
||||
较上月提升 5.2%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-amber-100 p-2 rounded-full">
|
||||
<TrendingUp className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>目标</span>
|
||||
<span>¥250</span>
|
||||
</div>
|
||||
<Progress value={98} className="h-1.5 bg-amber-100" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 用户行为分析 */}
|
||||
<Card className="border-none shadow-md overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>用户行为分析</CardTitle>
|
||||
<CardDescription>用户行为与交互数据分析</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-blue-100 text-blue-800">转化</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">用户行为分析</h3>
|
||||
<BarChart
|
||||
data={{
|
||||
labels: ["浏览", "搜索", "点击", "加购", "下单", "支付", "分享"],
|
||||
datasets: [
|
||||
{
|
||||
label: "行为占比(%)",
|
||||
data: [100, 65, 45, 25, 15, 12, 8],
|
||||
backgroundColor: "rgba(59, 130, 246, 0.8)",
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">用户活跃度分析</h3>
|
||||
<LineChart
|
||||
data={{
|
||||
labels: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
|
||||
datasets: [
|
||||
{
|
||||
label: "活跃用户数",
|
||||
data: [65000, 59000, 80000, 81000, 56000, 85000, 90000],
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
{
|
||||
label: "平均停留时间(分钟)",
|
||||
data: [12, 10, 14, 15, 11, 18, 20],
|
||||
borderColor: "rgb(16, 185, 129)",
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Card className="bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">用户行为指标</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">平均活跃度</span>
|
||||
<span className="text-sm font-medium">68.5%</span>
|
||||
</div>
|
||||
<Progress value={68.5} className="h-1.5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">平均停留时间</span>
|
||||
<span className="text-sm font-medium">12.5分钟</span>
|
||||
</div>
|
||||
<Progress value={62.5} className="h-1.5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">互动率</span>
|
||||
<span className="text-sm font-medium">45.2%</span>
|
||||
</div>
|
||||
<Progress value={45.2} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 交易漏斗分析 */}
|
||||
<Card className="border-none shadow-md overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-green-50 to-emerald-50 border-b">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>交易漏斗分析</CardTitle>
|
||||
<CardDescription>用户交易转化漏斗分析</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-blue-100 text-blue-800">转化</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">转化漏斗</h3>
|
||||
<div className="flex flex-col items-center space-y-2 py-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold inline-block py-1 px-2 uppercase rounded-full text-blue-600 bg-blue-200">
|
||||
浏览 (100%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-semibold inline-block text-blue-600">125,689</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-blue-200">
|
||||
<div
|
||||
style={{ width: "100%" }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-blue-500"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold inline-block py-1 px-2 uppercase rounded-full text-blue-600 bg-blue-200">
|
||||
加入购物车 (45%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-semibold inline-block text-blue-600">56,560</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-blue-200">
|
||||
<div
|
||||
style={{ width: "45%" }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-blue-500"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold inline-block py-1 px-2 uppercase rounded-full text-yellow-600 bg-yellow-200">
|
||||
下单 (18%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-semibold inline-block text-yellow-600">22,624</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-yellow-200">
|
||||
<div
|
||||
style={{ width: "18%" }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-yellow-500"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold inline-block py-1 px-2 uppercase rounded-full text-green-600 bg-green-200">
|
||||
支付完成 (8%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-semibold inline-block text-green-600">10,055</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-green-200">
|
||||
<div
|
||||
style={{ width: "8%" }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-green-500"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">转化率趋势</h3>
|
||||
<LineChart
|
||||
data={{
|
||||
labels: ["1月", "2月", "3月", "4月", "5月", "6月", "7月"],
|
||||
datasets: [
|
||||
{
|
||||
label: "浏览转化率",
|
||||
data: [40, 42, 43, 44, 45, 46, 45],
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
{
|
||||
label: "购买转化率",
|
||||
data: [15, 16, 16.5, 17, 17.5, 18, 18],
|
||||
borderColor: "rgb(16, 185, 129)",
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 场景管理 */}
|
||||
<Card className="border-none shadow-md overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-purple-50 to-pink-50 border-b">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>场景管理</CardTitle>
|
||||
<CardDescription>用户交互场景管理与分析</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-blue-100 text-blue-800">转化</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">场景概览</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">活跃场景数</span>
|
||||
<span className="text-sm font-medium">48</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">场景转化率</span>
|
||||
<span className="text-sm font-medium">18.3%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">场景覆盖率</span>
|
||||
<span className="text-sm font-medium">85.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">热门场景</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">社交场景</span>
|
||||
<span className="text-sm font-medium">35%</span>
|
||||
</div>
|
||||
<Progress value={35} className="h-1.5" />
|
||||
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-sm">购物场景</span>
|
||||
<span className="text-sm font-medium">30%</span>
|
||||
</div>
|
||||
<Progress value={30} className="h-1.5" />
|
||||
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-sm">内容消费</span>
|
||||
<span className="text-sm font-medium">20%</span>
|
||||
</div>
|
||||
<Progress value={20} className="h-1.5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">场景效果</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">平均停留时间</span>
|
||||
<span className="text-sm font-medium">8.5分钟</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">互动率</span>
|
||||
<span className="text-sm font-medium">42.3%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">转化率</span>
|
||||
<span className="text-sm font-medium">18.3%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">场景使用趋势</h3>
|
||||
<LineChart
|
||||
data={{
|
||||
labels: ["1月", "2月", "3月", "4月", "5月", "6月", "7月"],
|
||||
datasets: [
|
||||
{
|
||||
label: "社交场景",
|
||||
data: [25000, 27000, 28000, 30000, 32000, 35000, 38000],
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
{
|
||||
label: "购物场景",
|
||||
data: [20000, 22000, 24000, 25000, 28000, 30000, 32000],
|
||||
borderColor: "rgb(16, 185, 129)",
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
app/data-dictionary/page.tsx
Normal file
142
app/data-dictionary/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { PlusCircle } from "lucide-react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
interface DataDictionaryItem {
|
||||
id: string
|
||||
fieldName: string
|
||||
description: string
|
||||
dataType: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export default function DataDictionaryPage() {
|
||||
const [dataDictionary, setDataDictionary] = useState<DataDictionaryItem[]>([
|
||||
{
|
||||
id: "1",
|
||||
fieldName: "username",
|
||||
description: "用户名",
|
||||
dataType: "string",
|
||||
source: "caihong_shua_users",
|
||||
},
|
||||
])
|
||||
const [newFieldName, setNewFieldName] = useState("")
|
||||
const [newFieldDescription, setNewFieldDescription] = useState("")
|
||||
const [newFieldDataType, setNewFieldDataType] = useState("string")
|
||||
const [newFieldSource, setNewFieldSource] = useState("")
|
||||
|
||||
const handleAddEntry = () => {
|
||||
if (newFieldName.trim() === "") {
|
||||
alert("字段名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
const newEntry: DataDictionaryItem = {
|
||||
id: Date.now().toString(),
|
||||
fieldName: newFieldName,
|
||||
description: newFieldDescription,
|
||||
dataType: newFieldDataType,
|
||||
source: newFieldSource,
|
||||
}
|
||||
|
||||
setDataDictionary([...dataDictionary, newEntry])
|
||||
setNewFieldName("")
|
||||
setNewFieldDescription("")
|
||||
setNewFieldDataType("string")
|
||||
setNewFieldSource("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">数据字典管理</h1>
|
||||
<p className="text-muted-foreground mt-1">管理和维护数据字典</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>添加数据字典条目</CardTitle>
|
||||
<CardDescription>手动添加数据字典条目</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="field-name">字段名称</Label>
|
||||
<Input
|
||||
id="field-name"
|
||||
placeholder="请输入字段名称"
|
||||
value={newFieldName}
|
||||
onChange={(e) => setNewFieldName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="field-description">字段描述</Label>
|
||||
<Input
|
||||
id="field-description"
|
||||
placeholder="请输入字段描述"
|
||||
value={newFieldDescription}
|
||||
onChange={(e) => setNewFieldDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="field-data-type">数据类型</Label>
|
||||
<Input
|
||||
id="field-data-type"
|
||||
placeholder="请输入数据类型"
|
||||
value={newFieldDataType}
|
||||
onChange={(e) => setNewFieldDataType(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="field-source">数据来源</Label>
|
||||
<Input
|
||||
id="field-source"
|
||||
placeholder="请输入数据来源"
|
||||
value={newFieldSource}
|
||||
onChange={(e) => setNewFieldSource(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAddEntry}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
添加条目
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>数据字典列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>字段名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>数据类型</TableHead>
|
||||
<TableHead>数据来源</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dataDictionary.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.fieldName}</TableCell>
|
||||
<TableCell>{item.description}</TableCell>
|
||||
<TableCell>{item.dataType}</TableCell>
|
||||
<TableCell>{item.source}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
554
app/data-integration/page.tsx
Normal file
554
app/data-integration/page.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Database, Server, Link, Plus, FileText, RefreshCw } from "lucide-react"
|
||||
|
||||
export default function DataIntegrationPage() {
|
||||
const [activeTab, setActiveTab] = useState("data-sources")
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">数据中台</h1>
|
||||
<p className="text-muted-foreground">管理数据源和API接口</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="data-sources">数据集成</TabsTrigger>
|
||||
<TabsTrigger value="api-management">API管理</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="data-sources" className="space-y-6">
|
||||
<DataSourcesTab setIsDialogOpen={setIsDialogOpen} />
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/database-structure">
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
查看数据库结构
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api-management" className="space-y-6">
|
||||
<ApiManagementTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AddDataSourceDialog isOpen={isDialogOpen} setIsOpen={setIsDialogOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 数据源管理标签页
|
||||
function DataSourcesTab({ setIsDialogOpen }: { setIsDialogOpen: (open: boolean) => void }) {
|
||||
// 模拟数据源列表
|
||||
const dataSources = [
|
||||
{
|
||||
id: "1",
|
||||
name: "用户数据库",
|
||||
type: "MySQL",
|
||||
host: "db.example.com",
|
||||
status: "connected",
|
||||
lastSync: "2023-07-20 15:30",
|
||||
tables: 24,
|
||||
records: 156789,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "订单系统",
|
||||
type: "PostgreSQL",
|
||||
host: "orders.example.com",
|
||||
status: "connected",
|
||||
lastSync: "2023-07-19 12:45",
|
||||
tables: 18,
|
||||
records: 89456,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "内容库",
|
||||
type: "MongoDB",
|
||||
host: "content.example.com",
|
||||
status: "error",
|
||||
lastSync: "2023-07-15 09:20",
|
||||
tables: 12,
|
||||
records: 45678,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "用户行为分析",
|
||||
type: "ClickHouse",
|
||||
host: "analytics.example.com",
|
||||
status: "connected",
|
||||
lastSync: "2023-07-20 10:15",
|
||||
tables: 8,
|
||||
records: 2345678,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "CRM系统",
|
||||
type: "Oracle",
|
||||
host: "crm.example.com",
|
||||
status: "pending",
|
||||
lastSync: "等待连接",
|
||||
tables: 0,
|
||||
records: 0,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold">数据源管理</h2>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>已连接的数据源</CardTitle>
|
||||
<CardDescription>管理和监控所有数据源连接</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>数据源名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>主机地址</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>最后同步</TableHead>
|
||||
<TableHead>表数量</TableHead>
|
||||
<TableHead>记录数</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dataSources.map((source) => (
|
||||
<TableRow key={source.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Database className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<span className="font-medium">{source.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{source.type}</TableCell>
|
||||
<TableCell>{source.host}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
source.status === "connected"
|
||||
? "bg-green-100 text-green-800"
|
||||
: source.status === "error"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}
|
||||
>
|
||||
{source.status === "connected" ? "已连接" : source.status === "error" ? "错误" : "等待中"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{source.lastSync}</TableCell>
|
||||
<TableCell>{source.tables}</TableCell>
|
||||
<TableCell>{source.records.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Link className="h-4 w-4 mr-1" />
|
||||
查看
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
同步
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>数据源类型</CardTitle>
|
||||
<CardDescription>支持的数据库和数据源类型</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col items-center p-4 border rounded-lg">
|
||||
<Database className="h-8 w-8 mb-2 text-blue-500" />
|
||||
<span className="font-medium">MySQL</span>
|
||||
<span className="text-xs text-muted-foreground">关系型数据库</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-4 border rounded-lg">
|
||||
<Database className="h-8 w-8 mb-2 text-blue-500" />
|
||||
<span className="font-medium">PostgreSQL</span>
|
||||
<span className="text-xs text-muted-foreground">关系型数据库</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-4 border rounded-lg">
|
||||
<Database className="h-8 w-8 mb-2 text-green-500" />
|
||||
<span className="font-medium">MongoDB</span>
|
||||
<span className="text-xs text-muted-foreground">文档型数据库</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-4 border rounded-lg">
|
||||
<Database className="h-8 w-8 mb-2 text-yellow-500" />
|
||||
<span className="font-medium">ClickHouse</span>
|
||||
<span className="text-xs text-muted-foreground">列式数据库</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-4 border rounded-lg">
|
||||
<Database className="h-8 w-8 mb-2 text-red-500" />
|
||||
<span className="font-medium">Oracle</span>
|
||||
<span className="text-xs text-muted-foreground">关系型数据库</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-4 border rounded-lg">
|
||||
<Database className="h-8 w-8 mb-2 text-purple-500" />
|
||||
<span className="font-medium">SQL Server</span>
|
||||
<span className="text-xs text-muted-foreground">关系型数据库</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-4 border rounded-lg">
|
||||
<Server className="h-8 w-8 mb-2 text-gray-500" />
|
||||
<span className="font-medium">Redis</span>
|
||||
<span className="text-xs text-muted-foreground">键值存储</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-4 border rounded-lg">
|
||||
<FileText className="h-8 w-8 mb-2 text-gray-500" />
|
||||
<span className="font-medium">CSV/Excel</span>
|
||||
<span className="text-xs text-muted-foreground">文件导入</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// API管理标签页
|
||||
function ApiManagementTab() {
|
||||
// 模拟API接口数据
|
||||
const apiEndpoints = [
|
||||
{
|
||||
id: "1",
|
||||
name: "用户数据API",
|
||||
endpoint: "/api/users",
|
||||
method: "GET",
|
||||
category: "用户画像",
|
||||
status: "active",
|
||||
calls: 12567,
|
||||
lastCalled: "2023-07-20 16:45",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "用户标签API",
|
||||
endpoint: "/api/users/tags",
|
||||
method: "GET",
|
||||
category: "用户画像",
|
||||
status: "active",
|
||||
calls: 8945,
|
||||
lastCalled: "2023-07-20 15:30",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "流量池数据API",
|
||||
endpoint: "/api/traffic-pools",
|
||||
method: "GET",
|
||||
category: "流量池",
|
||||
status: "active",
|
||||
calls: 5678,
|
||||
lastCalled: "2023-07-20 14:20",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "AI分析API",
|
||||
endpoint: "/api/ai/analyze",
|
||||
method: "POST",
|
||||
category: "AI分析",
|
||||
status: "active",
|
||||
calls: 3456,
|
||||
lastCalled: "2023-07-20 13:15",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "数据同步API",
|
||||
endpoint: "/api/sync",
|
||||
method: "POST",
|
||||
category: "数据集成",
|
||||
status: "maintenance",
|
||||
calls: 2345,
|
||||
lastCalled: "2023-07-19 10:30",
|
||||
},
|
||||
]
|
||||
|
||||
// 模拟API密钥数据
|
||||
const apiKeys = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Web应用",
|
||||
key: "sk_web_*************",
|
||||
created: "2023-05-15",
|
||||
lastUsed: "2023-07-20",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "移动应用",
|
||||
key: "sk_mobile_*************",
|
||||
created: "2023-06-10",
|
||||
lastUsed: "2023-07-19",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "第三方集成",
|
||||
key: "sk_partner_*************",
|
||||
created: "2023-04-20",
|
||||
lastUsed: "2023-07-18",
|
||||
status: "active",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold">API接口管理</h2>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
创建新API
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API接口列表</CardTitle>
|
||||
<CardDescription>所有可用的API接口</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>API名称</TableHead>
|
||||
<TableHead>接口地址</TableHead>
|
||||
<TableHead>方法</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>调用次数</TableHead>
|
||||
<TableHead>最后调用</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiEndpoints.map((api) => (
|
||||
<TableRow key={api.id}>
|
||||
<TableCell className="font-medium">{api.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-sm">{api.endpoint}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
api.method === "GET"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: api.method === "POST"
|
||||
? "bg-green-100 text-green-800"
|
||||
: api.method === "PUT"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}
|
||||
>
|
||||
{api.method}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{api.category}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
api.status === "active" ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"
|
||||
}
|
||||
>
|
||||
{api.status === "active" ? "正常" : "维护中"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{api.calls.toLocaleString()}</TableCell>
|
||||
<TableCell>{api.lastCalled}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
文档
|
||||
</Button>
|
||||
<Button size="sm">测试</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API密钥管理</CardTitle>
|
||||
<CardDescription>管理API访问密钥</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>应用名称</TableHead>
|
||||
<TableHead>密钥</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>最后使用</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-medium">{key.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-sm">{key.key}</code>
|
||||
</TableCell>
|
||||
<TableCell>{key.created}</TableCell>
|
||||
<TableCell>{key.lastUsed}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-100 text-green-800">{key.status === "active" ? "有效" : "已禁用"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
重置
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-red-500">
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API使用统计</CardTitle>
|
||||
<CardDescription>API调用量和性能统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>API调用统计图表</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 添加数据源对话框
|
||||
function AddDataSourceDialog({ isOpen, setIsOpen }: { isOpen: boolean; setIsOpen: (open: boolean) => void }) {
|
||||
const [dbType, setDbType] = useState("mysql")
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加新数据源</DialogTitle>
|
||||
<DialogDescription>连接到新的数据库或数据源</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="db-type" className="text-right">
|
||||
数据库类型
|
||||
</Label>
|
||||
<Select value={dbType} onValueChange={setDbType} className="col-span-3">
|
||||
<SelectTrigger id="db-type">
|
||||
<SelectValue placeholder="选择数据库类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgresql">PostgreSQL</SelectItem>
|
||||
<SelectItem value="mongodb">MongoDB</SelectItem>
|
||||
<SelectItem value="clickhouse">ClickHouse</SelectItem>
|
||||
<SelectItem value="oracle">Oracle</SelectItem>
|
||||
<SelectItem value="sqlserver">SQL Server</SelectItem>
|
||||
<SelectItem value="redis">Redis</SelectItem>
|
||||
<SelectItem value="file">CSV/Excel文件</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
数据源名称
|
||||
</Label>
|
||||
<Input id="name" placeholder="给数据源起个名字" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="host" className="text-right">
|
||||
主机地址
|
||||
</Label>
|
||||
<Input id="host" placeholder="例如: db.example.com" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="port" className="text-right">
|
||||
端口
|
||||
</Label>
|
||||
<Input
|
||||
id="port"
|
||||
placeholder={dbType === "mysql" ? "3306" : dbType === "postgresql" ? "5432" : "27017"}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="database" className="text-right">
|
||||
数据库名
|
||||
</Label>
|
||||
<Input id="database" placeholder="数据库名称" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="username" className="text-right">
|
||||
用户名
|
||||
</Label>
|
||||
<Input id="username" placeholder="数据库用户名" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="password" className="text-right">
|
||||
密码
|
||||
</Label>
|
||||
<Input id="password" type="password" placeholder="数据库密码" className="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">测试连接</Button>
|
||||
<Button type="submit">保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
3
app/data-platform/loading.tsx
Normal file
3
app/data-platform/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
433
app/data-platform/page.tsx
Normal file
433
app/data-platform/page.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Database, Server, Link, RefreshCw, Download, Mail, FileText } from "lucide-react"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { DataSourceList } from "@/components/data-integration/data-source-list"
|
||||
import { DataFieldMapping } from "@/components/data-integration/data-field-mapping"
|
||||
import { DataCollectionSettings } from "@/components/data-integration/data-collection-settings"
|
||||
import { ApiDocumentation } from "@/components/data-integration/api-documentation"
|
||||
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
type: "mysql" | "postgresql" | "mongodb" | "oracle" | "sqlserver" | "api" | "file"
|
||||
status: "connected" | "disconnected" | "error"
|
||||
lastSync: string
|
||||
tables: number
|
||||
records: number
|
||||
}
|
||||
|
||||
export default function DataPlatformPage() {
|
||||
const [activeTab, setActiveTab] = useState("data-integration")
|
||||
const [selectedUserSegment, setSelectedUserSegment] = useState("")
|
||||
const [analysisPrompt, setAnalysisPrompt] = useState("")
|
||||
const [selectedEmail, setSelectedEmail] = useState("")
|
||||
const [isAddingDataSource, setIsAddingDataSource] = useState(false)
|
||||
|
||||
// 数据源列表
|
||||
const dataSources: DataSource[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "用户数据库",
|
||||
type: "mysql",
|
||||
status: "connected",
|
||||
lastSync: "2023-07-21 15:30",
|
||||
tables: 12,
|
||||
records: 1250000,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "交易系统",
|
||||
type: "postgresql",
|
||||
status: "connected",
|
||||
lastSync: "2023-07-21 14:45",
|
||||
tables: 8,
|
||||
records: 3450000,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "用户行为数据",
|
||||
type: "mongodb",
|
||||
status: "connected",
|
||||
lastSync: "2023-07-21 13:20",
|
||||
tables: 5,
|
||||
records: 7800000,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "CRM系统",
|
||||
type: "oracle",
|
||||
status: "error",
|
||||
lastSync: "2023-07-20 09:15",
|
||||
tables: 15,
|
||||
records: 2100000,
|
||||
},
|
||||
]
|
||||
|
||||
// 用户画像分群列表
|
||||
const userSegments = [
|
||||
{ id: "high-value", name: "高价值用户", count: 12500 },
|
||||
{ id: "new-users", name: "新注册用户", count: 45600 },
|
||||
{ id: "inactive", name: "非活跃用户", count: 28900 },
|
||||
{ id: "potential", name: "潜在高转化用户", count: 18700 },
|
||||
{ id: "loyal", name: "忠诚用户", count: 9800 },
|
||||
{ id: "risk", name: "流失风险用户", count: 15400 },
|
||||
]
|
||||
|
||||
// 邮箱列表
|
||||
const emailList = [
|
||||
{ id: "1", email: "marketing@company.com", name: "营销团队" },
|
||||
{ id: "2", email: "sales@company.com", name: "销售团队" },
|
||||
{ id: "3", email: "product@company.com", name: "产品团队" },
|
||||
{ id: "4", email: "executive@company.com", name: "管理层" },
|
||||
]
|
||||
|
||||
const getDataSourceIcon = (type: DataSource["type"]) => {
|
||||
switch (type) {
|
||||
case "mysql":
|
||||
case "postgresql":
|
||||
case "oracle":
|
||||
case "sqlserver":
|
||||
return <Database className="h-8 w-8 text-blue-500" />
|
||||
case "mongodb":
|
||||
return <Server className="h-8 w-8 text-green-500" />
|
||||
case "api":
|
||||
case "file":
|
||||
return <Link className="h-8 w-8 text-purple-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getDataSourceTypeName = (type: DataSource["type"]) => {
|
||||
switch (type) {
|
||||
case "mysql":
|
||||
return "MySQL"
|
||||
case "postgresql":
|
||||
return "PostgreSQL"
|
||||
case "mongodb":
|
||||
return "MongoDB"
|
||||
case "oracle":
|
||||
return "Oracle"
|
||||
case "sqlserver":
|
||||
return "SQL Server"
|
||||
case "api":
|
||||
return "API"
|
||||
case "file":
|
||||
return "文件"
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: DataSource["status"]) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return <Badge className="bg-green-100 text-green-800">已连接</Badge>
|
||||
case "disconnected":
|
||||
return <Badge className="bg-gray-100 text-gray-800">未连接</Badge>
|
||||
case "error":
|
||||
return <Badge className="bg-red-100 text-red-800">错误</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">数据中台</h1>
|
||||
<p className="text-muted-foreground mt-1">数据集成、API管理与数据采集</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList className="grid grid-cols-4 w-full max-w-2xl">
|
||||
<TabsTrigger value="data-integration">数据集成</TabsTrigger>
|
||||
<TabsTrigger value="data-collection">数据采集</TabsTrigger>
|
||||
<TabsTrigger value="data-mapping">数据映射</TabsTrigger>
|
||||
<TabsTrigger value="api-management">API管理</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 数据集成标签页 */}
|
||||
<TabsContent value="data-integration" className="space-y-4">
|
||||
<Card className="border shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>数据源管理</CardTitle>
|
||||
<CardDescription>管理和查看已连接的数据源</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataSourceList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>数据整合概览</CardTitle>
|
||||
<CardDescription>基于ID、手机号、身份证号和IMEI设备号的数据整合</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border shadow-sm">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||
<div className="bg-blue-100 p-3 rounded-full mb-2">
|
||||
<Database className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-medium text-center">用户ID</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">1,245,678 条记录</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border shadow-sm">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||
<div className="bg-green-100 p-3 rounded-full mb-2">
|
||||
<FileText className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-medium text-center">手机号</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">987,543 条记录</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border shadow-sm">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||
<div className="bg-purple-100 p-3 rounded-full mb-2">
|
||||
<FileText className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-medium text-center">身份证号</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">654,321 条记录</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border shadow-sm">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||
<div className="bg-orange-100 p-3 rounded-full mb-2">
|
||||
<Server className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<h3 className="font-medium text-center">IMEI设备号</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">789,456 条记录</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-medium">数据整合状态</h3>
|
||||
<p className="text-sm text-muted-foreground">最后更新: 2023-07-21 16:30</p>
|
||||
</div>
|
||||
<Button>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">用户数据整合率</span>
|
||||
<span className="text-green-600 font-medium">87%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div className="bg-green-600 h-2.5 rounded-full" style={{ width: "87%" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">数据质量评分</span>
|
||||
<span className="text-blue-600 font-medium">92/100</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div className="bg-blue-600 h-2.5 rounded-full" style={{ width: "92%" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">数据重复率</span>
|
||||
<span className="text-yellow-600 font-medium">5.2%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div className="bg-yellow-600 h-2.5 rounded-full" style={{ width: "5.2%" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 数据采集标签页 */}
|
||||
<TabsContent value="data-collection" className="space-y-4">
|
||||
<DataCollectionSettings />
|
||||
</TabsContent>
|
||||
|
||||
{/* 数据映射标签页 */}
|
||||
<TabsContent value="data-mapping" className="space-y-4">
|
||||
<DataFieldMapping />
|
||||
</TabsContent>
|
||||
|
||||
{/* API管理标签页 */}
|
||||
<TabsContent value="api-management" className="space-y-4">
|
||||
<ApiDocumentation />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 添加数据源对话框 */}
|
||||
<Dialog open={isAddingDataSource} onOpenChange={setIsAddingDataSource}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加新数据源</DialogTitle>
|
||||
<DialogDescription>选择数据源类型并填写连接信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="datasource-type" className="text-right">
|
||||
数据源类型
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="选择数据源类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgresql">PostgreSQL</SelectItem>
|
||||
<SelectItem value="mongodb">MongoDB</SelectItem>
|
||||
<SelectItem value="oracle">Oracle</SelectItem>
|
||||
<SelectItem value="sqlserver">SQL Server</SelectItem>
|
||||
<SelectItem value="api">API</SelectItem>
|
||||
<SelectItem value="file">文件</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
数据源名称
|
||||
</Label>
|
||||
<Input id="name" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="host" className="text-right">
|
||||
主机地址
|
||||
</Label>
|
||||
<Input id="host" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="port" className="text-right">
|
||||
端口
|
||||
</Label>
|
||||
<Input id="port" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="username" className="text-right">
|
||||
用户名
|
||||
</Label>
|
||||
<Input id="username" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="password" className="text-right">
|
||||
密码
|
||||
</Label>
|
||||
<Input id="password" type="password" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="database" className="text-right">
|
||||
数据库名
|
||||
</Label>
|
||||
<Input id="database" className="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddingDataSource(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setIsAddingDataSource(false)}>测试连接</Button>
|
||||
<Button onClick={() => setIsAddingDataSource(false)}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* AI数据分析对话框 */}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="hidden">打开AI数据分析</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI数据分析</DialogTitle>
|
||||
<DialogDescription>选择用户分群并输入分析需求</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>选择用户分群</Label>
|
||||
<Select value={selectedUserSegment} onValueChange={setSelectedUserSegment}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择用户分群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userSegments.map((segment) => (
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.name} ({segment.count.toLocaleString()}人)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>分析需求</Label>
|
||||
<Textarea
|
||||
placeholder="例如:分析该用户群体的消费习惯和偏好"
|
||||
value={analysisPrompt}
|
||||
onChange={(e) => setAnalysisPrompt(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>发送分析报告至</Label>
|
||||
<Select value={selectedEmail} onValueChange={setSelectedEmail}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择接收邮箱" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailList.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name} ({item.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="schedule" />
|
||||
<Label htmlFor="schedule">定期发送分析报告</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline">取消</Button>
|
||||
<Button>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
生成分析并发送
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
app/database-structure/page.tsx
Normal file
10
app/database-structure/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DatabaseStructureViewer } from "@/components/data-integration/database-structure-viewer"
|
||||
|
||||
export default function DatabaseStructurePage() {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<h1 className="text-2xl font-bold mb-6">数据库结构查看器</h1>
|
||||
<DatabaseStructureViewer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
282
app/devices/[id]/page.tsx
Normal file
282
app/devices/[id]/page.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeft, Smartphone, Battery, Wifi, MessageCircle, Users, Settings, History } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
interface WechatAccount {
|
||||
id: string
|
||||
avatar: string
|
||||
nickname: string
|
||||
wechatId: string
|
||||
gender: "male" | "female"
|
||||
status: "normal" | "abnormal"
|
||||
addFriendStatus: "enabled" | "disabled"
|
||||
friendCount: number
|
||||
lastActive: string
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
imei: string
|
||||
name: string
|
||||
status: "online" | "offline"
|
||||
battery: number
|
||||
lastActive: string
|
||||
historicalIds: string[]
|
||||
wechatAccounts: WechatAccount[]
|
||||
features: {
|
||||
autoAddFriend: boolean
|
||||
autoReply: boolean
|
||||
contentSync: boolean
|
||||
aiChat: boolean
|
||||
}
|
||||
history: {
|
||||
time: string
|
||||
action: string
|
||||
operator: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function DeviceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const [device, setDevice] = useState<Device | null>(null)
|
||||
const [activeTab, setActiveTab] = useState("info")
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟API调用
|
||||
const mockDevice: Device = {
|
||||
id: params.id as string,
|
||||
imei: "sd123123",
|
||||
name: "设备 1",
|
||||
status: "online",
|
||||
battery: 85,
|
||||
lastActive: "2024-02-09 15:30:45",
|
||||
historicalIds: ["vx412321", "vfbadasd"],
|
||||
wechatAccounts: [
|
||||
{
|
||||
id: "1",
|
||||
avatar: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-q2rVrFbfDdAbSnT3ZTNE7gfn3QCbvr.png",
|
||||
nickname: "老张",
|
||||
wechatId: "wxid_abc123",
|
||||
gender: "male",
|
||||
status: "normal",
|
||||
addFriendStatus: "enabled",
|
||||
friendCount: 523,
|
||||
lastActive: "2024-02-09 15:20:33",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
avatar: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-q2rVrFbfDdAbSnT3ZTNE7gfn3QCbvr.png",
|
||||
nickname: "老李",
|
||||
wechatId: "wxid_xyz789",
|
||||
gender: "male",
|
||||
status: "abnormal",
|
||||
addFriendStatus: "disabled",
|
||||
friendCount: 245,
|
||||
lastActive: "2024-02-09 14:15:22",
|
||||
},
|
||||
],
|
||||
features: {
|
||||
autoAddFriend: true,
|
||||
autoReply: true,
|
||||
contentSync: false,
|
||||
aiChat: true,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
time: "2024-02-09 15:30:45",
|
||||
action: "开启自动加好友",
|
||||
operator: "系统",
|
||||
},
|
||||
{
|
||||
time: "2024-02-09 14:20:33",
|
||||
action: "添加微信号",
|
||||
operator: "管理员",
|
||||
},
|
||||
],
|
||||
}
|
||||
setDevice(mockDevice)
|
||||
}, [params.id])
|
||||
|
||||
if (!device) {
|
||||
return <div>加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<div className="max-w-[390px] mx-auto bg-white">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">设备详情</h1>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<Smartphone className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium truncate">{device.name}</h2>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">IMEI: {device.imei}</div>
|
||||
<div className="text-sm text-gray-500">历史ID: {device.historicalIds.join(", ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
|
||||
<span className="text-sm">{device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wifi className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm">{device.status === "online" ? "已连接" : "未连接"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">最后活跃:{device.lastActive}</div>
|
||||
</Card>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="info">基本信息</TabsTrigger>
|
||||
<TabsTrigger value="accounts">关联账号</TabsTrigger>
|
||||
<TabsTrigger value="history">操作记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info">
|
||||
<Card className="p-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>自动加好友</Label>
|
||||
<div className="text-sm text-gray-500">自动通过好友验证</div>
|
||||
</div>
|
||||
<Switch checked={device.features.autoAddFriend} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>自动回复</Label>
|
||||
<div className="text-sm text-gray-500">自动回复好友消息</div>
|
||||
</div>
|
||||
<Switch checked={device.features.autoReply} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>朋友圈同步</Label>
|
||||
<div className="text-sm text-gray-500">自动同步朋友圈内容</div>
|
||||
</div>
|
||||
<Switch checked={device.features.contentSync} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>AI会话</Label>
|
||||
<div className="text-sm text-gray-500">启用AI智能对话</div>
|
||||
</div>
|
||||
<Switch checked={device.features.aiChat} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts">
|
||||
<Card className="p-4">
|
||||
<ScrollArea className="h-[calc(100vh-300px)]">
|
||||
<div className="space-y-4">
|
||||
{device.wechatAccounts.map((account) => (
|
||||
<div key={account.id} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
src={account.avatar || "/placeholder.svg"}
|
||||
alt={account.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium truncate">{account.nickname}</div>
|
||||
<Badge variant={account.status === "normal" ? "success" : "destructive"}>
|
||||
{account.status === "normal" ? "正常" : "异常"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">微信号: {account.wechatId}</div>
|
||||
<div className="text-sm text-gray-500">性别: {account.gender === "male" ? "男" : "女"}</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-sm text-gray-500">好友数: {account.friendCount}</span>
|
||||
<Badge variant={account.addFriendStatus === "enabled" ? "outline" : "secondary"}>
|
||||
{account.addFriendStatus === "enabled" ? "可加友" : "已停用"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<Card className="p-4">
|
||||
<ScrollArea className="h-[calc(100vh-300px)]">
|
||||
<div className="space-y-4">
|
||||
{device.history.map((record, index) => (
|
||||
<div key={index} className="flex items-start space-x-3">
|
||||
<div className="p-2 bg-blue-50 rounded-full">
|
||||
<History className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{record.action}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
操作人: {record.operator} · {record.time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center space-x-2 text-gray-500">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm">好友总数</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-2">
|
||||
{device?.wechatAccounts?.reduce((sum, account) => sum + account.friendCount, 0)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center space-x-2 text-gray-500">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span className="text-sm">消息数量</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-2">5,678</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
app/devices/loading.tsx
Normal file
3
app/devices/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
421
app/devices/page.tsx
Normal file
421
app/devices/page.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { PieChart, BarChart, LineChart } from "@/components/charts"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Activity, Filter, Plus, RefreshCw, Search, Smartphone, Laptop, Monitor, Wifi, WifiOff } from "lucide-react"
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [deviceType, setDeviceType] = useState("all")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">设备管理</h1>
|
||||
<p className="text-muted-foreground mt-1">管理和分析用户设备数据</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加设备
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-white shadow-sm">
|
||||
<CardContent className="p-4 flex items-center">
|
||||
<div className="bg-blue-100 p-2 rounded-full mr-3">
|
||||
<Smartphone className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600">总设备数</div>
|
||||
<div className="text-xl font-bold">156,789</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white shadow-sm">
|
||||
<CardContent className="p-4 flex items-center">
|
||||
<div className="bg-green-100 p-2 rounded-full mr-3">
|
||||
<Wifi className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600">在线设备</div>
|
||||
<div className="text-xl font-bold">132,456</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white shadow-sm">
|
||||
<CardContent className="p-4 flex items-center">
|
||||
<div className="bg-red-100 p-2 rounded-full mr-3">
|
||||
<WifiOff className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600">离线设备</div>
|
||||
<div className="text-xl font-bold">24,333</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white shadow-sm">
|
||||
<CardContent className="p-4 flex items-center">
|
||||
<div className="bg-purple-100 p-2 rounded-full mr-3">
|
||||
<Activity className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600">活跃率</div>
|
||||
<div className="text-xl font-bold">84.5%</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-none shadow-md overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>设备分析</CardTitle>
|
||||
<CardDescription>设备类型、活跃度和使用情况分析</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList className="grid grid-cols-3 w-full">
|
||||
<TabsTrigger value="overview">设备概览</TabsTrigger>
|
||||
<TabsTrigger value="activity">活跃分析</TabsTrigger>
|
||||
<TabsTrigger value="distribution">分布分析</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">设备类型分布</h3>
|
||||
<PieChart
|
||||
data={{
|
||||
labels: ["iOS设备", "Android设备", "Windows设备", "Mac设备", "其他设备"],
|
||||
datasets: [
|
||||
{
|
||||
data: [45, 40, 8, 5, 2],
|
||||
backgroundColor: [
|
||||
"rgb(59, 130, 246)",
|
||||
"rgb(16, 185, 129)",
|
||||
"rgb(249, 115, 22)",
|
||||
"rgb(139, 92, 246)",
|
||||
"rgb(156, 163, 175)",
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">设备型号分布 Top 10</h3>
|
||||
<BarChart
|
||||
data={{
|
||||
labels: [
|
||||
"iPhone 13",
|
||||
"iPhone 12",
|
||||
"Samsung S21",
|
||||
"iPhone 14",
|
||||
"Xiaomi 12",
|
||||
"Huawei P40",
|
||||
"OPPO Find X",
|
||||
"Vivo X60",
|
||||
"OnePlus 9",
|
||||
"iPhone SE",
|
||||
],
|
||||
datasets: [
|
||||
{
|
||||
label: "设备数量",
|
||||
data: [25000, 22000, 18000, 15000, 12000, 10000, 8000, 7000, 6000, 5000],
|
||||
backgroundColor: "rgba(59, 130, 246, 0.8)",
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity" className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">设备活跃度趋势</h3>
|
||||
<LineChart
|
||||
data={{
|
||||
labels: ["1月", "2月", "3月", "4月", "5月", "6月", "7月"],
|
||||
datasets: [
|
||||
{
|
||||
label: "iOS设备",
|
||||
data: [75, 78, 80, 82, 85, 87, 90],
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
{
|
||||
label: "Android设备",
|
||||
data: [70, 72, 75, 78, 80, 82, 85],
|
||||
borderColor: "rgb(16, 185, 129)",
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">设备使用时长分布</h3>
|
||||
<PieChart
|
||||
data={{
|
||||
labels: ["<30分钟/天", "30-60分钟/天", "1-2小时/天", "2-4小时/天", ">4小时/天"],
|
||||
datasets: [
|
||||
{
|
||||
data: [15, 25, 30, 20, 10],
|
||||
backgroundColor: [
|
||||
"rgb(156, 163, 175)",
|
||||
"rgb(249, 115, 22)",
|
||||
"rgb(16, 185, 129)",
|
||||
"rgb(59, 130, 246)",
|
||||
"rgb(139, 92, 246)",
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="distribution" className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">地区分布 Top 10</h3>
|
||||
<BarChart
|
||||
data={{
|
||||
labels: ["北京", "上海", "广州", "深圳", "杭州", "成都", "武汉", "南京", "重庆", "西安"],
|
||||
datasets: [
|
||||
{
|
||||
label: "设备数量",
|
||||
data: [18500, 17200, 15800, 14500, 12000, 10500, 9800, 8500, 7800, 7200],
|
||||
backgroundColor: "rgba(59, 130, 246, 0.8)",
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="text-lg font-medium mb-3">系统版本分布</h3>
|
||||
<PieChart
|
||||
data={{
|
||||
labels: ["iOS 16", "iOS 15", "iOS 14", "Android 13", "Android 12", "Android 11", "其他"],
|
||||
datasets: [
|
||||
{
|
||||
data: [30, 25, 10, 15, 10, 5, 5],
|
||||
backgroundColor: [
|
||||
"rgb(59, 130, 246)",
|
||||
"rgb(96, 165, 250)",
|
||||
"rgb(147, 197, 253)",
|
||||
"rgb(16, 185, 129)",
|
||||
"rgb(52, 211, 153)",
|
||||
"rgb(110, 231, 183)",
|
||||
"rgb(156, 163, 175)",
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-md overflow-hidden">
|
||||
<CardHeader className="bg-white border-b">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>设备列表</CardTitle>
|
||||
<CardDescription>查看和管理所有设备</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索设备..."
|
||||
className="pl-8 w-[200px] md:w-[300px]"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={deviceType} onValueChange={setDeviceType}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="设备类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="ios">iOS设备</SelectItem>
|
||||
<SelectItem value="android">Android设备</SelectItem>
|
||||
<SelectItem value="windows">Windows设备</SelectItem>
|
||||
<SelectItem value="mac">Mac设备</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">设备ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">设备名称</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">设备类型</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">系统版本</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">状态</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">最后活跃时间</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{[
|
||||
{
|
||||
id: "DEV-001",
|
||||
name: "iPhone 13 Pro",
|
||||
type: "iOS",
|
||||
version: "iOS 16.2",
|
||||
status: "在线",
|
||||
lastActive: "2023-07-15 14:30",
|
||||
},
|
||||
{
|
||||
id: "DEV-002",
|
||||
name: "Samsung Galaxy S21",
|
||||
type: "Android",
|
||||
version: "Android 13",
|
||||
status: "在线",
|
||||
lastActive: "2023-07-15 13:45",
|
||||
},
|
||||
{
|
||||
id: "DEV-003",
|
||||
name: "MacBook Pro",
|
||||
type: "Mac",
|
||||
version: "macOS 13.1",
|
||||
status: "在线",
|
||||
lastActive: "2023-07-15 12:20",
|
||||
},
|
||||
{
|
||||
id: "DEV-004",
|
||||
name: "Xiaomi 12",
|
||||
type: "Android",
|
||||
version: "Android 12",
|
||||
status: "离线",
|
||||
lastActive: "2023-07-14 18:10",
|
||||
},
|
||||
{
|
||||
id: "DEV-005",
|
||||
name: "iPad Pro",
|
||||
type: "iOS",
|
||||
version: "iOS 16.1",
|
||||
status: "在线",
|
||||
lastActive: "2023-07-15 10:05",
|
||||
},
|
||||
{
|
||||
id: "DEV-006",
|
||||
name: "Huawei P40",
|
||||
type: "Android",
|
||||
version: "Android 11",
|
||||
status: "离线",
|
||||
lastActive: "2023-07-13 09:30",
|
||||
},
|
||||
{
|
||||
id: "DEV-007",
|
||||
name: "Windows Laptop",
|
||||
type: "Windows",
|
||||
version: "Windows 11",
|
||||
status: "在线",
|
||||
lastActive: "2023-07-15 11:45",
|
||||
},
|
||||
{
|
||||
id: "DEV-008",
|
||||
name: "OPPO Find X5",
|
||||
type: "Android",
|
||||
version: "Android 13",
|
||||
status: "在线",
|
||||
lastActive: "2023-07-15 09:20",
|
||||
},
|
||||
{
|
||||
id: "DEV-009",
|
||||
name: "iPhone 12",
|
||||
type: "iOS",
|
||||
version: "iOS 16.2",
|
||||
status: "离线",
|
||||
lastActive: "2023-07-14 22:15",
|
||||
},
|
||||
{
|
||||
id: "DEV-010",
|
||||
name: "Vivo X80",
|
||||
type: "Android",
|
||||
version: "Android 12",
|
||||
status: "在线",
|
||||
lastActive: "2023-07-15 08:50",
|
||||
},
|
||||
].map((device) => (
|
||||
<tr key={device.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm">{device.id}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{device.name}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="flex items-center">
|
||||
{device.type === "iOS" && <Smartphone className="h-4 w-4 mr-1 text-blue-500" />}
|
||||
{device.type === "Android" && <Smartphone className="h-4 w-4 mr-1 text-green-500" />}
|
||||
{device.type === "Mac" && <Laptop className="h-4 w-4 mr-1 text-gray-500" />}
|
||||
{device.type === "Windows" && <Monitor className="h-4 w-4 mr-1 text-blue-400" />}
|
||||
{device.type}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{device.version}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${
|
||||
device.status === "在线"
|
||||
? "bg-green-50 text-green-600 border-green-200"
|
||||
: "bg-red-50 text-red-600 border-red-200"
|
||||
}`}
|
||||
>
|
||||
{device.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{device.lastActive}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<Button variant="ghost" size="sm">
|
||||
详情
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
app/documentation/layout.tsx
Normal file
9
app/documentation/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type React from "react"
|
||||
|
||||
export default function DocumentationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <div className="min-h-screen bg-gray-50">{children}</div>
|
||||
}
|
||||
1051
app/documentation/page.tsx
Normal file
1051
app/documentation/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
184
app/globals.css
Normal file
184
app/globals.css
Normal file
@@ -0,0 +1,184 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* 更新为毛玻璃风格的颜色变量 */
|
||||
--background: 240 10% 98%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 240 10% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 240 10% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 240 5.9% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 240 5.9% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 240 5.9% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 240 5.9% 98%;
|
||||
--primary: 240 5.9% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 240 5.9% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 240 5.9% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 240 5.9% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gradient-to-br from-blue-50 via-white to-purple-50 text-foreground min-h-screen;
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
background-attachment: scroll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* 毛玻璃效果基础类 */
|
||||
.glass {
|
||||
@apply backdrop-blur-md bg-white/10 border border-white/20;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
}
|
||||
|
||||
.glass-light {
|
||||
@apply backdrop-blur-sm bg-white/20 border border-white/30;
|
||||
box-shadow: 0 4px 16px 0 rgba(31, 38, 135, 0.2);
|
||||
}
|
||||
|
||||
.glass-heavy {
|
||||
@apply backdrop-blur-xl bg-white/30 border border-white/40;
|
||||
box-shadow: 0 16px 64px 0 rgba(31, 38, 135, 0.5);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply backdrop-blur-md bg-black/10 border border-white/10;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 移动端毛玻璃效果优化 */
|
||||
@media (max-width: 768px) {
|
||||
.glass {
|
||||
@apply backdrop-blur-sm bg-white/15;
|
||||
}
|
||||
|
||||
.glass-light {
|
||||
@apply backdrop-blur-sm bg-white/25;
|
||||
}
|
||||
|
||||
.glass-heavy {
|
||||
@apply backdrop-blur-md bg-white/35;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片毛玻璃效果 */
|
||||
.glass-card {
|
||||
@apply glass rounded-2xl p-6 transition-all duration-300 hover:bg-white/20 hover:shadow-glass-lg;
|
||||
}
|
||||
|
||||
/* 移动端卡片优化 */
|
||||
@media (max-width: 768px) {
|
||||
.glass-card {
|
||||
@apply p-4 rounded-xl;
|
||||
}
|
||||
}
|
||||
|
||||
/* 导航栏毛玻璃效果 */
|
||||
.glass-nav {
|
||||
@apply glass-light rounded-2xl transition-all duration-300;
|
||||
}
|
||||
|
||||
/* 按钮毛玻璃效果 */
|
||||
.glass-button {
|
||||
@apply glass-light rounded-xl px-4 py-2 transition-all duration-300 hover:bg-white/30 hover:scale-105;
|
||||
}
|
||||
|
||||
/* 移动端按钮优化 */
|
||||
@media (max-width: 768px) {
|
||||
.glass-button {
|
||||
@apply px-6 py-3 text-base;
|
||||
min-height: 44px; /* iOS推荐的最小触摸目标 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框毛玻璃效果 */
|
||||
.glass-input {
|
||||
@apply glass-light rounded-xl px-4 py-2 transition-all duration-300 focus:bg-white/30 focus:ring-2 focus:ring-white/50;
|
||||
}
|
||||
|
||||
/* 移动端输入框优化 */
|
||||
@media (max-width: 768px) {
|
||||
.glass-input {
|
||||
@apply px-4 py-3 text-base;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 安全区域适配 */
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-area-left {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-area-right {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端滚动优化 */
|
||||
@media (max-width: 768px) {
|
||||
.overflow-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
181
app/group-sync/page.tsx
Normal file
181
app/group-sync/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus, Trash2, RefreshCw, MoreVertical, Check } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { format } from "date-fns"
|
||||
import "regenerator-runtime/runtime"
|
||||
|
||||
interface GroupSync {
|
||||
id: string
|
||||
name: string
|
||||
targetGroup: string
|
||||
contentLib: string
|
||||
creator: string
|
||||
sentCount: number
|
||||
lastSyncTime: Date
|
||||
createTime: Date
|
||||
syncStatus: "pending" | "running" | "completed"
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const mockData: GroupSync[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "每日早安问候",
|
||||
targetGroup: "业务交流群1",
|
||||
contentLib: "早安问候语库",
|
||||
creator: "张三",
|
||||
sentCount: 156,
|
||||
lastSyncTime: new Date(),
|
||||
createTime: new Date(),
|
||||
syncStatus: "running",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "周末营销推广",
|
||||
targetGroup: "产品推广群",
|
||||
contentLib: "营销文案库",
|
||||
creator: "李四",
|
||||
sentCount: 89,
|
||||
lastSyncTime: new Date(),
|
||||
createTime: new Date(),
|
||||
syncStatus: "completed",
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
export default function GroupSyncPage() {
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.length === mockData.length) {
|
||||
setSelectedItems([])
|
||||
} else {
|
||||
setSelectedItems(mockData.map((item) => item.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectItem = (id: string) => {
|
||||
if (selectedItems.includes(id)) {
|
||||
setSelectedItems(selectedItems.filter((item) => item !== id))
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, id])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-16">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h1 className="text-xl font-semibold">社群同步</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" disabled={selectedItems.length === 0}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox checked={selectedItems.length === mockData.length} onCheckedChange={toggleSelectAll} />
|
||||
</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>社群同步名称</TableHead>
|
||||
<TableHead>推送社群</TableHead>
|
||||
<TableHead>内容库</TableHead>
|
||||
<TableHead>新建人</TableHead>
|
||||
<TableHead>已推送条数</TableHead>
|
||||
<TableHead>上次推送时间</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>循环推送</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead className="w-12">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockData.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedItems.includes(item.id)}
|
||||
onCheckedChange={() => toggleSelectItem(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.id}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.targetGroup}</TableCell>
|
||||
<TableCell>{item.contentLib}</TableCell>
|
||||
<TableCell>{item.creator}</TableCell>
|
||||
<TableCell>{item.sentCount}</TableCell>
|
||||
<TableCell>{format(item.lastSyncTime, "yyyy-MM-dd HH:mm:ss")}</TableCell>
|
||||
<TableCell>{format(item.createTime, "yyyy-MM-dd HH:mm:ss")}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
item.syncStatus === "running"
|
||||
? "bg-green-100 text-green-800"
|
||||
: item.syncStatus === "pending"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{item.syncStatus === "running" ? "进行中" : item.syncStatus === "pending" ? "待执行" : "已完成"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Check className={`h-4 w-4 ${item.enabled ? "text-green-500" : "text-gray-300"}`} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>编辑</DropdownMenuItem>
|
||||
<DropdownMenuItem>删除</DropdownMenuItem>
|
||||
<DropdownMenuItem>查看详情</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
app/hooks/useDeviceStatusPolling.ts
Normal file
46
app/hooks/useDeviceStatusPolling.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import type { Device } from "@/components/device-grid"
|
||||
|
||||
interface DeviceStatus {
|
||||
status: "online" | "offline"
|
||||
battery: number
|
||||
}
|
||||
|
||||
async function fetchDeviceStatuses(deviceIds: string[]): Promise<Record<string, DeviceStatus>> {
|
||||
// 模拟API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
return deviceIds.reduce(
|
||||
(acc, id) => {
|
||||
acc[id] = {
|
||||
status: Math.random() > 0.3 ? "online" : "offline",
|
||||
battery: Math.floor(Math.random() * 100),
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, DeviceStatus>,
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeviceStatusPolling(devices: Device[]) {
|
||||
const [statuses, setStatuses] = useState<Record<string, DeviceStatus>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const newStatuses = await fetchDeviceStatuses(devices.map((d) => d.id))
|
||||
setStatuses((prevStatuses) => ({ ...prevStatuses, ...newStatuses }))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch device statuses:", error)
|
||||
}
|
||||
}
|
||||
|
||||
pollStatus() // 立即执行一次
|
||||
const intervalId = setInterval(pollStatus, 30000) // 每30秒更新一次
|
||||
|
||||
return () => clearInterval(intervalId)
|
||||
}, [devices])
|
||||
|
||||
return statuses
|
||||
}
|
||||
23
app/layout.tsx
Normal file
23
app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import ClientLayout from "./ClientLayout"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
generator: 'v0.dev'
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <ClientLayout>{children}</ClientLayout>
|
||||
}
|
||||
|
||||
|
||||
import './globals.css'
|
||||
6
app/lib/utils.ts
Normal file
6
app/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
355
app/login/page.tsx
Normal file
355
app/login/page.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Eye, EyeOff, Phone } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { WeChatIcon } from "@/components/icons/wechat-icon"
|
||||
import { AppleIcon } from "@/components/icons/apple-icon"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
// 使用环境变量获取API域名
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"
|
||||
|
||||
// 定义登录响应类型
|
||||
interface LoginResponse {
|
||||
code: number
|
||||
message: string
|
||||
data?: {
|
||||
token: string
|
||||
}
|
||||
}
|
||||
|
||||
interface LoginForm {
|
||||
phone: string
|
||||
password: string
|
||||
verificationCode: string
|
||||
agreeToTerms: boolean
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<"password" | "verification">("password")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [form, setForm] = useState<LoginForm>({
|
||||
phone: "",
|
||||
password: "",
|
||||
verificationCode: "",
|
||||
agreeToTerms: false,
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target
|
||||
setForm((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
setForm((prev) => ({ ...prev, agreeToTerms: checked }))
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入手机号",
|
||||
description: "手机号不能为空",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!form.agreeToTerms) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请同意用户协议",
|
||||
description: "需要同意用户协议和隐私政策才能继续",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (activeTab === "password" && !form.password) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入密码",
|
||||
description: "密码不能为空",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (activeTab === "verification" && !form.verificationCode) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入验证码",
|
||||
description: "验证码不能为空",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// 创建FormData对象
|
||||
const formData = new FormData()
|
||||
formData.append("phone", form.phone)
|
||||
|
||||
if (activeTab === "password") {
|
||||
formData.append("password", form.password)
|
||||
} else {
|
||||
formData.append("verificationCode", form.verificationCode)
|
||||
}
|
||||
|
||||
// 发送登录请求
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
// 不需要设置Content-Type,浏览器会自动设置为multipart/form-data并添加boundary
|
||||
})
|
||||
|
||||
const result: LoginResponse = await response.json()
|
||||
|
||||
if (result.code === 10000 && result.data?.token) {
|
||||
// 保存token到localStorage
|
||||
localStorage.setItem("token", result.data.token)
|
||||
|
||||
// 成功后跳转
|
||||
router.push("/profile")
|
||||
|
||||
toast({
|
||||
title: "登录成功",
|
||||
description: "欢迎回来!",
|
||||
})
|
||||
} else {
|
||||
throw new Error(result.message || "登录失败")
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "登录失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendVerificationCode = async () => {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入手机号",
|
||||
description: "发送验证码需要手机号",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// 创建FormData对象
|
||||
const formData = new FormData()
|
||||
formData.append("phone", form.phone)
|
||||
|
||||
// 发送验证码请求
|
||||
const response = await fetch(`${API_BASE_URL}/auth/send-code`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 10000) {
|
||||
toast({
|
||||
title: "验证码已发送",
|
||||
description: "请查看手机短信",
|
||||
})
|
||||
} else {
|
||||
throw new Error(result.message || "发送失败")
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "发送失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 检查是否已登录
|
||||
const token = localStorage.getItem("token")
|
||||
if (token) {
|
||||
router.push("/profile")
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-gray-900 flex flex-col px-4 py-8">
|
||||
<div className="max-w-md w-full mx-auto space-y-8">
|
||||
<Tabs
|
||||
defaultValue="password"
|
||||
className="w-full"
|
||||
onValueChange={(v) => setActiveTab(v as "password" | "verification")}
|
||||
>
|
||||
<TabsList className="w-full bg-transparent border-b border-gray-200">
|
||||
<TabsTrigger
|
||||
value="verification"
|
||||
className="flex-1 data-[state=active]:border-blue-500 data-[state=active]:text-blue-500 border-b-2 border-transparent"
|
||||
>
|
||||
验证码登录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="password"
|
||||
className="flex-1 data-[state=active]:border-blue-500 data-[state=active]:text-blue-500 border-b-2 border-transparent"
|
||||
>
|
||||
密码登录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-gray-600 mb-6">你所在地区仅支持 手机号 / 微信 / Apple 登录</p>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="手机号"
|
||||
className="pl-16 border-gray-300 text-gray-900 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1">
|
||||
<Phone className="h-4 w-4" />
|
||||
+86
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TabsContent value="password" className="m-0">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="密码"
|
||||
className="pr-12 border-gray-300 text-gray-900 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="verification" className="m-0">
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
name="verificationCode"
|
||||
value={form.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder="验证码"
|
||||
className="border-gray-300 text-gray-900 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-32 h-12 border-gray-300 text-gray-600 hover:text-gray-900"
|
||||
onClick={handleSendVerificationCode}
|
||||
disabled={isLoading}
|
||||
>
|
||||
发送验证码
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={form.agreeToTerms}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
className="border-gray-300 data-[state=checked]:bg-blue-500"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label htmlFor="terms" className="text-sm text-gray-600">
|
||||
已阅读并同意
|
||||
<a href="#" className="text-blue-500 mx-1">
|
||||
用户协议
|
||||
</a>
|
||||
与
|
||||
<a href="#" className="text-blue-500 ml-1">
|
||||
隐私政策
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-blue-500 hover:bg-blue-600 text-white"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-gray-300"></span>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-gray-500">或</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<WeChatIcon className="w-6 h-6 mr-2 text-[#07C160]" />
|
||||
使用微信登录
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-12 border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<AppleIcon className="w-6 h-6 mr-2" />
|
||||
使用 Apple 登录
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<a href="#" className="text-sm text-gray-500">
|
||||
联系我们
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
app/orders/submit/[planId]/page.tsx
Normal file
151
app/orders/submit/[planId]/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { OrderFormData } from "@/types/acquisition"
|
||||
|
||||
export default function OrderSubmitPage({ params }: { params: { planId: string } }) {
|
||||
const [formData, setFormData] = useState<OrderFormData>({
|
||||
customerName: "",
|
||||
phone: "",
|
||||
wechatId: "",
|
||||
source: "",
|
||||
amount: undefined,
|
||||
orderDate: new Date().toISOString().split("T")[0],
|
||||
remark: "",
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/acquisition/${params.planId}/orders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: "提交成功",
|
||||
description: "订单信息已成功提交",
|
||||
})
|
||||
// 重置表单
|
||||
setFormData({
|
||||
customerName: "",
|
||||
phone: "",
|
||||
wechatId: "",
|
||||
source: "",
|
||||
amount: undefined,
|
||||
orderDate: new Date().toISOString().split("T")[0],
|
||||
remark: "",
|
||||
})
|
||||
} else {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "提交失败",
|
||||
description: "订单提交失败,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card className="p-6">
|
||||
<h1 className="text-2xl font-semibold mb-6">订单信息录入</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="customerName">客户姓名</Label>
|
||||
<Input
|
||||
id="customerName"
|
||||
value={formData.customerName}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, customerName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">手机号码</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, phone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="wechatId">微信号</Label>
|
||||
<Input
|
||||
id="wechatId"
|
||||
value={formData.wechatId}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, wechatId: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="source">来源</Label>
|
||||
<Input
|
||||
id="source"
|
||||
value={formData.source}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, source: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="amount">订单金额</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.amount || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, amount: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="orderDate">下单日期</Label>
|
||||
<Input
|
||||
id="orderDate"
|
||||
type="date"
|
||||
value={formData.orderDate}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, orderDate: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="remark">备注</Label>
|
||||
<Textarea
|
||||
id="remark"
|
||||
value={formData.remark}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, remark: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
提交订单
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
308
app/page.tsx
Normal file
308
app/page.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
"use client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Database, Users, Target, TrendingUp, Activity, CheckCircle, ArrowRight, BarChart3, Zap } from "lucide-react"
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
|
||||
// 真实的数据概览
|
||||
const systemOverview = {
|
||||
totalUsers: 156234,
|
||||
activeUsers: 89167,
|
||||
dataIntegrations: 12,
|
||||
avgUserValue: 3248,
|
||||
dataQuality: 94.6,
|
||||
syncStatus: "正常",
|
||||
}
|
||||
|
||||
// 数据集成状态
|
||||
const dataIntegrations = [
|
||||
{ name: "用户行为数据", status: "connected", lastSync: "2分钟前", records: 245673 },
|
||||
{ name: "CRM系统", status: "connected", lastSync: "5分钟前", records: 156234 },
|
||||
{ name: "内容互动数据", status: "connected", lastSync: "1分钟前", records: 892456 },
|
||||
{ name: "营销活动数据", status: "syncing", lastSync: "正在同步", records: 0 },
|
||||
]
|
||||
|
||||
// 用户画像概览
|
||||
const userPortraitStats = {
|
||||
totalTags: 342,
|
||||
activeTags: 287,
|
||||
userCoverage: 95.8,
|
||||
segmentCount: 24,
|
||||
}
|
||||
|
||||
// 用户估值概览
|
||||
const userValueStats = {
|
||||
highValueUsers: 18234,
|
||||
mediumValueUsers: 47891,
|
||||
lowValueUsers: 32156,
|
||||
upgradeRate: 12.3,
|
||||
}
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
title: "数据集成",
|
||||
description: "管理数据源和同步",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
href: "/data-integration",
|
||||
color: "bg-blue-50 hover:bg-blue-100 border-blue-200",
|
||||
},
|
||||
{
|
||||
title: "用户画像",
|
||||
description: "查看用户标签和分群",
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
href: "/user-portrait",
|
||||
color: "bg-purple-50 hover:bg-purple-100 border-purple-200",
|
||||
},
|
||||
{
|
||||
title: "用户估值",
|
||||
description: "分析用户价值和升级路径",
|
||||
icon: <Target className="h-5 w-5" />,
|
||||
href: "/user-value",
|
||||
color: "bg-green-50 hover:bg-green-100 border-green-200",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
用户数字资产中台
|
||||
</h1>
|
||||
<p className="text-muted-foreground">统一的用户数据管理和分析平台</p>
|
||||
</div>
|
||||
|
||||
{/* 系统概览 */}
|
||||
<Card className="border-none shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
系统概览
|
||||
</CardTitle>
|
||||
<CardDescription>平台整体运行状况</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">总用户数</p>
|
||||
<p className="text-2xl font-bold text-blue-900">{systemOverview.totalUsers.toLocaleString()}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-blue-600">
|
||||
活跃用户: {systemOverview.activeUsers.toLocaleString()} (
|
||||
{((systemOverview.activeUsers / systemOverview.totalUsers) * 100).toFixed(1)}%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-purple-50 to-purple-100 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-purple-600">平均用户价值</p>
|
||||
<p className="text-2xl font-bold text-purple-900">¥{systemOverview.avgUserValue.toLocaleString()}</p>
|
||||
</div>
|
||||
<Target className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-purple-600">较上月提升 8.2%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-green-50 to-green-100 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600">数据质量</p>
|
||||
<p className="text-2xl font-bold text-green-900">{systemOverview.dataQuality}%</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Progress value={systemOverview.dataQuality} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 快速操作 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{quickActions.map((action, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
"border-2 cursor-pointer transition-all duration-300 hover:shadow-lg hover:scale-105",
|
||||
action.color,
|
||||
)}
|
||||
onClick={() => router.push(action.href)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-2 rounded-lg bg-white/50">{action.icon}</div>
|
||||
<ArrowRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">{action.title}</h3>
|
||||
<p className="text-sm text-gray-600">{action.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 详细状态 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 数据集成状态 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
数据集成状态
|
||||
</CardTitle>
|
||||
<CardDescription>当前数据源连接状态</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{dataIntegrations.map((integration, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"w-3 h-3 rounded-full",
|
||||
integration.status === "connected"
|
||||
? "bg-green-500"
|
||||
: integration.status === "syncing"
|
||||
? "bg-yellow-500"
|
||||
: "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{integration.name}</p>
|
||||
<p className="text-xs text-gray-500">{integration.lastSync}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{integration.records.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">记录</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4 bg-transparent"
|
||||
onClick={() => router.push("/data-integration")}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 用户画像概览 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
用户画像概览
|
||||
</CardTitle>
|
||||
<CardDescription>用户标签和分群统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-purple-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-purple-600">{userPortraitStats.totalTags}</p>
|
||||
<p className="text-sm text-gray-600">总标签数</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-blue-600">{userPortraitStats.segmentCount}</p>
|
||||
<p className="text-sm text-gray-600">用户分群</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">标签覆盖率</span>
|
||||
<span className="text-sm font-medium">{userPortraitStats.userCoverage}%</span>
|
||||
</div>
|
||||
<Progress value={userPortraitStats.userCoverage} className="h-2" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">活跃标签</span>
|
||||
<span className="text-sm font-medium">{userPortraitStats.activeTags}</span>
|
||||
</div>
|
||||
<Progress value={(userPortraitStats.activeTags / userPortraitStats.totalTags) * 100} className="h-2" />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4 bg-transparent"
|
||||
onClick={() => router.push("/user-portrait")}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 用户估值概览 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
用户价值分布
|
||||
</CardTitle>
|
||||
<CardDescription>用户价值等级和升级情况</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-green-600">高价值用户</p>
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-green-900">{userValueStats.highValueUsers.toLocaleString()}</p>
|
||||
<p className="text-xs text-green-600">
|
||||
占比 {((userValueStats.highValueUsers / systemOverview.totalUsers) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-blue-600">中价值用户</p>
|
||||
<Activity className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-blue-900">{userValueStats.mediumValueUsers.toLocaleString()}</p>
|
||||
<p className="text-xs text-blue-600">
|
||||
占比 {((userValueStats.mediumValueUsers / systemOverview.totalUsers) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-600">低价值用户</p>
|
||||
<Users className="h-4 w-4 text-gray-600" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900">{userValueStats.lowValueUsers.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
占比 {((userValueStats.lowValueUsers / systemOverview.totalUsers) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-orange-600">升级率</p>
|
||||
<Zap className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-orange-900">{userValueStats.upgradeRate}%</p>
|
||||
<p className="text-xs text-orange-600">较上月提升 2.1%</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full mt-4 bg-transparent" onClick={() => router.push("/user-value")}>
|
||||
查看详情
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
app/plans/new/loading.tsx
Normal file
3
app/plans/new/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
265
app/plans/new/page.tsx
Normal file
265
app/plans/new/page.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BasicSettings } from "./steps/BasicSettings"
|
||||
import { FriendRequestSettings } from "./steps/FriendRequestSettings"
|
||||
import { MessageSettings } from "./steps/MessageSettings"
|
||||
import { TagSettings } from "./steps/TagSettings"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
{ id: 4, title: "步骤四", subtitle: "流量标签设置" },
|
||||
]
|
||||
|
||||
// 场景分类规则
|
||||
const scenarioRules = {
|
||||
LIVE: ["直播", "直播间", "主播", "抖音"],
|
||||
COMMENT: ["评论", "互动", "回复", "小红书"],
|
||||
GROUP: ["群", "社群", "群聊", "微信群"],
|
||||
ARTICLE: ["文章", "笔记", "内容", "公众号"],
|
||||
}
|
||||
|
||||
// 根据计划名称和标签自动判断场景
|
||||
const determineScenario = (planName: string, tags: any[]) => {
|
||||
// 优先使用标签进行分类
|
||||
if (tags && tags.length > 0) {
|
||||
const firstTag = tags[0]
|
||||
if (firstTag.name?.includes("直播") || firstTag.name?.includes("抖音")) return "douyin"
|
||||
if (firstTag.name?.includes("评论") || firstTag.name?.includes("小红书")) return "xiaohongshu"
|
||||
if (firstTag.name?.includes("群") || firstTag.name?.includes("微信")) return "weixinqun"
|
||||
if (firstTag.name?.includes("文章") || firstTag.name?.includes("公众号")) return "gongzhonghao"
|
||||
}
|
||||
|
||||
// 如果没有标签,使用计划名称进行分类
|
||||
const planNameLower = planName.toLowerCase()
|
||||
if (planNameLower.includes("直播") || planNameLower.includes("抖音")) return "douyin"
|
||||
if (planNameLower.includes("评论") || planNameLower.includes("小红书")) return "xiaohongshu"
|
||||
if (planNameLower.includes("群") || planNameLower.includes("微信")) return "weixinqun"
|
||||
if (planNameLower.includes("文章") || planNameLower.includes("公众号")) return "gongzhonghao"
|
||||
return "other"
|
||||
}
|
||||
|
||||
export default function NewAcquisitionPlan() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const type = searchParams.get("type")
|
||||
const source = searchParams.get("source")
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [formData, setFormData] = useState({
|
||||
planName: "",
|
||||
scenario: type === "order" ? "order" : "",
|
||||
accounts: [],
|
||||
materials: [],
|
||||
enabled: true,
|
||||
remarkType: "phone",
|
||||
remarkKeyword: "",
|
||||
greeting: "",
|
||||
addFriendTimeStart: "09:00",
|
||||
addFriendTimeEnd: "18:00",
|
||||
addFriendInterval: 1,
|
||||
maxDailyFriends: 20,
|
||||
messageInterval: 1,
|
||||
messageContent: "",
|
||||
tags: [],
|
||||
selectedDevices: [],
|
||||
messagePlans: [],
|
||||
importedTags: [],
|
||||
sourceWechatId: source || "",
|
||||
})
|
||||
|
||||
// 如果是从微信号好友转移过来,自动设置计划名称
|
||||
useEffect(() => {
|
||||
if (type === "order" && source) {
|
||||
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
planName: `${source}好友转移${today}`,
|
||||
scenario: "order",
|
||||
}))
|
||||
|
||||
// 模拟加载好友数据
|
||||
setTimeout(() => {
|
||||
toast({
|
||||
title: "好友数据加载成功",
|
||||
description: `已从微信号 ${source} 导入好友数据`,
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}, [type, source])
|
||||
|
||||
const handleSave = () => {
|
||||
// 根据标签和计划名称自动判断场景
|
||||
const scenario = formData.scenario || determineScenario(formData.planName, formData.tags)
|
||||
|
||||
console.log("计划已创建:", { ...formData, scenario })
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: "获客计划已创建完成",
|
||||
})
|
||||
|
||||
// 跳转到首页
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prevStep) => Math.max(prevStep - 1, 1))
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (isStepValid()) {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave()
|
||||
} else {
|
||||
setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isStepValid = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
if (!formData.planName.trim()) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写计划名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 2:
|
||||
// 如果是订单导入场景,跳过好友申请设置验证
|
||||
if (formData.scenario === "order") {
|
||||
return true
|
||||
}
|
||||
if (!formData.greeting?.trim() || formData.selectedDevices?.length === 0) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写好友申请信息并选择设备",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 3:
|
||||
// 如果是订单导入场景,跳过消息设置验证
|
||||
if (formData.scenario === "order") {
|
||||
return true
|
||||
}
|
||||
if (formData.messagePlans?.length === 0) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请设置至少一条消息",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 4:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} />
|
||||
case 2:
|
||||
return (
|
||||
<FriendRequestSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
|
||||
)
|
||||
case 3:
|
||||
return <MessageSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
|
||||
case 4:
|
||||
return <TagSettings formData={formData} onComplete={handleSave} onPrev={handlePrev} onChange={setFormData} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是订单导入场景,直接跳到标签设置步骤
|
||||
useEffect(() => {
|
||||
// 只有在订单场景下,才自动开启步骤1,而不是直接跳到步骤4
|
||||
if (formData.scenario === "order" && currentStep === 1 && formData.planName) {
|
||||
// 保持在步骤1,不再自动跳转到步骤4
|
||||
// 之前的逻辑是直接跳到步骤4:setCurrentStep(4)
|
||||
}
|
||||
}, [formData.scenario, currentStep, formData.planName])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/")}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">{formData.sourceWechatId ? "好友转移" : "新建获客计划"}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="px-4 py-6">
|
||||
<div className="relative flex justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
"flex flex-col items-center relative z-10",
|
||||
currentStep >= step.id ? "text-blue-600" : "text-gray-400",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2",
|
||||
currentStep >= step.id
|
||||
? "border-blue-600 bg-blue-600 text-white"
|
||||
: "border-gray-300 bg-white text-gray-400",
|
||||
)}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
<div className="text-xs mt-1">{step.title}</div>
|
||||
<div className="text-xs mt-0.5 font-medium">{step.subtitle}</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-4 left-[32px] w-[calc(100%-32px)] h-[2px]",
|
||||
currentStep > step.id ? "bg-blue-600" : "bg-gray-200",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
|
||||
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-white border-t p-4">
|
||||
<div className="flex justify-between max-w-[390px] mx-auto">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
<Button className={cn("min-w-[120px]", currentStep === 1 ? "w-full" : "ml-auto")} onClick={handleNext}>
|
||||
{currentStep === steps.length ? "完成" : "下一步"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
548
app/plans/new/steps/BasicSettings.tsx
Normal file
548
app/plans/new/steps/BasicSettings.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { QrCode, X, ChevronDown, Plus, Maximize2, Upload, Download } from "lucide-react"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
|
||||
const scenarios = [
|
||||
{ id: "haibao", name: "海报", type: "material" },
|
||||
{ id: "order", name: "订单", type: "api" },
|
||||
{ id: "douyin", name: "抖音", type: "social" },
|
||||
{ id: "xiaohongshu", name: "小红书", type: "social" },
|
||||
{ id: "gongzhonghao", name: "公众号", type: "social" },
|
||||
{ id: "payment", name: "支付码", type: "material" },
|
||||
{ id: "weixinqun", name: "微信群", type: "social" },
|
||||
{ id: "api", name: "API", type: "api" },
|
||||
]
|
||||
|
||||
interface Account {
|
||||
id: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
interface Material {
|
||||
id: string
|
||||
name: string
|
||||
type: "poster" | "payment"
|
||||
preview: string
|
||||
}
|
||||
|
||||
interface BasicSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onNext?: () => void
|
||||
}
|
||||
|
||||
const posterTemplates = [
|
||||
{
|
||||
id: "poster-1",
|
||||
name: "点击领取",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E9%A2%86%E5%8F%961-tipd1HI7da6qooY5NkhxQnXBnT5LGU.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-2",
|
||||
name: "点击合作",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%90%88%E4%BD%9C-LPlMdgxtvhqCSr4IM1bZFEFDBF3ztI.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-3",
|
||||
name: "点击咨询",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-4",
|
||||
name: "点击签到",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E7%AD%BE%E5%88%B0-94TZIkjLldb4P2jTVlI6MkSDg0NbXi.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-5",
|
||||
name: "点击了解",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E4%BA%86%E8%A7%A3-6GCl7mQVdO4WIiykJyweSubLsTwj71.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-6",
|
||||
name: "点击报名",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E6%8A%A5%E5%90%8D-Mj0nnva0BiASeDAIhNNaRRAbjPgjEj.gif",
|
||||
},
|
||||
]
|
||||
|
||||
const generateRandomAccounts = (count: number): Account[] => {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `account-${index + 1}`,
|
||||
nickname: `账号-${Math.random().toString(36).substring(2, 7)}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${index + 1}`,
|
||||
}))
|
||||
}
|
||||
|
||||
const generatePosterMaterials = (): Material[] => {
|
||||
return posterTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
type: "poster",
|
||||
preview: template.preview,
|
||||
}))
|
||||
}
|
||||
|
||||
export function BasicSettings({ formData, onChange, onNext }: BasicSettingsProps) {
|
||||
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false)
|
||||
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false)
|
||||
const [isQRCodeOpen, setIsQRCodeOpen] = useState(false)
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
||||
const [previewImage, setPreviewImage] = useState("")
|
||||
const [accounts] = useState<Account[]>(generateRandomAccounts(50))
|
||||
const [materials] = useState<Material[]>(generatePosterMaterials())
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<Account[]>(
|
||||
formData.accounts?.length > 0 ? formData.accounts : [],
|
||||
)
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
|
||||
formData.materials?.length > 0 ? formData.materials : [],
|
||||
)
|
||||
const [showAllScenarios, setShowAllScenarios] = useState(false)
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
|
||||
const [importedTags, setImportedTags] = useState<
|
||||
Array<{
|
||||
phone: string
|
||||
wechat: string
|
||||
source?: string
|
||||
orderAmount?: number
|
||||
orderDate?: string
|
||||
}>
|
||||
>(formData.importedTags || [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData.planName) {
|
||||
if (formData.materials?.length > 0) {
|
||||
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
|
||||
onChange({ ...formData, planName: `海报${today}` })
|
||||
} else {
|
||||
onChange({ ...formData, planName: "场景" })
|
||||
}
|
||||
}
|
||||
}, [formData, onChange])
|
||||
|
||||
const handleScenarioSelect = (scenarioId: string) => {
|
||||
onChange({ ...formData, scenario: scenarioId })
|
||||
}
|
||||
|
||||
const handleAccountSelect = (account: Account) => {
|
||||
const updatedAccounts = [...selectedAccounts, account]
|
||||
setSelectedAccounts(updatedAccounts)
|
||||
onChange({ ...formData, accounts: updatedAccounts })
|
||||
}
|
||||
|
||||
const handleMaterialSelect = (material: Material) => {
|
||||
const updatedMaterials = [material]
|
||||
setSelectedMaterials(updatedMaterials)
|
||||
onChange({ ...formData, materials: updatedMaterials })
|
||||
setIsMaterialDialogOpen(false)
|
||||
|
||||
// 更新计划名称
|
||||
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "")
|
||||
onChange({ ...formData, planName: `海报${today}`, materials: updatedMaterials })
|
||||
}
|
||||
|
||||
const handleRemoveAccount = (accountId: string) => {
|
||||
const updatedAccounts = selectedAccounts.filter((a) => a.id !== accountId)
|
||||
setSelectedAccounts(updatedAccounts)
|
||||
onChange({ ...formData, accounts: updatedAccounts })
|
||||
}
|
||||
|
||||
const handleRemoveMaterial = (materialId: string) => {
|
||||
const updatedMaterials = selectedMaterials.filter((m) => m.id !== materialId)
|
||||
setSelectedMaterials(updatedMaterials)
|
||||
onChange({ ...formData, materials: updatedMaterials })
|
||||
}
|
||||
|
||||
const handlePreviewImage = (imageUrl: string) => {
|
||||
setPreviewImage(imageUrl)
|
||||
setIsPreviewOpen(true)
|
||||
}
|
||||
|
||||
const displayedScenarios = showAllScenarios ? scenarios : scenarios.slice(0, 3)
|
||||
|
||||
const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string
|
||||
const rows = content.split("\n").filter((row) => row.trim())
|
||||
const tags = rows.slice(1).map((row) => {
|
||||
const [phone, wechat, source, orderAmount, orderDate] = row.split(",")
|
||||
return {
|
||||
phone: phone.trim(),
|
||||
wechat: wechat.trim(),
|
||||
source: source?.trim(),
|
||||
orderAmount: orderAmount ? Number(orderAmount) : undefined,
|
||||
orderDate: orderDate?.trim(),
|
||||
}
|
||||
})
|
||||
setImportedTags(tags)
|
||||
onChange({ ...formData, importedTags: tags })
|
||||
} catch (error) {
|
||||
console.error("导入失败:", error)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const template = "电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03"
|
||||
const blob = new Blob([template], { type: "text/csv" })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "订单导入模板.csv"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-base mb-4 block">获客场景</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{displayedScenarios.map((scenario) => (
|
||||
<button
|
||||
key={scenario.id}
|
||||
className={`p-2 rounded-lg text-center transition-all ${
|
||||
formData.scenario === scenario.id
|
||||
? "bg-blue-100 text-blue-600 font-medium"
|
||||
: "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => handleScenarioSelect(scenario.id)}
|
||||
>
|
||||
{scenario.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!showAllScenarios && (
|
||||
<Button variant="ghost" className="mt-2 w-full text-blue-600" onClick={() => setShowAllScenarios(true)}>
|
||||
展开更多选项 <ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="planName">计划名称</Label>
|
||||
<Input
|
||||
id="planName"
|
||||
value={formData.planName}
|
||||
onChange={(e) => onChange({ ...formData, planName: e.target.value })}
|
||||
placeholder="请输入计划名称"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.scenario && (
|
||||
<>
|
||||
{scenarios.find((s) => s.id === formData.scenario)?.type === "social" && (
|
||||
<div>
|
||||
<Label>绑定账号</Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 justify-start"
|
||||
onClick={() => setIsAccountDialogOpen(true)}
|
||||
>
|
||||
{selectedAccounts.length > 0 ? `已选择 ${selectedAccounts.length} 个账号` : "选择账号"}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => setIsQRCodeOpen(true)}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{selectedAccounts.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedAccounts.map((account) => (
|
||||
<div key={account.id} className="flex items-center bg-gray-100 rounded-full px-3 py-1">
|
||||
<img
|
||||
src={account.avatar || "/placeholder.svg"}
|
||||
alt={account.nickname}
|
||||
className="w-4 h-4 rounded-full mr-2"
|
||||
/>
|
||||
<span className="text-sm">{account.nickname}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 p-0"
|
||||
onClick={() => handleRemoveAccount(account.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scenarios.find((s) => s.id === formData.scenario)?.type === "material" && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Label>选择海报</Label>
|
||||
<Button variant="outline" onClick={() => setIsMaterialDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 海报展示区域 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{materials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden group ${
|
||||
selectedMaterials.find((m) => m.id === material.id)
|
||||
? "ring-2 ring-blue-600"
|
||||
: "hover:ring-2 hover:ring-blue-600"
|
||||
}`}
|
||||
onClick={() => handleMaterialSelect(material)}
|
||||
>
|
||||
<img
|
||||
src={material.preview || "/placeholder.svg"}
|
||||
alt={material.name}
|
||||
className="w-full aspect-[9/16] object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreviewImage(material.preview)
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black/50 text-white">
|
||||
<div className="text-sm truncate">{material.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedMaterials.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<Label>已选择的海报</Label>
|
||||
<div className="mt-2">
|
||||
<div className="relative w-full max-w-[200px]">
|
||||
<img
|
||||
src={selectedMaterials[0].preview || "/placeholder.svg"}
|
||||
alt={selectedMaterials[0].name}
|
||||
className="w-full aspect-[9/16] object-cover rounded-lg cursor-pointer"
|
||||
onClick={() => handlePreviewImage(selectedMaterials[0].preview)}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => handleRemoveMaterial(selectedMaterials[0].id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scenarios.find((s) => s.id === formData.scenario)?.id === "order" && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Label>订单导入</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDownloadTemplate}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载模板
|
||||
</Button>
|
||||
<Button onClick={() => setIsImportDialogOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
导入订单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importedTags.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium mb-2">已导入 {importedTags.length} 条数据</h4>
|
||||
<div className="max-h-[300px] overflow-auto border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>电话号码</TableHead>
|
||||
<TableHead>微信号</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>订单金额</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{importedTags.slice(0, 5).map((tag, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{tag.phone}</TableCell>
|
||||
<TableCell>{tag.wechat}</TableCell>
|
||||
<TableCell>{tag.source}</TableCell>
|
||||
<TableCell>{tag.orderAmount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{importedTags.length > 5 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-gray-500">
|
||||
还有 {importedTags.length - 5} 条数据未显示
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enabled">是否启用</Label>
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => onChange({ ...formData, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full h-12 text-base" onClick={onNext}>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 账号选择对话框 */}
|
||||
<Dialog open={isAccountDialogOpen} onOpenChange={setIsAccountDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择账号</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 max-h-[400px] overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center space-x-3 p-3 hover:bg-gray-100 rounded-lg cursor-pointer"
|
||||
onClick={() => handleAccountSelect(account)}
|
||||
>
|
||||
<img src={account.avatar || "/placeholder.svg"} alt="" className="w-10 h-10 rounded-full" />
|
||||
<span className="flex-1">{account.nickname}</span>
|
||||
{selectedAccounts.find((a) => a.id === account.id) && (
|
||||
<div className="w-4 h-4 rounded-full bg-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 二维码对话框 */}
|
||||
<Dialog open={isQRCodeOpen} onOpenChange={setIsQRCodeOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>绑定账号</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center p-6">
|
||||
<div className="w-64 h-64 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<img src="/placeholder.svg?height=256&width=256" alt="二维码" className="w-full h-full" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-600">请用相应的APP扫码</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 图片预览对话框 */}
|
||||
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>海报预览</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center items-center p-4">
|
||||
<img src={previewImage || "/placeholder.svg"} alt="预览" className="max-h-[80vh] object-contain" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 订单导入对话框 */}
|
||||
<Dialog open={isImportDialogOpen} onOpenChange={setIsImportDialogOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入订单标签</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Input type="file" accept=".csv" onChange={handleFileImport} className="flex-1" />
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>电话号码</TableHead>
|
||||
<TableHead>微信号</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>订单金额</TableHead>
|
||||
<TableHead>下单日期</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{importedTags.map((tag, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{tag.phone}</TableCell>
|
||||
<TableCell>{tag.wechat}</TableCell>
|
||||
<TableCell>{tag.source}</TableCell>
|
||||
<TableCell>{tag.orderAmount}</TableCell>
|
||||
<TableCell>{tag.orderDate}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsImportDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange({ ...formData, importedTags })
|
||||
setIsImportDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
227
app/plans/new/steps/FriendRequestSettings.tsx
Normal file
227
app/plans/new/steps/FriendRequestSettings.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
"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 { DeviceSelectionDialog } from "@/app/components/device-selection-dialog"
|
||||
import { HelpCircle, ChevronDown, MessageSquare } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
interface FriendRequestSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
}
|
||||
|
||||
// 招呼语模板
|
||||
const greetingTemplates = [
|
||||
"你好,请通过",
|
||||
"你好,了解XX,请通过",
|
||||
"你好,我是XX产品的客服请通过",
|
||||
"你好,感谢关注我们的产品",
|
||||
"你好,很高兴为您服务",
|
||||
]
|
||||
|
||||
// 备注类型选项
|
||||
const remarkTypes = [
|
||||
{ value: "phone", label: "手机号" },
|
||||
{ value: "nickname", label: "昵称" },
|
||||
{ value: "source", label: "来源" },
|
||||
]
|
||||
|
||||
export function FriendRequestSettings({ formData, onChange, onNext, onPrev }: FriendRequestSettingsProps) {
|
||||
const [isDeviceDialogOpen, setIsDeviceDialogOpen] = useState(false)
|
||||
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
|
||||
|
||||
// 使用useEffect设置默认值
|
||||
useEffect(() => {
|
||||
if (!formData.greeting) {
|
||||
onChange({
|
||||
...formData,
|
||||
greeting: "你好,请通过",
|
||||
remarkType: "phone", // 默认选择手机号
|
||||
maxDailyFriends: 20,
|
||||
addFriendInterval: 1,
|
||||
})
|
||||
}
|
||||
}, [formData, formData.greeting, onChange])
|
||||
|
||||
const handleDeviceSelect = (deviceIds: string[]) => {
|
||||
onChange({
|
||||
...formData,
|
||||
selectedDevices: deviceIds,
|
||||
})
|
||||
}
|
||||
|
||||
const handleTemplateSelect = (template: string) => {
|
||||
onChange({ ...formData, greeting: template })
|
||||
setIsTemplateDialogOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-base">选择设备</Label>
|
||||
<Button variant="outline" className="w-full mt-2 justify-between" onClick={() => setIsDeviceDialogOpen(true)}>
|
||||
{formData.selectedDevices?.length ? `已选择 ${formData.selectedDevices.length} 个设备` : "选择设备"}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="text-base">好友备注</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>设置添加好友时的备注格式</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select
|
||||
value={formData.remarkType || "phone"}
|
||||
onValueChange={(value) => onChange({ ...formData, remarkType: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full mt-2">
|
||||
<SelectValue placeholder="选择备注类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remarkTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.remarkType && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="text-gray-500 mb-1">备注格式预览:</div>
|
||||
{formData.remarkType === "phone" && "138****1234"}
|
||||
{formData.remarkType === "nickname" && "小红书用户2851"}
|
||||
{formData.remarkType === "source" && "抖音直播"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base">招呼语</Label>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsTemplateDialogOpen(true)} className="text-blue-500">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
参考模板
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.greeting}
|
||||
onChange={(e) => onChange({ ...formData, greeting: e.target.value })}
|
||||
placeholder="请输入招呼语"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="text-base">每个设备每日最大添加数量</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<HelpCircle className="h-4 w-4 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>为了账号安全,建议每个设备每日添加不超过20个好友</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.maxDailyFriends || 20}
|
||||
onChange={(e) => onChange({ ...formData, maxDailyFriends: Number(e.target.value) })}
|
||||
max={20}
|
||||
min={1}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">添加间隔</Label>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.addFriendInterval || 1}
|
||||
onChange={(e) => onChange({ ...formData, addFriendInterval: Number(e.target.value) })}
|
||||
className="w-32"
|
||||
/>
|
||||
<span>分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">允许加人的时间段</Label>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeStart || "09:00"}
|
||||
onChange={(e) => onChange({ ...formData, addFriendTimeStart: e.target.value })}
|
||||
className="w-32"
|
||||
/>
|
||||
<span>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeEnd || "18:00"}
|
||||
onChange={(e) => onChange({ ...formData, addFriendTimeEnd: e.target.value })}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={onNext}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeviceSelectionDialog
|
||||
open={isDeviceDialogOpen}
|
||||
onOpenChange={setIsDeviceDialogOpen}
|
||||
selectedDevices={formData.selectedDevices || []}
|
||||
onSelect={handleDeviceSelect}
|
||||
excludeUsedDevices={true}
|
||||
/>
|
||||
|
||||
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>招呼语模板</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{greetingTemplates.map((template, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-3 px-4"
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
406
app/plans/new/steps/MessageSettings.tsx
Normal file
406
app/plans/new/steps/MessageSettings.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
MessageSquare,
|
||||
ImageIcon,
|
||||
Video,
|
||||
FileText,
|
||||
Link2,
|
||||
Users,
|
||||
AppWindowIcon as Window,
|
||||
Plus,
|
||||
X,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface MessageContent {
|
||||
id: string
|
||||
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group"
|
||||
content: string
|
||||
sendInterval?: number
|
||||
intervalUnit?: "seconds" | "minutes"
|
||||
title?: string
|
||||
description?: string
|
||||
address?: string
|
||||
coverImage?: string
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
interface DayPlan {
|
||||
day: number
|
||||
messages: MessageContent[]
|
||||
}
|
||||
|
||||
interface MessageSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
}
|
||||
|
||||
// 消息类型配置
|
||||
const messageTypes = [
|
||||
{ id: "text", icon: MessageSquare, label: "文本" },
|
||||
{ id: "image", icon: ImageIcon, label: "图片" },
|
||||
{ id: "video", icon: Video, label: "视频" },
|
||||
{ id: "file", icon: FileText, label: "文件" },
|
||||
{ id: "miniprogram", icon: Window, label: "小程序" },
|
||||
{ id: "link", icon: Link2, label: "链<><E993BE><EFBFBD>" },
|
||||
{ id: "group", icon: Users, label: "邀请入群" },
|
||||
]
|
||||
|
||||
// 模拟群组数据
|
||||
const mockGroups = [
|
||||
{ id: "1", name: "产品交流群1", memberCount: 156 },
|
||||
{ id: "2", name: "产品交流群2", memberCount: 234 },
|
||||
{ id: "3", name: "产品交流群3", memberCount: 89 },
|
||||
]
|
||||
|
||||
export function MessageSettings({ formData, onChange, onNext, onPrev }: MessageSettingsProps) {
|
||||
const [dayPlans, setDayPlans] = useState<DayPlan[]>([
|
||||
{
|
||||
day: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "text",
|
||||
content: "",
|
||||
sendInterval: 5,
|
||||
intervalUnit: "minutes",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false)
|
||||
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false)
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("")
|
||||
|
||||
// 添加新消息
|
||||
const handleAddMessage = (dayIndex: number, type = "text") => {
|
||||
const updatedPlans = [...dayPlans]
|
||||
updatedPlans[dayIndex].messages.push({
|
||||
id: Date.now().toString(),
|
||||
type: type as MessageContent["type"],
|
||||
content: "",
|
||||
sendInterval: 5,
|
||||
intervalUnit: "minutes",
|
||||
})
|
||||
setDayPlans(updatedPlans)
|
||||
onChange({ ...formData, messagePlans: updatedPlans })
|
||||
}
|
||||
|
||||
// 更新消息内容
|
||||
const handleUpdateMessage = (dayIndex: number, messageIndex: number, updates: Partial<MessageContent>) => {
|
||||
const updatedPlans = [...dayPlans]
|
||||
updatedPlans[dayIndex].messages[messageIndex] = {
|
||||
...updatedPlans[dayIndex].messages[messageIndex],
|
||||
...updates,
|
||||
}
|
||||
setDayPlans(updatedPlans)
|
||||
onChange({ ...formData, messagePlans: updatedPlans })
|
||||
}
|
||||
|
||||
// 删除消息
|
||||
const handleRemoveMessage = (dayIndex: number, messageIndex: number) => {
|
||||
const updatedPlans = [...dayPlans]
|
||||
updatedPlans[dayIndex].messages.splice(messageIndex, 1)
|
||||
setDayPlans(updatedPlans)
|
||||
onChange({ ...formData, messagePlans: updatedPlans })
|
||||
}
|
||||
|
||||
// 添加新的天数计划
|
||||
const handleAddDayPlan = () => {
|
||||
setDayPlans([
|
||||
...dayPlans,
|
||||
{
|
||||
day: dayPlans.length,
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "text",
|
||||
content: "",
|
||||
sendInterval: 5,
|
||||
intervalUnit: "minutes",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
setIsAddDayPlanOpen(false)
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `已添加第${dayPlans.length}天的消息计划`,
|
||||
})
|
||||
}
|
||||
|
||||
// 选择群组
|
||||
const handleSelectGroup = (groupId: string) => {
|
||||
setSelectedGroupId(groupId)
|
||||
setIsGroupSelectOpen(false)
|
||||
toast({
|
||||
title: "选择成功",
|
||||
description: `已选择群组:${mockGroups.find((g) => g.id === groupId)?.name}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileUpload = (dayIndex: number, messageIndex: number, type: "image" | "video" | "file") => {
|
||||
// 模拟文件上传
|
||||
toast({
|
||||
title: "上传成功",
|
||||
description: `${type === "image" ? "图片" : type === "video" ? "视频" : "文件"}上传成功`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">消息设置</h2>
|
||||
<Button variant="outline" size="icon" onClick={() => setIsAddDayPlanOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="0" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
{dayPlans.map((plan) => (
|
||||
<TabsTrigger key={plan.day} value={plan.day.toString()} className="flex-1">
|
||||
{plan.day === 0 ? "即时消息" : `第${plan.day}天`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{dayPlans.map((plan, dayIndex) => (
|
||||
<TabsContent key={plan.day} value={plan.day.toString()}>
|
||||
<div className="space-y-4">
|
||||
{plan.messages.map((message, messageIndex) => (
|
||||
<div key={message.id} className="space-y-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label>发送间隔</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={message.sendInterval}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, { sendInterval: Number(e.target.value) })
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
<Select
|
||||
value={message.intervalUnit}
|
||||
onValueChange={(value) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
intervalUnit: value as "seconds" | "minutes",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="选择单位" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">秒</SelectItem>
|
||||
<SelectItem value="minutes">分钟</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveMessage(dayIndex, messageIndex)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 bg-white p-2 rounded-lg">
|
||||
{messageTypes.map((type) => (
|
||||
<Button
|
||||
key={type.id}
|
||||
variant={message.type === type.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleUpdateMessage(dayIndex, messageIndex, { type: type.id as any })}
|
||||
className="flex flex-col items-center p-2 h-auto"
|
||||
>
|
||||
<type.icon className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message.type === "text" && (
|
||||
<Textarea
|
||||
value={message.content}
|
||||
onChange={(e) => handleUpdateMessage(dayIndex, messageIndex, { content: e.target.value })}
|
||||
placeholder="请输入消息内容"
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.type === "miniprogram" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
标题<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) => handleUpdateMessage(dayIndex, messageIndex, { title: e.target.value })}
|
||||
placeholder="请输入小程序标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, { description: e.target.value })
|
||||
}
|
||||
placeholder="请输入小程序描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
地址<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={message.address}
|
||||
onChange={(e) => handleUpdateMessage(dayIndex, messageIndex, { address: e.target.value })}
|
||||
placeholder="请输入小程序路径"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
封面<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="border-2 border-dashed rounded-lg p-4 text-center">
|
||||
{message.coverImage ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={message.coverImage || "/placeholder.svg"}
|
||||
alt="封面"
|
||||
className="max-w-[200px] mx-auto rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => handleUpdateMessage(dayIndex, messageIndex, { coverImage: undefined })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-[120px]"
|
||||
onClick={() => handleFileUpload(dayIndex, messageIndex, "image")}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上传封面
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === "group" && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
选择群聊<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setIsGroupSelectOpen(true)}
|
||||
>
|
||||
{selectedGroupId ? mockGroups.find((g) => g.id === selectedGroupId)?.name : "选择邀请入的群"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(message.type === "image" || message.type === "video" || message.type === "file") && (
|
||||
<div className="border-2 border-dashed rounded-lg p-4 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-[120px]"
|
||||
onClick={() => handleFileUpload(dayIndex, messageIndex, message.type as any)}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上传{message.type === "image" ? "图片" : message.type === "video" ? "视频" : "文件"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button variant="outline" onClick={() => handleAddMessage(dayIndex)} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加消息
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={onNext}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加天数计划弹窗 */}
|
||||
<Dialog open={isAddDayPlanOpen} onOpenChange={setIsAddDayPlanOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加消息计划</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-gray-500 mb-4">选择要添加的消息计划类型</p>
|
||||
<Button onClick={handleAddDayPlan} className="w-full">
|
||||
添加第 {dayPlans.length} 天计划
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 选择群聊弹窗 */}
|
||||
<Dialog open={isGroupSelectOpen} onOpenChange={setIsGroupSelectOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择群聊</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
{mockGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={`p-4 rounded-lg cursor-pointer hover:bg-gray-100 ${
|
||||
selectedGroupId === group.id ? "bg-blue-50 border border-blue-200" : ""
|
||||
}`}
|
||||
onClick={() => handleSelectGroup(group.id)}
|
||||
>
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-sm text-gray-500">成员数:{group.memberCount}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsGroupSelectOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setIsGroupSelectOpen(false)}>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
284
app/plans/new/steps/TagSettings.tsx
Normal file
284
app/plans/new/steps/TagSettings.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Users, UserPlus, X } from "lucide-react"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
interface TeamMember {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
status: "active" | "inactive"
|
||||
}
|
||||
|
||||
interface OrderTag {
|
||||
phone: string
|
||||
wechat: string
|
||||
source?: string
|
||||
orderAmount?: number
|
||||
orderDate?: string
|
||||
}
|
||||
|
||||
interface WechatFriend {
|
||||
id: string
|
||||
nickname: string
|
||||
wechatId: string
|
||||
remark: string
|
||||
addedDate: string
|
||||
}
|
||||
|
||||
interface TagSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onComplete: () => void
|
||||
onPrev?: () => void
|
||||
}
|
||||
|
||||
export function TagSettings({ formData, onChange, onComplete, onPrev }: TagSettingsProps) {
|
||||
const router = useRouter()
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>(formData.teamMembers || [])
|
||||
const [selectedRole, setSelectedRole] = useState("member")
|
||||
const [newMemberName, setNewMemberName] = useState("")
|
||||
const [importedTags, setImportedTags] = useState<OrderTag[]>([])
|
||||
const [wechatFriends, setWechatFriends] = useState<WechatFriend[]>([])
|
||||
const [activeTab, setActiveTab] = useState(formData.sourceWechatId ? "friends" : "team")
|
||||
|
||||
// 如果是从微信号好友转移过来,加载好友数据
|
||||
useEffect(() => {
|
||||
if (formData.sourceWechatId) {
|
||||
// 模拟加载微信好友数据
|
||||
const mockFriends = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `friend-${i + 1}`,
|
||||
nickname: `好友${i + 1}`,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
remark: Math.random() > 0.5 ? `备注${i + 1}` : "",
|
||||
addedDate: new Date(Date.now() - Math.floor(Math.random() * 10000000000)).toLocaleDateString(),
|
||||
}))
|
||||
setWechatFriends(mockFriends)
|
||||
setActiveTab("friends")
|
||||
}
|
||||
}, [formData.sourceWechatId])
|
||||
|
||||
const handleAddMember = () => {
|
||||
if (newMemberName.trim()) {
|
||||
const newMember: TeamMember = {
|
||||
id: Date.now().toString(),
|
||||
name: newMemberName,
|
||||
role: selectedRole,
|
||||
status: "active",
|
||||
}
|
||||
const updatedMembers = [...teamMembers, newMember]
|
||||
setTeamMembers(updatedMembers)
|
||||
onChange({ ...formData, teamMembers: updatedMembers })
|
||||
setNewMemberName("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveMember = (id: string) => {
|
||||
const updatedMembers = teamMembers.filter((member) => member.id !== id)
|
||||
setTeamMembers(updatedMembers)
|
||||
onChange({ ...formData, teamMembers: updatedMembers })
|
||||
}
|
||||
|
||||
const handleToggleStatus = (id: string) => {
|
||||
const updatedMembers = teamMembers.map((member) => {
|
||||
if (member.id === id) {
|
||||
return {
|
||||
...member,
|
||||
status: member.status === "active" ? "inactive" : "active",
|
||||
}
|
||||
}
|
||||
return member
|
||||
})
|
||||
setTeamMembers(updatedMembers)
|
||||
onChange({ ...formData, teamMembers: updatedMembers })
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
// 根据当前标签页确定要保存的数据
|
||||
if (activeTab === "friends" && wechatFriends.length > 0) {
|
||||
// 将微信好友转换为标签
|
||||
const friendTags = wechatFriends.map((friend) => ({
|
||||
phone: "",
|
||||
wechat: friend.wechatId,
|
||||
source: formData.sourceWechatId,
|
||||
orderAmount: undefined,
|
||||
orderDate: friend.addedDate,
|
||||
}))
|
||||
onChange({ ...formData, importedTags: friendTags })
|
||||
} else if (activeTab === "import" && importedTags.length > 0) {
|
||||
onChange({ ...formData, importedTags })
|
||||
}
|
||||
|
||||
onComplete()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="team">打粉团队</TabsTrigger>
|
||||
<TabsTrigger value="import">标签导入</TabsTrigger>
|
||||
{formData.sourceWechatId && <TabsTrigger value="friends">微信好友</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="team" className="mt-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={newMemberName}
|
||||
onChange={(e) => setNewMemberName(e.target.value)}
|
||||
placeholder="输入成员名称"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="选择角色" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="leader">组长</SelectItem>
|
||||
<SelectItem value="member">组员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleAddMember}>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
添加成员
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{teamMembers.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{member.name}</span>
|
||||
<Badge variant="outline">{member.role === "leader" ? "组长" : "组员"}</Badge>
|
||||
<Badge
|
||||
variant={member.status === "active" ? "default" : "secondary"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleToggleStatus(member.id)}
|
||||
>
|
||||
{member.status === "active" ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRemoveMember(member.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="import" className="mt-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">标签导入</h3>
|
||||
</div>
|
||||
|
||||
{importedTags.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium mb-2">已导入 {importedTags.length} 条数据</h4>
|
||||
<div className="max-h-[300px] overflow-auto border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>电话号码</TableHead>
|
||||
<TableHead>微信号</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>订单金额</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{importedTags.slice(0, 5).map((tag, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{tag.phone}</TableCell>
|
||||
<TableCell>{tag.wechat}</TableCell>
|
||||
<TableCell>{tag.source}</TableCell>
|
||||
<TableCell>{tag.orderAmount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{importedTags.length > 5 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-gray-500">
|
||||
还有 {importedTags.length - 5} 条数据未显示
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{formData.sourceWechatId && (
|
||||
<TabsContent value="friends" className="mt-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
<h3 className="text-lg font-medium">微信好友列表</h3>
|
||||
</div>
|
||||
<Badge variant="outline">共 {wechatFriends.length} 位好友</Badge>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-auto border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>昵称</TableHead>
|
||||
<TableHead>微信号</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead>添加时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{wechatFriends.map((friend) => (
|
||||
<TableRow key={friend.id}>
|
||||
<TableCell>{friend.nickname}</TableCell>
|
||||
<TableCell>{friend.wechatId}</TableCell>
|
||||
<TableCell>{friend.remark || "-"}</TableCell>
|
||||
<TableCell>{friend.addedDate}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>• 这些好友将被导入为订单标签</p>
|
||||
<p>• 导入后可用于创建获客计划</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-between">
|
||||
{onPrev && (
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleComplete} className={onPrev ? "ml-auto" : "w-full"}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
app/scenarios/[channel]/acquired/page.tsx
Normal file
115
app/scenarios/[channel]/acquired/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
tags: string[]
|
||||
acquiredTime: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export default function AcquiredCustomersPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
const [customers] = useState<Customer[]>(
|
||||
Array.from({ length: 31 }, (_, i) => ({
|
||||
id: `customer-${i + 1}`,
|
||||
nickname: `用户${i + 1}`,
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
|
||||
tags: ["直播间", "高互动", Math.random() > 0.5 ? "潜在客户" : "意向客户"],
|
||||
acquiredTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
|
||||
source: Math.random() > 0.5 ? "直播间" : "评论区",
|
||||
})),
|
||||
)
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
const totalPages = Math.ceil(customers.length / itemsPerPage)
|
||||
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">{channelName}已获客用户</h1>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">共 {customers.length} 位用户</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{currentCustomers.map((customer) => (
|
||||
<Card key={customer.id} className="p-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={customer.avatar || "/placeholder.svg"}
|
||||
alt={customer.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">{customer.nickname}</h3>
|
||||
<span className="text-sm text-gray-500">{customer.acquiredTime}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{customer.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">来源:{customer.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {currentPage} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
app/scenarios/[channel]/added/page.tsx
Normal file
118
app/scenarios/[channel]/added/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
interface AddedCustomer {
|
||||
id: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
tags: string[]
|
||||
addedTime: string
|
||||
source: string
|
||||
wechatId: string
|
||||
}
|
||||
|
||||
export default function AddedCustomersPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
const [customers] = useState<AddedCustomer[]>(
|
||||
Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `customer-${i + 1}`,
|
||||
nickname: `用户${i + 1}`,
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
|
||||
tags: ["已添加", Math.random() > 0.5 ? "高意向" : "待跟进", "直播间"],
|
||||
addedTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
|
||||
source: Math.random() > 0.5 ? "直播间" : "评论区",
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
})),
|
||||
)
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
const totalPages = Math.ceil(customers.length / itemsPerPage)
|
||||
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">{channelName}已添加好友</h1>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">共 {customers.length} 位好友</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{currentCustomers.map((customer) => (
|
||||
<Card key={customer.id} className="p-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={customer.avatar || "/placeholder.svg"}
|
||||
alt={customer.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">{customer.nickname}</h3>
|
||||
<span className="text-sm text-gray-500">{customer.addedTime}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">微信号:{customer.wechatId}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{customer.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">来源:{customer.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {currentPage} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
app/scenarios/[channel]/devices/page.tsx
Normal file
257
app/scenarios/[channel]/devices/page.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ChevronLeft, Filter, Search, RefreshCw } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { Device } from "@/types/device"
|
||||
|
||||
export default function ScenarioDevicesPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
const devicesPerPage = 10
|
||||
const maxDevices = 5
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟API调用
|
||||
const fetchDevices = async () => {
|
||||
const mockDevices = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `device-${i + 1}`,
|
||||
imei: `sd${123123 + i}`,
|
||||
name: `设备 ${i + 1}`,
|
||||
remark: `${channelName}获客设备 ${i + 1}`,
|
||||
status: Math.random() > 0.2 ? "online" : "offline",
|
||||
battery: Math.floor(Math.random() * 100),
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
friendCount: Math.floor(Math.random() * 1000),
|
||||
todayAdded: Math.floor(Math.random() * 50),
|
||||
messageCount: Math.floor(Math.random() * 200),
|
||||
lastActive: new Date(Date.now() - Math.random() * 86400000).toLocaleString(),
|
||||
addFriendStatus: Math.random() > 0.2 ? "normal" : "abnormal",
|
||||
}))
|
||||
setDevices(mockDevices)
|
||||
}
|
||||
|
||||
fetchDevices()
|
||||
}, [channelName])
|
||||
|
||||
const handleRefresh = () => {
|
||||
toast({
|
||||
title: "刷新成功",
|
||||
description: "设备列表已更新",
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDevices.length === devices.length || selectedDevices.length === maxDevices) {
|
||||
setSelectedDevices([])
|
||||
} else {
|
||||
const newSelection = devices.slice(0, maxDevices).map((d) => d.id)
|
||||
setSelectedDevices(newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
} else {
|
||||
if (selectedDevices.length >= maxDevices) {
|
||||
toast({
|
||||
title: "选择超出限制",
|
||||
description: `最多可选择${maxDevices}个设备`,
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
setSelectedDevices([...selectedDevices, deviceId])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用来保存选中的设备
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "已更新计划设备",
|
||||
})
|
||||
router.back()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "更新设备失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesSearch =
|
||||
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.wechatId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const matchesStatus = statusFilter === "all" || device.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">{channelName}获客计划设备</h1>
|
||||
</div>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selectedDevices.length}/{maxDevices} 个设备
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedDevices.length > 0 &&
|
||||
(selectedDevices.length === devices.length || selectedDevices.length === maxDevices)
|
||||
}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm">全选</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paginatedDevices.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">暂无设备</div>
|
||||
) : (
|
||||
paginatedDevices.map((device) => (
|
||||
<Card
|
||||
key={device.id}
|
||||
className={`p-3 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selectedDevices.includes(device.id) ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
onClick={() => handleDeviceSelect(device.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
className="mt-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeviceSelect(device.id)
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium truncate">{device.name}</div>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
|
||||
<div className="text-sm text-gray-500">微信号: {device.wechatId}</div>
|
||||
<div className="flex items-center justify-between mt-1 text-sm">
|
||||
<span className="text-gray-500">好友数: {device.friendCount}</span>
|
||||
<span className="text-gray-500">今日新增: +{device.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredDevices.length > devicesPerPage && (
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {currentPage} / {Math.ceil(filteredDevices.length / devicesPerPage)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
|
||||
}
|
||||
disabled={currentPage === Math.ceil(filteredDevices.length / devicesPerPage)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
240
app/scenarios/[channel]/edit/[id]/page.tsx
Normal file
240
app/scenarios/[channel]/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BasicSettings } from "../../../new/steps/BasicSettings"
|
||||
import { FriendRequestSettings } from "../../../new/steps/FriendRequestSettings"
|
||||
import { MessageSettings } from "../../../new/steps/MessageSettings"
|
||||
import { TagSettings } from "../../../new/steps/TagSettings"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
{ id: 4, title: "步骤四", subtitle: "流量标签设置" },
|
||||
]
|
||||
|
||||
export default function EditAcquisitionPlan({ params }: { params: { channel: string; id: string } }) {
|
||||
const router = useRouter()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [formData, setFormData] = useState({
|
||||
planName: "",
|
||||
accounts: [],
|
||||
dailyLimit: 10,
|
||||
enabled: true,
|
||||
remarkType: "phone",
|
||||
remarkKeyword: "",
|
||||
greeting: "",
|
||||
addFriendTimeStart: "09:00",
|
||||
addFriendTimeEnd: "18:00",
|
||||
addFriendInterval: 1,
|
||||
maxDailyFriends: 20,
|
||||
messageInterval: 1,
|
||||
messageContent: "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟从API获取计划数据
|
||||
const fetchPlanData = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
const mockData = {
|
||||
planName: "测试计划",
|
||||
accounts: ["account1"],
|
||||
dailyLimit: 15,
|
||||
enabled: true,
|
||||
remarkType: "phone",
|
||||
remarkKeyword: "测试",
|
||||
greeting: "你好",
|
||||
addFriendTimeStart: "09:00",
|
||||
addFriendTimeEnd: "18:00",
|
||||
addFriendInterval: 2,
|
||||
maxDailyFriends: 25,
|
||||
messageInterval: 2,
|
||||
messageContent: "欢迎",
|
||||
}
|
||||
setFormData(mockData)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "加载失败",
|
||||
description: "获取计划数据失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPlanData()
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 这里应该是实际的API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "获客计划已更新",
|
||||
})
|
||||
router.push(`/scenarios/${params.channel}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "更新计划失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prevStep) => Math.max(prevStep - 1, 1))
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (isStepValid()) {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave()
|
||||
} else {
|
||||
setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isStepValid = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
if (!formData.planName.trim() || formData.accounts.length === 0) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写计划名称并选择至少一个账号",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 2:
|
||||
if (!formData.greeting.trim()) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写好友申请信息",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 3:
|
||||
if (!formData.messageContent.trim()) {
|
||||
toast({
|
||||
title: "请完善信息",
|
||||
description: "请填写消息内容",
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 4:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} isEdit />
|
||||
case 2:
|
||||
return (
|
||||
<FriendRequestSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
|
||||
)
|
||||
case 3:
|
||||
return <MessageSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
|
||||
case 4:
|
||||
return <TagSettings formData={formData} onChange={setFormData} onNext={handleSave} onPrev={handlePrev} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">编辑获客计划</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="px-4 py-6">
|
||||
<div className="relative flex justify-between">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
"flex flex-col items-center relative z-10",
|
||||
currentStep >= step.id ? "text-blue-600" : "text-gray-400",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 transition-colors",
|
||||
currentStep >= step.id
|
||||
? "border-blue-600 bg-blue-600 text-white"
|
||||
: "border-gray-300 bg-white text-gray-400",
|
||||
)}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
<div className="text-xs mt-1">{step.title}</div>
|
||||
<div className="text-xs mt-0.5 font-medium">{step.subtitle}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="absolute top-4 left-0 right-0 h-0.5 bg-gray-200 -z-10">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
|
||||
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-white border-t p-4">
|
||||
<div className="flex justify-between max-w-[390px] mx-auto">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
<Button className={cn("min-w-[120px]", currentStep === 1 ? "w-full" : "ml-auto")} onClick={handleNext}>
|
||||
{currentStep === steps.length ? "保存" : "下一步"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
293
app/scenarios/[channel]/page.tsx
Normal file
293
app/scenarios/[channel]/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Clock, ChevronLeft, MoreVertical, Copy, Pencil, Trash2 } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { BindDouyinQRCode } from "@/components/BindDouyinQRCode"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import Link from "next/link"
|
||||
import { PlanSettingsDialog } from "@/components/acquisition/PlanSettingsDialog"
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
status: "running" | "paused" | "completed"
|
||||
stats: {
|
||||
devices: number
|
||||
acquired: number
|
||||
added: number
|
||||
}
|
||||
lastUpdated: string
|
||||
executionTime: string
|
||||
nextExecutionTime: string
|
||||
trend: { date: string; customers: number }[]
|
||||
}
|
||||
|
||||
interface DeviceStats {
|
||||
active: number
|
||||
}
|
||||
|
||||
export default function ChannelPage({ params }: { params: { channel: string } }) {
|
||||
const router = useRouter()
|
||||
const channel = params.channel
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: `${channelName}直播获客计划`,
|
||||
status: "running",
|
||||
stats: {
|
||||
devices: 5,
|
||||
acquired: 31,
|
||||
added: 25,
|
||||
},
|
||||
lastUpdated: "2024-02-09 15:30",
|
||||
executionTime: "2024-02-09 17:24:10",
|
||||
nextExecutionTime: "2024-02-09 17:25:36",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2月${String(i + 1)}日`,
|
||||
customers: Math.floor(Math.random() * 30) + 30,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: `${channelName}评论区获客计划`,
|
||||
status: "paused",
|
||||
stats: {
|
||||
devices: 3,
|
||||
acquired: 15,
|
||||
added: 12,
|
||||
},
|
||||
lastUpdated: "2024-02-09 14:00",
|
||||
executionTime: "2024-02-09 16:30:00",
|
||||
nextExecutionTime: "2024-02-09 16:45:00",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2月${String(i + 1)}日`,
|
||||
customers: Math.floor(Math.random() * 20) + 20,
|
||||
})),
|
||||
},
|
||||
])
|
||||
|
||||
const [deviceStats, setDeviceStats] = useState<DeviceStats>({
|
||||
active: 5,
|
||||
})
|
||||
|
||||
const [isPlanSettingsOpen, setIsPlanSettingsOpen] = useState(false)
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string>("")
|
||||
|
||||
const toggleTaskStatus = (taskId: string) => {
|
||||
setTasks(
|
||||
tasks.map((task) => {
|
||||
if (task.id === taskId) {
|
||||
const newStatus = task.status === "running" ? "paused" : "running"
|
||||
return { ...task, status: newStatus }
|
||||
}
|
||||
return task
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditPlan = (taskId: string) => {
|
||||
console.log(`编辑计划: ${taskId}`)
|
||||
console.log(`跳转到: /scenarios/${channel}/edit/${taskId}`)
|
||||
router.push(`/scenarios/${channel}/edit/${taskId}`)
|
||||
}
|
||||
|
||||
const handleCopyPlan = (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId)
|
||||
if (taskToCopy) {
|
||||
const newTask = {
|
||||
...taskToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${taskToCopy.name} (副本)`,
|
||||
status: "paused" as const,
|
||||
}
|
||||
setTasks([...tasks, newTask])
|
||||
toast({
|
||||
title: "计划已复制",
|
||||
description: `已成功复制"${taskToCopy.name}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlan = (taskId: string) => {
|
||||
const taskToDelete = tasks.find((t) => t.id === taskId)
|
||||
if (taskToDelete) {
|
||||
setTasks(tasks.filter((t) => t.id !== taskId))
|
||||
toast({
|
||||
title: "计划已删除",
|
||||
description: `已成功删除"${taskToDelete.name}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 计算通过率
|
||||
const calculatePassRate = (acquired: number, added: number) => {
|
||||
if (acquired === 0) return 0
|
||||
return Math.round((added / acquired) * 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-blue-600">{channelName}获客</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
{tasks.map((task) => {
|
||||
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
|
||||
const passRate = calculatePassRate(acquiredCount, addedCount)
|
||||
|
||||
return (
|
||||
<Card key={task.id} className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium text-lg">{task.name}</h3>
|
||||
<Badge
|
||||
variant={task.status === "running" ? "success" : "secondary"}
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
onClick={() => {
|
||||
setSelectedPlanId(task.id)
|
||||
setIsPlanSettingsOpen(true)
|
||||
}}
|
||||
>
|
||||
{task.status === "running" ? "进行中" : "已暂停"}
|
||||
</Badge>
|
||||
{params.channel === "douyin" && <BindDouyinQRCode />}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full z-10">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 z-50">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditPlan(task.id)
|
||||
}}
|
||||
className="cursor-pointer hover:bg-blue-50"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
编辑计划
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopyPlan(task.id)
|
||||
}}
|
||||
className="cursor-pointer hover:bg-blue-50"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制计划
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeletePlan(task.id)
|
||||
}}
|
||||
className="text-red-600 cursor-pointer hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除计划
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<Link href={`/scenarios/${channel}/devices`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">设备数</div>
|
||||
<div className="text-2xl font-semibold">{deviceCount}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href={`/scenarios/${channel}/acquired`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已获客</div>
|
||||
<div className="text-2xl font-semibold">{acquiredCount}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href={`/scenarios/${channel}/added`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已添加</div>
|
||||
<div className="text-2xl font-semibold">{addedCount}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="text-sm text-gray-500 mb-1">通过率</div>
|
||||
<div className="text-2xl font-semibold">{passRate}%</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="h-48 bg-white rounded-lg p-4 mb-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={task.trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="customers"
|
||||
name="获客数"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#3b82f6" }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>上次执行:{task.executionTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>下次执行:{task.nextExecutionTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<PlanSettingsDialog open={isPlanSettingsOpen} onOpenChange={setIsPlanSettingsOpen} planId={selectedPlanId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
230
app/scenarios/[channel]/traffic/page.tsx
Normal file
230
app/scenarios/[channel]/traffic/page.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ChevronLeft, Search, Filter, RefreshCw } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
|
||||
interface TrafficUser {
|
||||
id: string
|
||||
avatar: string
|
||||
nickname: string
|
||||
wechatId: string
|
||||
phone: string
|
||||
region: string
|
||||
note: string
|
||||
status: "pending" | "added" | "failed"
|
||||
addTime: string
|
||||
source: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export default function ChannelTrafficPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { channel: string }
|
||||
searchParams: { type?: string }
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [users, setUsers] = useState<TrafficUser[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.type || "all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
xiaohongshu: "小红书",
|
||||
weibo: "微博",
|
||||
}
|
||||
return channelMap[channel] || channel
|
||||
}
|
||||
|
||||
const channelName = getChannelName(params.channel)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
// 模拟API调用
|
||||
const mockUsers = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `user-${i + 1}`,
|
||||
avatar: "/placeholder.svg?height=40&width=40",
|
||||
nickname: `用户${i + 1}`,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () =>
|
||||
Math.floor(Math.random() * 10),
|
||||
).join("")}`,
|
||||
region: ["广东", "浙江", "江苏", "北京", "上海"][Math.floor(Math.random() * 5)],
|
||||
note: ["感兴趣", "需要了解", "想购买", "咨询价格"][Math.floor(Math.random() * 4)],
|
||||
status: searchParams.type === "added" ? "added" : ["pending", "added", "failed"][Math.floor(Math.random() * 3)],
|
||||
addTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
|
||||
source: params.channel,
|
||||
tags: ["意向客户", "高活跃度", "新用户"][Math.floor(Math.random() * 3)].split(" "),
|
||||
}))
|
||||
setUsers(mockUsers)
|
||||
}
|
||||
|
||||
fetchUsers()
|
||||
}, [params.channel, searchParams.type])
|
||||
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const matchesSearch =
|
||||
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.phone.includes(searchQuery)
|
||||
const matchesStatus = statusFilter === "all" || user.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const paginatedUsers = filteredUsers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
|
||||
|
||||
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">
|
||||
{channelName}
|
||||
{searchParams.type === "added" ? "已添加用户" : "获客用户"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索用户"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待处理</SelectItem>
|
||||
<SelectItem value="added">已添加</SelectItem>
|
||||
<SelectItem value="failed">已失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{paginatedUsers.map((user) => (
|
||||
<Card key={user.id} className="p-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<img
|
||||
src={user.avatar || "/placeholder.svg"}
|
||||
alt={user.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium truncate">{user.nickname}</div>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "added" ? "success" : user.status === "failed" ? "destructive" : "secondary"
|
||||
}
|
||||
>
|
||||
{user.status === "added" ? "已添加" : user.status === "failed" ? "已失败" : "待处理"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
<div>微信号: {user.wechatId}</div>
|
||||
<div>手机号: {user.phone}</div>
|
||||
<div>地区: {user.region}</div>
|
||||
<div>添加时间: {user.addTime}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{user.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={currentPage === page}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage(page)
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
app/scenarios/api/loading.tsx
Normal file
37
app/scenarios/api/loading.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">API 接口管理</h1>
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
<div className="flex justify-between mt-4">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
373
app/scenarios/api/page.tsx
Normal file
373
app/scenarios/api/page.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus, Filter, Search, RefreshCw, MoreVertical, Clock, Copy, Code } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import Link from "next/link"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { useRouter } from "next/navigation"
|
||||
import type { Device } from "@/components/device-grid"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge" // Import Badge component
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
status: "running" | "paused" | "completed"
|
||||
stats: {
|
||||
devices: number
|
||||
customersPerDevice: number
|
||||
totalCalls: number
|
||||
successCalls: number
|
||||
errorCalls: number
|
||||
successRate: number
|
||||
}
|
||||
lastUpdated: string
|
||||
executionTime: string
|
||||
nextExecutionTime: string
|
||||
trend: { date: string; calls: number; success: number }[]
|
||||
deviceList: Device[]
|
||||
apiInfo: {
|
||||
endpoint: string
|
||||
method: string
|
||||
token: string
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机数据的辅助函数
|
||||
function generateRandomStats() {
|
||||
const devices = Math.floor(Math.random() * 16) + 5
|
||||
const customersPerDevice = Math.floor(Math.random() * 11) + 10
|
||||
const totalCalls = Math.floor(Math.random() * 1000) + 500
|
||||
const successCalls = Math.floor(totalCalls * (Math.random() * 0.3 + 0.6))
|
||||
const errorCalls = totalCalls - successCalls
|
||||
|
||||
return {
|
||||
devices,
|
||||
customersPerDevice,
|
||||
totalCalls,
|
||||
successCalls,
|
||||
errorCalls,
|
||||
successRate: Math.round((successCalls / totalCalls) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
export default function ApiPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "APP加友接口",
|
||||
status: "running",
|
||||
stats: generateRandomStats(),
|
||||
lastUpdated: "2024-02-09 15:30",
|
||||
executionTime: "2024-02-09 17:24:10",
|
||||
nextExecutionTime: "2024-02-09 17:25:36",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2024-02-${String(i + 1).padStart(2, "0")}`,
|
||||
calls: Math.floor(Math.random() * 100) + 50,
|
||||
success: Math.floor(Math.random() * 80) + 40,
|
||||
})),
|
||||
deviceList: [],
|
||||
apiInfo: {
|
||||
endpoint: "https://api.ckb.quwanzhi.com/api/open/task/addFriend",
|
||||
method: "POST",
|
||||
token: "ckb_token_xxxxx",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
|
||||
const [isApiDocsOpen, setIsApiDocsOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const toggleTaskStatus = (taskId: string) => {
|
||||
setTasks(
|
||||
tasks.map((task) => {
|
||||
if (task.id === taskId) {
|
||||
return {
|
||||
...task,
|
||||
status: task.status === "running" ? "paused" : "running",
|
||||
}
|
||||
}
|
||||
return task
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const copyTask = (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId)
|
||||
if (taskToCopy) {
|
||||
const newTask = {
|
||||
...taskToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${taskToCopy.name} (副本)`,
|
||||
stats: generateRandomStats(),
|
||||
status: "paused" as const,
|
||||
lastUpdated: new Date().toLocaleString(),
|
||||
}
|
||||
setTasks([...tasks, newTask])
|
||||
toast({
|
||||
title: "复制成功",
|
||||
description: "已创建计划副本",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskClick = (taskId: string) => {
|
||||
router.push(`/scenarios/api/${taskId}/edit`)
|
||||
}
|
||||
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gradient-to-b from-violet-50 to-white">
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h1 className="text-xl font-semibold text-violet-600">API获客</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="/scenarios/api/new">
|
||||
<Button className="bg-violet-600 hover:bg-violet-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建计划
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
|
||||
<Input className="pl-9" placeholder="搜索计划名称" />
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => (
|
||||
<Card
|
||||
key={task.id}
|
||||
className="p-6 hover:shadow-lg transition-all cursor-pointer"
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium text-lg">{task.name}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
task.status === "running"
|
||||
? "bg-green-50 text-green-600"
|
||||
: task.status === "paused"
|
||||
? "bg-yellow-50 text-yellow-600"
|
||||
: "bg-gray-50 text-gray-600"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleTaskStatus(task.id)
|
||||
}}
|
||||
>
|
||||
{task.status === "running" ? "进行中" : task.status === "paused" ? "已暂停" : "已完成"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsApiDocsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
查看文档
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => router.push(`/scenarios/api/${task.id}/edit`)}>
|
||||
编辑计划
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => copyTask(task.id)}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制计划
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>查看详情</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-600">删除计划</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-violet-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500">总调用次数</div>
|
||||
<div className="text-lg font-semibold text-violet-600">{task.stats.totalCalls}</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-violet-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500">成功调用</div>
|
||||
<div className="text-lg font-semibold text-violet-600">{task.stats.successCalls}</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-violet-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500">失败调用</div>
|
||||
<div className="text-lg font-semibold text-violet-600">{task.stats.errorCalls}</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-violet-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500">成功率</div>
|
||||
<div className="text-lg font-semibold text-violet-600">{task.stats.successRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="h-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={task.trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="calls" name="调用次数" stroke="#8b5cf6" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="success" name="成功次数" stroke="#22c55e" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm border-t pt-4">
|
||||
<div className="flex items-center space-x-2 text-gray-500">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>上次执行: {task.executionTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-gray-500">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>下次执行: {task.nextExecutionTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isApiDocsOpen} onOpenChange={setIsApiDocsOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>API 文档</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="overview" className="mt-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">概述</TabsTrigger>
|
||||
<TabsTrigger value="endpoints">接口列表</TabsTrigger>
|
||||
<TabsTrigger value="examples">示例代码</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="p-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">概述</h3>
|
||||
<p className="text-sm text-gray-600">本接口文档依照REST标准,请求头需要添加token作为鉴权。</p>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium">基础域名</p>
|
||||
<code className="text-sm text-violet-600">https://api.ckb.quwanzhi.com</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="endpoints" className="p-4">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">手机微信号加友接口</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline">POST</Badge> {/* Badge component used here */}
|
||||
<code className="text-sm">/api/open/task/addFriend</code>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium mb-2">请求参数</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>是否必填</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>phone</td>
|
||||
<td>string</td>
|
||||
<td>是</td>
|
||||
<td>手机或微信号</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>tags</td>
|
||||
<td>string</td>
|
||||
<td>否</td>
|
||||
<td>标签</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>taskId</td>
|
||||
<td>int</td>
|
||||
<td>是</td>
|
||||
<td>计划任务ID</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="examples" className="p-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">请求示例</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
|
||||
<pre className="text-sm">
|
||||
{JSON.stringify(
|
||||
{
|
||||
phone: "18956545898",
|
||||
taskId: 593,
|
||||
tags: "90后,女生,美女",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">返回示例</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
|
||||
<pre className="text-sm">
|
||||
{JSON.stringify(
|
||||
{
|
||||
code: 10000,
|
||||
data: null,
|
||||
message: "操作成功",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user