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:
v0
2025-07-18 13:47:12 +00:00
parent 440b310c6f
commit 2408d50cb0
316 changed files with 55785 additions and 0 deletions

103
components/AIAssistant.tsx Normal file
View File

@@ -0,0 +1,103 @@
"use client"
import { useState, useEffect } from "react"
import { usePathname } from "next/navigation"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Video, Play } from "lucide-react"
import { getPageTutorials } from "@/lib/tutorials"
import type { TutorialVideo } from "@/types/tutorial"
export function AIAssistant() {
const pathname = usePathname()
const [isOpen, setIsOpen] = useState(false)
const [tutorials, setTutorials] = useState<TutorialVideo[]>([])
const [selectedVideo, setSelectedVideo] = useState<TutorialVideo | null>(null)
useEffect(() => {
// 获取当前页面的教程视频
const pageTutorials = getPageTutorials(pathname)
setTutorials(pageTutorials)
setSelectedVideo(pageTutorials[0] || null)
}, [pathname])
const handleOpenDialog = () => {
setIsOpen(true)
}
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Button
size="icon"
className="fixed bottom-20 right-4 h-12 w-12 rounded-full shadow-lg bg-white hover:bg-gray-50 border z-50"
onClick={handleOpenDialog}
>
<Video className="h-5 w-5 text-gray-600" />
</Button>
<DialogContent className="sm:max-w-[640px] p-0">
<ScrollArea className="h-[480px]">
<div className="space-y-4">
{selectedVideo ? (
<div>
<div className="aspect-video bg-gray-900 relative">
<img
src={selectedVideo.thumbnailUrl || "/placeholder.svg"}
alt={selectedVideo.title}
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center">
<Button
size="icon"
variant="ghost"
className="w-16 h-16 rounded-full bg-white/10 hover:bg-white/20"
>
<Play className="h-8 w-8 text-white" />
</Button>
</div>
</div>
<div className="p-4">
<h3 className="text-lg font-semibold">{selectedVideo.title}</h3>
<p className="text-sm text-gray-500 mt-1">{selectedVideo.description}</p>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500"></div>
)}
{tutorials.length > 1 && (
<div className="p-4 border-t">
<h4 className="font-medium mb-4"></h4>
<div className="grid grid-cols-2 gap-4">
{tutorials
.filter((video) => video.id !== selectedVideo?.id)
.map((video) => (
<div
key={video.id}
className="cursor-pointer hover:opacity-80"
onClick={() => setSelectedVideo(video)}
>
<div className="aspect-video bg-gray-100 rounded-lg relative overflow-hidden">
<img
src={video.thumbnailUrl || "/placeholder.svg"}
alt={video.title}
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center">
<Play className="h-6 w-6 text-white" />
</div>
</div>
<h5 className="text-sm font-medium mt-2">{video.title}</h5>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

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

47
components/Charts.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client"
import { Line } from "@ant-design/plots"
interface LineChartProps {
data: { date: string; value: number }[]
xField: string
yField: string
}
export function LineChart({ data, xField, yField }: LineChartProps) {
const config = {
data,
xField,
yField,
smooth: true,
color: "#1677ff",
point: {
size: 4,
shape: "circle",
style: {
fill: "#1677ff",
stroke: "#fff",
lineWidth: 2,
},
},
tooltip: {
showMarkers: false,
},
state: {
active: {
style: {
shadowBlur: 4,
stroke: "#000",
fill: "red",
},
},
},
interactions: [
{
type: "marker-active",
},
],
}
return <Line {...config} />
}

View 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,
} as TrafficTeam,
])
}
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>
)
}

View File

@@ -0,0 +1,63 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Play, Video } from "lucide-react"
import { usePathname } from "next/navigation"
import { getPageTutorials } from "@/lib/tutorials"
export function VideoTutorialButton() {
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
const tutorials = getPageTutorials(pathname)
const handleOpenDialog = () => {
setIsOpen(true)
}
return (
<>
<Button
size="icon"
className="fixed bottom-20 right-4 h-12 w-12 rounded-full shadow-lg bg-white hover:bg-gray-50 border z-50"
onClick={handleOpenDialog}
>
<Video className="h-5 w-5 text-gray-600" />
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[640px] p-0">
<DialogHeader>
<DialogTitle className="p-4 border-b"></DialogTitle>
</DialogHeader>
<div className="p-4">
{tutorials.length > 0 ? (
<div className="space-y-4">
{tutorials.map((tutorial) => (
<div key={tutorial.id} className="flex items-center space-x-4">
<div className="w-24 h-16 bg-gray-200 rounded-lg relative overflow-hidden">
<img
src={tutorial.thumbnailUrl || "/placeholder.svg"}
alt={tutorial.title}
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center">
<Play className="w-8 h-8 text-white" />
</div>
</div>
<div className="flex-1">
<h3 className="font-medium">{tutorial.title}</h3>
<p className="text-sm text-gray-500">{tutorial.description}</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500"></div>
)}
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,117 @@
"use client"
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Search } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
interface WechatFriend {
id: string
nickname: string
wechatId: string
avatar: string
gender: "male" | "female"
customer: string
}
interface WechatFriendSelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedFriends: WechatFriend[]
onSelect: (friends: WechatFriend[]) => void
}
export function WechatFriendSelector({ open, onOpenChange, selectedFriends, onSelect }: WechatFriendSelectorProps) {
const [searchQuery, setSearchQuery] = useState("")
const [friends, setFriends] = useState<WechatFriend[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (open) {
fetchFriends()
}
}, [open])
const fetchFriends = async () => {
setLoading(true)
// 模拟从API获取好友列表
await new Promise((resolve) => setTimeout(resolve, 1000))
const mockFriends = Array.from({ length: 20 }, (_, i) => ({
id: `friend-${i}`,
nickname: `好友${i + 1}`,
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
avatar: `/placeholder.svg?height=40&width=40&text=${i + 1}`,
gender: Math.random() > 0.5 ? "male" : "female",
customer: `客户${i + 1}`,
}))
setFriends(mockFriends)
setLoading(false)
}
const filteredFriends = friends.filter(
(friend) =>
friend.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
friend.wechatId.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<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="mt-4 space-y-2 max-h-[400px] overflow-y-auto">
{loading ? (
<div className="text-center py-4">...</div>
) : filteredFriends.length === 0 ? (
<div className="text-center py-4"></div>
) : (
filteredFriends.map((friend) => (
<div key={friend.id} className="flex items-center space-x-3 p-2 hover:bg-gray-100 rounded-lg">
<Checkbox
checked={selectedFriends.some((f) => f.id === friend.id)}
onCheckedChange={(checked) => {
if (checked) {
onSelect([...selectedFriends, friend])
} else {
onSelect(selectedFriends.filter((f) => f.id !== friend.id))
}
}}
/>
<Avatar>
<AvatarImage src={friend.avatar} />
<AvatarFallback>{friend.nickname[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="font-medium">{friend.nickname}</div>
<div className="text-sm text-gray-500">
<div>{friend.wechatId}</div>
<div>{friend.customer}</div>
</div>
</div>
</div>
))
)}
</div>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={() => onOpenChange(false)}></Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,110 @@
"use client"
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Search } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
interface WechatGroup {
id: string
name: string
memberCount: number
avatar: string
owner: string
customer: string
}
interface WechatGroupSelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedGroups: WechatGroup[]
onSelect: (groups: WechatGroup[]) => void
}
export function WechatGroupSelector({ open, onOpenChange, selectedGroups, onSelect }: WechatGroupSelectorProps) {
const [searchQuery, setSearchQuery] = useState("")
const [groups, setGroups] = useState<WechatGroup[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (open) {
fetchGroups()
}
}, [open])
const fetchGroups = async () => {
setLoading(true)
// 模拟从API获取群聊列表
await new Promise((resolve) => setTimeout(resolve, 1000))
const mockGroups = Array.from({ length: 10 }, (_, i) => ({
id: `group-${i}`,
name: `群聊${i + 1}`,
memberCount: Math.floor(Math.random() * 400) + 100,
avatar: `/placeholder.svg?height=40&width=40&text=群${i + 1}`,
owner: `群主${i + 1}`,
customer: `客户${i + 1}`,
}))
setGroups(mockGroups)
setLoading(false)
}
const filteredGroups = groups.filter((group) => group.name.toLowerCase().includes(searchQuery.toLowerCase()))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<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="mt-4 space-y-2 max-h-[400px] overflow-y-auto">
{loading ? (
<div className="text-center py-4">...</div>
) : filteredGroups.length === 0 ? (
<div className="text-center py-4"></div>
) : (
filteredGroups.map((group) => (
<div key={group.id} className="flex items-center space-x-3 p-2 hover:bg-gray-100 rounded-lg">
<Checkbox
checked={selectedGroups.some((g) => g.id === group.id)}
onCheckedChange={(checked) => {
if (checked) {
onSelect([...selectedGroups, group])
} else {
onSelect(selectedGroups.filter((g) => g.id !== group.id))
}
}}
/>
<img src={group.avatar || "/placeholder.svg"} alt={group.name} className="w-10 h-10 rounded-lg" />
<div className="flex-1">
<div className="font-medium">{group.name}</div>
<div className="text-sm text-gray-500">
<div>{group.owner}</div>
<div>{group.customer}</div>
<div>{group.memberCount}</div>
</div>
</div>
</div>
))
)}
</div>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={() => onOpenChange(false)}></Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,152 @@
"use client"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/use-toast"
import { Copy, QrCode } from "lucide-react"
interface PlanSettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
planId: string
}
export function PlanSettingsDialog({ open, onOpenChange, planId }: PlanSettingsDialogProps) {
const [rewardType, setRewardType] = useState<"disabled" | "onSubmit" | "onApprove">("onSubmit")
const [rewardAmount, setRewardAmount] = useState("5.00")
const [selectedWorker, setSelectedWorker] = useState("")
// 生成订单填写链接
const orderFormUrl =
typeof window !== "undefined" ? `${window.location.origin}/orders/submit/${planId}` : `/orders/submit/${planId}`
const handleCopyLink = (url: string) => {
if (typeof navigator !== "undefined") {
navigator.clipboard.writeText(url)
toast({
title: "链接已复制",
description: "已将链接复制到剪贴板",
})
}
}
const handleSave = () => {
// 这里添加保存设置的逻辑
toast({
title: "设置已保存",
description: "获客计划设置已更新",
})
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-4">
<Label></Label>
<RadioGroup value={rewardType} onValueChange={(value: any) => setRewardType(value)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="disabled" id="disabled" />
<Label htmlFor="disabled"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="onSubmit" id="onSubmit" />
<Label htmlFor="onSubmit"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="onApprove" id="onApprove" />
<Label htmlFor="onApprove"></Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="icon"
onClick={() => setRewardAmount((prev) => (Number(prev) - 1).toFixed(2))}
>
-
</Button>
<Input
type="number"
value={rewardAmount}
onChange={(e) => setRewardAmount(e.target.value)}
className="w-24 text-center"
step="0.01"
/>
<Button
variant="outline"
size="icon"
onClick={() => setRewardAmount((prev) => (Number(prev) + 1).toFixed(2))}
>
+
</Button>
<span className="text-gray-500"></span>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex space-x-2">
<Select value={selectedWorker} onValueChange={setSelectedWorker}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
<SelectItem value="worker1"></SelectItem>
<SelectItem value="worker2"></SelectItem>
<SelectItem value="worker3"></SelectItem>
<SelectItem value="worker4"></SelectItem>
<SelectItem value="worker5"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline"></Button>
</div>
</div>
<div className="space-y-2">
<Label>Web入口</Label>
<div className="flex items-center space-x-2">
<Input value={orderFormUrl} readOnly />
<Button variant="outline" size="icon" onClick={() => handleCopyLink(orderFormUrl)}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-col items-center p-4 border rounded-md">
<QrCode className="w-32 h-32 text-gray-400 mb-2" />
<p className="text-sm text-gray-500 text-center">访</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => handleCopyLink(orderFormUrl)}>
<Copy className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

121
components/charts.tsx Normal file
View File

@@ -0,0 +1,121 @@
"use client"
import type React from "react"
import {
LineChart as RechartsLineChart,
Line,
BarChart as RechartsBarChart,
Bar,
PieChart as RechartsPieChart,
Pie,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} from "recharts"
interface ChartProps {
data: any
height?: number
}
export const LineChart: React.FC<ChartProps> = ({ data, height = 300 }) => {
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsLineChart
data={data.labels.map((label: string, index: number) => {
const dataPoint: any = { name: label }
data.datasets.forEach((dataset: any, datasetIndex: number) => {
dataPoint[dataset.label] = dataset.data[index]
})
return dataPoint
})}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
{data.datasets.map((dataset: any, index: number) => (
<Line
key={index}
type="monotone"
dataKey={dataset.label}
stroke={dataset.borderColor}
fill={dataset.backgroundColor}
activeDot={{ r: 8 }}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
)
}
export const BarChart: React.FC<ChartProps> = ({ data, height = 300 }) => {
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsBarChart
data={data.labels.map((label: string, index: number) => {
const dataPoint: any = { name: label }
data.datasets.forEach((dataset: any, datasetIndex: number) => {
dataPoint[dataset.label] = dataset.data[index]
})
return dataPoint
})}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
{data.datasets.map((dataset: any, index: number) => (
<Bar
key={index}
dataKey={dataset.label}
fill={dataset.backgroundColor || `rgba(59, 130, 246, ${0.8 - index * 0.2})`}
/>
))}
</RechartsBarChart>
</ResponsiveContainer>
)
}
export const PieChart: React.FC<ChartProps> = ({ data, height = 300 }) => {
return (
<ResponsiveContainer width="100%" height={height}>
<RechartsPieChart>
<Tooltip />
<Legend />
{data.datasets.map((dataset: any, datasetIndex: number) => (
<Pie
key={datasetIndex}
data={data.labels.map((label: string, index: number) => ({
name: label,
value: dataset.data[index],
}))}
cx="50%"
cy="50%"
outerRadius={80}
fill="#8884d8"
dataKey="value"
label
>
{data.labels.map((entry: any, index: number) => (
<Cell
key={`cell-${index}`}
fill={
dataset.backgroundColor instanceof Array
? dataset.backgroundColor[index]
: `rgba(59, 130, 246, ${0.8 - index * 0.1})`
}
/>
))}
</Pie>
))}
</RechartsPieChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,27 @@
import { Button } from "@/components/ui/button"
import { Calendar, Download, Filter } from "lucide-react"
export function DashboardHeader() {
return (
<div className="flex flex-col md:flex-row justify-between items-start md:items-center space-y-4 md:space-y-0">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm">
<Calendar className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<Filter className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,179 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { formatNumber, getGrowthClass } from "@/lib/utils"
import { ArrowDown, ArrowUp } from "lucide-react"
export function DashboardOverview() {
// 这里应该从API获取实际数据
const overviewData = {
userAcquisition: {
total: 12458,
growth: 12.5,
sources: [
{ name: "微信", value: 4523, growth: 15.2 },
{ name: "抖音", value: 3254, growth: 24.3 },
{ name: "小红书", value: 2145, growth: 8.7 },
{ name: "公众号", value: 1536, growth: -3.2 },
{ name: "官网", value: 1000, growth: 5.6 },
],
},
userRetention: {
rate: 68.5,
growth: 2.3,
periods: [
{ name: "7天", value: 85.2, growth: 1.2 },
{ name: "30天", value: 68.5, growth: 2.3 },
{ name: "90天", value: 42.3, growth: 3.5 },
{ name: "180天", value: 28.7, growth: -1.2 },
{ name: "365天", value: 15.4, growth: -2.5 },
],
},
userConversion: {
rate: 12.5,
growth: 3.2,
stages: [
{ name: "浏览", value: 100, growth: 0 },
{ name: "注册", value: 35.2, growth: 2.1 },
{ name: "首次购买", value: 12.5, growth: 3.2 },
{ name: "复购", value: 8.3, growth: 5.4 },
{ name: "会员", value: 4.2, growth: 1.8 },
],
},
}
return (
<Tabs defaultValue="acquisition" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="acquisition"></TabsTrigger>
<TabsTrigger value="retention"></TabsTrigger>
<TabsTrigger value="conversion"></TabsTrigger>
</TabsList>
<TabsContent value="acquisition">
<Card className="border shadow-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"></p>
<p className="text-2xl font-bold">{formatNumber(overviewData.userAcquisition.total)}</p>
</div>
<div className={`flex items-center ${getGrowthClass(overviewData.userAcquisition.growth)}`}>
{overviewData.userAcquisition.growth > 0 ? (
<ArrowUp className="h-4 w-4 mr-1" />
) : (
<ArrowDown className="h-4 w-4 mr-1" />
)}
<span>{Math.abs(overviewData.userAcquisition.growth)}%</span>
</div>
</div>
<div className="space-y-2">
{overviewData.userAcquisition.sources.map((source) => (
<div key={source.name} className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm">{source.name}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm">{formatNumber(source.value)}</span>
<span className={`text-xs ${getGrowthClass(source.growth)}`}>
{source.growth > 0 ? "+" : ""}
{source.growth}%
</span>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="retention">
<Card className="border shadow-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">30</p>
<p className="text-2xl font-bold">{overviewData.userRetention.rate}%</p>
</div>
<div className={`flex items-center ${getGrowthClass(overviewData.userRetention.growth)}`}>
{overviewData.userRetention.growth > 0 ? (
<ArrowUp className="h-4 w-4 mr-1" />
) : (
<ArrowDown className="h-4 w-4 mr-1" />
)}
<span>{Math.abs(overviewData.userRetention.growth)}%</span>
</div>
</div>
<div className="space-y-2">
{overviewData.userRetention.periods.map((period) => (
<div key={period.name} className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm">{period.name}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm">{period.value}%</span>
<span className={`text-xs ${getGrowthClass(period.growth)}`}>
{period.growth > 0 ? "+" : ""}
{period.growth}%
</span>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="conversion">
<Card className="border shadow-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium"></p>
<p className="text-2xl font-bold">{overviewData.userConversion.rate}%</p>
</div>
<div className={`flex items-center ${getGrowthClass(overviewData.userConversion.growth)}`}>
{overviewData.userConversion.growth > 0 ? (
<ArrowUp className="h-4 w-4 mr-1" />
) : (
<ArrowDown className="h-4 w-4 mr-1" />
)}
<span>{Math.abs(overviewData.userConversion.growth)}%</span>
</div>
</div>
<div className="space-y-2">
{overviewData.userConversion.stages.map((stage) => (
<div key={stage.name} className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm">{stage.name}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm">{stage.value}%</span>
<span className={`text-xs ${getGrowthClass(stage.growth)}`}>
{stage.growth > 0 ? "+" : ""}
{stage.growth}%
</span>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}

View File

@@ -0,0 +1,74 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { formatDate } from "@/lib/utils"
import { Activity, User, ShoppingCart, MessageSquare } from "lucide-react"
export function RecentActivities() {
// 这里应该从API获取实际数据
const activities = [
{
id: 1,
type: "user",
title: "新用户注册",
description: "张三通过微信小程序注册成为新用户",
time: new Date(2023, 6, 15, 14, 30),
},
{
id: 2,
type: "purchase",
title: "完成购买",
description: "李四购买了高级会员服务",
time: new Date(2023, 6, 15, 13, 45),
},
{
id: 3,
type: "message",
title: "客户咨询",
description: "王五咨询了产品使用问题",
time: new Date(2023, 6, 15, 11, 20),
},
{
id: 4,
type: "activity",
title: "活动参与",
description: "赵六参与了新品推广活动",
time: new Date(2023, 6, 15, 10, 15),
},
]
const getIcon = (type: string) => {
switch (type) {
case "user":
return <User className="h-4 w-4" />
case "purchase":
return <ShoppingCart className="h-4 w-4" />
case "message":
return <MessageSquare className="h-4 w-4" />
case "activity":
return <Activity className="h-4 w-4" />
default:
return <Activity className="h-4 w-4" />
}
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start space-x-3">
<div className="bg-primary/10 rounded-full p-2 text-primary">{getIcon(activity.type)}</div>
<div className="space-y-1">
<p className="text-sm font-medium">{activity.title}</p>
<p className="text-xs text-muted-foreground">{activity.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(activity.time)}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,36 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"
export function TrafficSourceAnalysis() {
// 这里应该从API获取实际数据
const data = [
{ source: "微信", value: 4000 },
{ source: "抖音", value: 3000 },
{ source: "小红书", value: 2000 },
{ source: "公众号", value: 2780 },
{ source: "官网", value: 1890 },
{ source: "其他", value: 2390 },
]
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<XAxis dataKey="source" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill="hsl(var(--primary))" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,40 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { formatNumber } from "@/lib/utils"
export function UserAssetsSummary() {
// 这里应该从API获取实际数据
const stats = {
totalUsers: 125689,
activeUsers: 78452,
highValueUsers: 12458,
conversionRate: 12.5,
}
return (
<Card className="border shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-2xl font-bold">{formatNumber(stats.totalUsers)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-2xl font-bold">{formatNumber(stats.activeUsers)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-2xl font-bold">{formatNumber(stats.highValueUsers)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-2xl font-bold">{stats.conversionRate}%</p>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,138 @@
"use client"
import { Card, CardContent } from "@/components/ui/card"
import { LineChart, BarChart } from "@/components/charts"
interface UserBehaviorAnalysisProps {
timeRange: string
}
export function UserBehaviorAnalysis({ timeRange }: UserBehaviorAnalysisProps) {
// 这里应该根据timeRange从API获取实际数据
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground">/</div>
<div className="text-2xl font-bold">12.5</div>
<div className="text-xs text-green-600"> 1.2</div>
</div>
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground">使/</div>
<div className="text-2xl font-bold">42</div>
<div className="text-xs text-green-600"> 5</div>
</div>
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground">/</div>
<div className="text-2xl font-bold">8.3</div>
<div className="text-xs text-green-600"> 0.5</div>
</div>
</div>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<LineChart
data={{
labels: ["1月", "2月", "3月", "4月", "5月", "6月", "7月"],
datasets: [
{
label: "日活跃用户",
data: [45000, 48000, 52000, 55000, 58000, 62000, 65000],
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
{
label: "周活跃用户",
data: [75000, 78000, 82000, 85000, 88000, 92000, 95000],
borderColor: "rgb(16, 185, 129)",
backgroundColor: "rgba(16, 185, 129, 0.1)",
},
{
label: "月活跃用户",
data: [95000, 98000, 102000, 105000, 108000, 112000, 115000],
borderColor: "rgb(249, 115, 22)",
backgroundColor: "rgba(249, 115, 22, 0.1)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<BarChart
data={{
labels: ["0-3点", "3-6点", "6-9点", "9-12点", "12-15点", "15-18点", "18-21点", "21-24点"],
datasets: [
{
label: "活跃用户数",
data: [8000, 4000, 25000, 58000, 42000, 38000, 75000, 62000],
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2">使</h3>
<BarChart
data={{
labels: ["iOS", "Android", "Windows", "MacOS", "其他"],
datasets: [
{
label: "用户数量",
data: [52000, 55000, 10000, 5000, 1000],
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<LineChart
data={{
labels: ["浏览", "搜索", "加入购物车", "下单", "支付", "评价", "复购"],
datasets: [
{
label: "高价值用户",
data: [100, 95, 85, 80, 78, 65, 60],
borderColor: "rgb(16, 185, 129)",
backgroundColor: "rgba(16, 185, 129, 0.1)",
},
{
label: "中价值用户",
data: [100, 85, 65, 55, 50, 35, 25],
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
{
label: "低价值用户",
data: [100, 70, 40, 25, 20, 10, 5],
borderColor: "rgb(156, 163, 175)",
backgroundColor: "rgba(156, 163, 175, 0.1)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,37 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"
export function UserGrowthChart() {
// 这里应该从API获取实际数据
const data = [
{ date: "1月", users: 4000 },
{ date: "2月", users: 5000 },
{ date: "3月", users: 6000 },
{ date: "4月", users: 7000 },
{ date: "5月", users: 9000 },
{ date: "6月", users: 12000 },
{ date: "7月", users: 15000 },
]
return (
<Card className="col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="users" stroke="hsl(var(--primary))" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,126 @@
"use client"
import { Card, CardContent } from "@/components/ui/card"
import { LineChart, BarChart } from "@/components/charts"
interface UserLifecycleAnalysisProps {
timeRange: string
}
export function UserLifecycleAnalysis({ timeRange }: UserLifecycleAnalysisProps) {
// 这里应该根据timeRange从API获取实际数据
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold">12,458</div>
<div className="text-xs text-green-600"> 8.2%</div>
</div>
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground">(30)</div>
<div className="text-2xl font-bold">68.5%</div>
<div className="text-xs text-green-600"> 2.3%</div>
</div>
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold">5.2%</div>
<div className="text-xs text-green-600"> 0.8%</div>
</div>
</div>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<BarChart
data={{
labels: ["获取阶段", "激活阶段", "留存阶段", "转化阶段", "忠诚阶段"],
datasets: [
{
label: "用户数量",
data: [25000, 18000, 12000, 8000, 5000],
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<LineChart
data={{
labels: ["1天", "7天", "14天", "30天", "60天", "90天", "180天", "365天"],
datasets: [
{
label: "留存率",
data: [95, 85, 75, 68, 55, 42, 28, 15],
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<BarChart
data={{
labels: ["产品体验差", "需求不匹配", "竞品转移", "价格因素", "服务问题", "其他原因"],
datasets: [
{
label: "流失用户比例",
data: [35, 25, 20, 10, 8, 2],
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<LineChart
data={{
labels: ["1月", "2月", "3月", "4月", "5月", "6月", "7月"],
datasets: [
{
label: "高价值用户比例",
data: [18, 19, 20, 22, 23, 24, 25],
borderColor: "rgb(16, 185, 129)",
backgroundColor: "rgba(16, 185, 129, 0.1)",
},
{
label: "中价值用户比例",
data: [42, 43, 44, 45, 45, 46, 45],
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
{
label: "低价值用户比例",
data: [40, 38, 36, 33, 32, 30, 30],
borderColor: "rgb(156, 163, 175)",
backgroundColor: "rgba(156, 163, 175, 0.1)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,138 @@
"use client"
import { Card, CardContent } from "@/components/ui/card"
import { PieChart, BarChart } from "@/components/charts"
interface UserPortraitOverviewProps {
timeRange: string
}
export function UserPortraitOverview({ timeRange }: UserPortraitOverviewProps) {
// 这里应该根据timeRange从API获取实际数据
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<PieChart
data={{
labels: ["男性", "女性", "未知"],
datasets: [
{
data: [45, 52, 3],
backgroundColor: ["rgb(59, 130, 246)", "rgb(236, 72, 153)", "rgb(156, 163, 175)"],
},
],
}}
height={180}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<PieChart
data={{
labels: ["18岁以下", "18-24岁", "25-34岁", "35-44岁", "45-54岁", "55岁以上"],
datasets: [
{
data: [5, 25, 40, 20, 7, 3],
backgroundColor: [
"rgb(59, 130, 246)",
"rgb(16, 185, 129)",
"rgb(249, 115, 22)",
"rgb(139, 92, 246)",
"rgb(236, 72, 153)",
"rgb(156, 163, 175)",
],
},
],
}}
height={180}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<PieChart
data={{
labels: ["高价值", "中价值", "低价值"],
datasets: [
{
data: [25, 45, 30],
backgroundColor: ["rgb(16, 185, 129)", "rgb(59, 130, 246)", "rgb(156, 163, 175)"],
},
],
}}
height={180}
/>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"> Top 10</h3>
<BarChart
data={{
labels: ["北京", "上海", "广东", "浙江", "江苏", "四川", "湖北", "福建", "山东", "河南"],
datasets: [
{
label: "用户数量",
data: [3200, 2800, 4500, 2100, 1900, 1600, 1400, 1200, 1100, 900],
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<BarChart
data={{
labels: ["学生", "企业员工", "自由职业", "企业管理者", "公务员", "其他"],
datasets: [
{
label: "用户数量",
data: [15000, 35000, 20000, 18000, 7000, 5000],
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-2"></h3>
<BarChart
data={{
labels: ["科技", "金融", "教育", "旅游", "健康", "美食", "时尚", "体育", "娱乐", "汽车"],
datasets: [
{
label: "用户数量",
data: [6500, 4800, 3900, 5200, 4100, 6800, 3500, 2900, 5500, 3200],
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,153 @@
"use client"
import { Card, CardContent } from "@/components/ui/card"
import { PieChart, BarChart } from "@/components/charts"
import { Badge } from "@/components/ui/badge"
interface UserTagsDistributionProps {
timeRange: string
}
export function UserTagsDistribution({ timeRange }: UserTagsDistributionProps) {
// 这里应该根据timeRange从API获取实际数据
// 模拟标签分类数据
const tagCategories = [
{ name: "行为特征", count: 45, color: "bg-blue-100 text-blue-800" },
{ name: "偏好特征", count: 38, color: "bg-green-100 text-green-800" },
{ name: "价值特征", count: 22, color: "bg-purple-100 text-purple-800" },
{ name: "生命周期", count: 15, color: "bg-yellow-100 text-yellow-800" },
{ name: "渠道来源", count: 12, color: "bg-indigo-100 text-indigo-800" },
{ name: "社交属性", count: 8, color: "bg-pink-100 text-pink-800" },
]
// 模拟热门标签数据
const hotTags = [
{ name: "高价值用户", count: 12458, category: "价值特征" },
{ name: "活跃用户", count: 45678, category: "行为特征" },
{ name: "数据分析师", count: 8765, category: "职业角色" },
{ name: "营销决策者", count: 6543, category: "职业角色" },
{ name: "技术爱好者", count: 15432, category: "偏好特征" },
{ name: "潜在客户", count: 23456, category: "价值特征" },
{ name: "流失风险", count: 5678, category: "生命周期" },
{ name: "企业客户", count: 3456, category: "客户类型" },
{ name: "新注册用户", count: 7890, category: "生命周期" },
{ name: "社交活跃", count: 12345, category: "社交属性" },
]
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-4"></h3>
<PieChart
data={{
labels: tagCategories.map((cat) => cat.name),
datasets: [
{
data: tagCategories.map((cat) => cat.count),
backgroundColor: [
"rgb(59, 130, 246)",
"rgb(16, 185, 129)",
"rgb(139, 92, 246)",
"rgb(249, 115, 22)",
"rgb(99, 102, 241)",
"rgb(236, 72, 153)",
],
},
],
}}
height={250}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-4"> Top 10</h3>
<BarChart
data={{
labels: hotTags.map((tag) => tag.name),
datasets: [
{
label: "用户数量",
data: hotTags.map((tag) => tag.count),
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{tagCategories.map((category) => (
<div key={category.name} className="space-y-2">
<div className="flex justify-between items-center">
<Badge className={category.color}>{category.name}</Badge>
<span className="text-sm text-muted-foreground">{category.count}</span>
</div>
<div className="space-y-1">
{hotTags
.filter((tag) => tag.category === category.name)
.slice(0, 3)
.map((tag) => (
<div key={tag.name} className="flex justify-between items-center text-sm">
<span>{tag.name}</span>
<span className="text-muted-foreground">{(tag.count / 1000).toFixed(1)}k用户</span>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-medium mb-4"></h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground">/</div>
<div className="text-2xl font-bold">5.8</div>
<div className="text-xs text-green-600"> 0.3</div>
</div>
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold">92.5%</div>
<div className="text-xs text-green-600"> 1.2%</div>
</div>
<div className="bg-primary/10 rounded-lg p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold">87.3%</div>
<div className="text-xs text-green-600"> 2.1%</div>
</div>
</div>
<BarChart
data={{
labels: ["0个标签", "1-3个标签", "4-6个标签", "7-9个标签", "10个以上标签"],
datasets: [
{
label: "用户数量",
data: [7500, 25000, 45000, 35000, 12500],
backgroundColor: "rgba(59, 130, 246, 0.8)",
},
],
}}
height={250}
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,36 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts"
export function UserValueDistribution() {
// 这里应该从API获取实际数据
const data = [
{ name: "高价值", value: 25, color: "hsl(var(--primary))" },
{ name: "中价值", value: 45, color: "hsl(var(--secondary))" },
{ name: "低价值", value: 30, color: "hsl(var(--muted))" },
]
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} cx="50%" cy="50%" innerRadius={40} outerRadius={80} paddingAngle={2} dataKey="value">
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,62 @@
"use client"
import type { RFMAnalysisResult, UserRFMData } from "@/types/data-analysis"
interface RFMAnalysisProps {
data: UserRFMData[]
analysisResult: RFMAnalysisResult
}
export function RFMAnalysis({ data, analysisResult }: RFMAnalysisProps) {
// 获取用户分群的颜色
const getSegmentColor = (segment: string) => {
if (segment.includes("高价值")) return "rgb(16, 185, 129)"
if (segment.includes("中价值")) return "rgb(59, 130, 246)"
if (segment.includes("低价值")) return "rgb(156, 163, 175)"
return "rgb(139, 92, 246)" // 新用户
}
// 准备饼图数据
const pieChartData = {
labels: analysisResult.segmentDistribution.map(item => item.segment),
datasets: [
{
data: analysisResult.segmentDistribution.map(item => item.count),
backgroundColor: analysisResult.segmentDistribution.map(item => getSegmentColor(item.segment)),
},
],
}
// 准备RFM分布柱状图数据
const rfmDistributionData = {
labels: ["R1", "R2", "R3", "R4", "R5", "F1", "F2", "F3", "F4", "F5", "M1", "M2", "M3", "M4", "M5"],
datasets: [
{
label: "用户数量",
data: [
// R分布
data.filter(user => user.rfmScore.recency === 1).length,
data.filter(user => user.rfmScore.recency === 2).length,
data.filter(user => user.rfmScore.recency === 3).length,
data.filter(user => user.rfmScore.recency === 4).length,
data.filter(user => user.rfmScore.recency === 5).length,
// F分布
data.filter(user => user.rfmScore.frequency === 1).length,
data.filter(user => user.rfmScore.frequency === 2).length,
data.filter(user => user.rfmScore.frequency === 3).length,
data.filter(user => user.rfmScore.frequency === 4).length,
data.filter(user => user.rfmScore.frequency === 5).length,
// M分布
data.filter(user => user.rfmScore.monetary === 1).length,
data.filter(user => user.rfmScore.monetary === 2).length,
data.filter(user => user.rfmScore.monetary === 3).length,
data.filter(user => user.rfmScore.monetary === 4).length,
data.filter(user => user.rfmScore.monetary === 5).length,
],
backgroundColor: [
// R颜色
"rgba(239, 68, 68, 0.7)", "rgba(239, 68, 68, 0.7)", "rgba(239, 68, 68, 0.7)", "rgba(239, 68, 68, 0.7)", "rgba(239, 68, 68, 0.7)",
// F颜色
"rgba(16, 185, 129, 0.7)", "rgba(16, 185, 129, 0.7)", "rgba(16, 185, 129, 0.7)", "rgba(16, 185, 129, 0.7)", "rgba(16, 185, 129, 0.7)",
// M颜色
"rgba(59, 130, 246, 0.7)", "rgba(59, 130, 246, 0.7)", "rgba(59, 130\

View File

@@ -0,0 +1,326 @@
"use client"
import { useState } from "react"
import type { UserRFMData, UserSegment, UserFilterOptions } from "@/types/data-analysis"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Search, Filter, RefreshCw, UserPlus, Download } from "lucide-react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
interface UserSelectorProps {
users: UserRFMData[]
selectedUsers: string[]
onSelectUser: (userId: string) => void
onSelectAll: (selected: boolean) => void
onFilterChange: (options: UserFilterOptions) => void
filterOptions: UserFilterOptions
}
export function UserSelector({
users,
selectedUsers,
onSelectUser,
onSelectAll,
onFilterChange,
filterOptions,
}: UserSelectorProps) {
const [searchQuery, setSearchQuery] = useState("")
const [showFilters, setShowFilters] = useState(false)
// 本地过滤用户(搜索功能)
const filteredUsers = users.filter(
(user) =>
user.userName.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.phone.includes(searchQuery) ||
user.segment.toLowerCase().includes(searchQuery.toLowerCase()),
)
// 处理分群选择
const handleSegmentChange = (segment: UserSegment, checked: boolean) => {
let newSegments = [...filterOptions.segments]
if (checked) {
newSegments.push(segment)
} else {
newSegments = newSegments.filter((s) => s !== segment)
}
onFilterChange({ ...filterOptions, segments: newSegments })
}
// 处理日期范围变更
const handleDateRangeChange = (field: "start" | "end", value: string) => {
onFilterChange({
...filterOptions,
dateRange: {
...filterOptions.dateRange,
[field]: value,
},
})
}
// 处理价值范围变更
const handleValueRangeChange = (field: "min" | "max", value: string) => {
onFilterChange({
...filterOptions,
valueRange: {
...filterOptions.valueRange,
[field]: Number.parseInt(value) || 0,
},
})
}
// 获取用户分群的颜色
const getSegmentColor = (segment: string) => {
if (segment.includes("高价值")) return "bg-green-100 text-green-800"
if (segment.includes("中价值")) return "bg-blue-100 text-blue-800"
if (segment.includes("低价值")) return "bg-gray-100 text-gray-800"
return "bg-purple-100 text-purple-800" // 新用户
}
return (
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle></CardTitle>
<div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={() => setShowFilters(!showFilters)}>
<Filter className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* 搜索栏 */}
<div className="mb-4 relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="搜索用户名、手机号、分群..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* 筛选面板 */}
{showFilters && (
<div className="mb-6 p-4 border rounded-md bg-gray-50">
<h3 className="text-sm font-medium mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 分群筛选 */}
<div>
<h4 className="text-xs font-medium mb-2"></h4>
<div className="space-y-2">
{[
"高价值活跃用户",
"高价值流失风险用户",
"高价值沉睡用户",
"中价值活跃用户",
"中价值流失风险用户",
"中价值沉睡用户",
"低价值活跃用户",
"低价值流失风险用户",
"低价值沉睡用户",
"新用户",
].map((segment) => (
<div key={segment} className="flex items-center space-x-2">
<Checkbox
id={`segment-${segment}`}
checked={filterOptions.segments.includes(segment as UserSegment)}
onCheckedChange={(checked) => handleSegmentChange(segment as UserSegment, checked as boolean)}
/>
<Label htmlFor={`segment-${segment}`} className="text-xs">
{segment}
</Label>
</div>
))}
</div>
</div>
{/* 日期范围筛选 */}
<div>
<h4 className="text-xs font-medium mb-2"></h4>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor="date-start" className="text-xs">
</Label>
<Input
id="date-start"
type="date"
value={filterOptions.dateRange.start}
onChange={(e) => handleDateRangeChange("start", e.target.value)}
className="text-xs"
/>
</div>
<div>
<Label htmlFor="date-end" className="text-xs">
</Label>
<Input
id="date-end"
type="date"
value={filterOptions.dateRange.end}
onChange={(e) => handleDateRangeChange("end", e.target.value)}
className="text-xs"
/>
</div>
</div>
</div>
</div>
{/* 价值范围筛选 */}
<div>
<h4 className="text-xs font-medium mb-2"></h4>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor="value-min" className="text-xs">
</Label>
<Input
id="value-min"
type="number"
value={filterOptions.valueRange.min}
onChange={(e) => handleValueRangeChange("min", e.target.value)}
className="text-xs"
/>
</div>
<div>
<Label htmlFor="value-max" className="text-xs">
</Label>
<Input
id="value-max"
type="number"
value={filterOptions.valueRange.max}
onChange={(e) => handleValueRangeChange("max", e.target.value)}
className="text-xs"
/>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-end mt-4">
<Button
variant="outline"
size="sm"
className="mr-2"
onClick={() =>
onFilterChange({
segments: [],
dateRange: { start: "", end: "" },
valueRange: { min: 0, max: 0 },
sources: [],
})
}
>
</Button>
<Button size="sm"></Button>
</div>
</div>
)}
{/* 用户表格 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedUsers.length === filteredUsers.length && filteredUsers.length > 0}
onCheckedChange={(checked) => onSelectAll(!!checked)}
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>RFM得分</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-4">
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.userId} className="cursor-pointer hover:bg-gray-50">
<TableCell>
<Checkbox
checked={selectedUsers.includes(user.userId)}
onCheckedChange={() => onSelectUser(user.userId)}
/>
</TableCell>
<TableCell className="font-medium">{user.userName}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>{user.lastPurchaseDate}</TableCell>
<TableCell>{user.purchaseFrequency}</TableCell>
<TableCell>¥{user.totalSpent.toLocaleString()}</TableCell>
<TableCell>
<div className="flex items-center space-x-1">
<Badge variant="outline" className="bg-red-50 text-red-700">
R{user.rfmScore.recency}
</Badge>
<Badge variant="outline" className="bg-green-50 text-green-700">
F{user.rfmScore.frequency}
</Badge>
<Badge variant="outline" className="bg-blue-50 text-blue-700">
M{user.rfmScore.monetary}
</Badge>
</div>
</TableCell>
<TableCell>
<Badge className={getSegmentColor(user.segment)}>{user.segment}</Badge>
</TableCell>
<TableCell className="font-medium">¥{user.valueEstimation.toLocaleString()}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 选择统计 */}
<div className="mt-4 flex justify-between items-center">
<div className="text-sm text-gray-500">
<span className="font-medium">{selectedUsers.length}</span> {" "}
<span className="font-medium">
¥
{users
.filter((user) => selectedUsers.includes(user.userId))
.reduce((sum, user) => sum + user.valueEstimation, 0)
.toLocaleString()}
</span>
</div>
<Button size="sm">
<UserPlus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,923 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
Brain,
Lightbulb,
BarChart,
PieChart,
Users,
Tag,
Play,
Settings,
Search,
Filter,
Copy,
Check,
Zap,
} from "lucide-react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useToast } from "@/components/ui/use-toast"
interface AIAnalysisTask {
id: string
name: string
type: string
status: "pending" | "running" | "completed" | "failed"
progress: number
createdAt: string
completedAt: string | null
dataSource: string
parameters: Record<string, any>
result: any | null
}
interface AIModel {
id: string
name: string
provider: string
type: string
capabilities: string[]
apiEndpoint: string
apiKey: string
status: "active" | "inactive"
}
export function AIAnalysisTools() {
const { toast } = useToast()
const [activeTab, setActiveTab] = useState("analysis-tasks")
const [isCreatingTask, setIsCreatingTask] = useState(false)
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const [isAddingModel, setIsAddingModel] = useState(false)
const [copiedText, setCopiedText] = useState<string | null>(null)
// 模拟AI分析任务
const [aiTasks, setAiTasks] = useState<AIAnalysisTask[]>([
{
id: "task-1",
name: "用户行为分析",
type: "behavior-analysis",
status: "completed",
progress: 100,
createdAt: "2023-07-20 10:15",
completedAt: "2023-07-20 10:25",
dataSource: "行为分析平台",
parameters: {
userSegment: "高价值用户",
timeRange: "近30天",
analysisDepth: "深度",
},
result: {
summary: "高价值用户在过去30天内主要活跃在晚间时段偏好浏览科技和旅游类内容平均会话时长较长。",
insights: [
"用户主要在晚上9点至11点活跃",
"科技类内容点击率高于平均水平58%",
"平均会话时长为8.5分钟高于普通用户3.2分钟",
"移动端访问占比87%其中iOS设备占65%",
],
recommendations: [
"在晚间时段推送个性化内容",
"增加科技和旅游类内容的推荐权重",
"针对长会话时长优化页面加载速度",
"优化iOS端用户体验",
],
},
},
{
id: "task-2",
name: "用户分群自动发现",
type: "segment-discovery",
status: "completed",
progress: 100,
createdAt: "2023-07-19 14:30",
completedAt: "2023-07-19 15:10",
dataSource: "用户数据库",
parameters: {
clusteringMethod: "K-Means",
features: ["消费行为", "活跃度", "内容偏好"],
maxClusters: 8,
},
result: {
summary: "系统自动发现了5个明显的用户分群其中包括高消费低活跃、高活跃低消费等特征明显的群体。",
clusters: [
{
name: "高消费科技爱好者",
size: 12500,
characteristics: ["高消费", "科技内容偏好", "中等活跃度"],
},
{
name: "活跃社交用户",
size: 28900,
characteristics: ["高活跃度", "社交内容偏好", "中等消费"],
},
{
name: "奢侈品偶尔购买者",
size: 8700,
characteristics: ["低活跃度", "高单次消费", "奢侈品偏好"],
},
{
name: "日常必需品购买者",
size: 45600,
characteristics: ["高频次低金额消费", "中等活跃度", "生活类内容偏好"],
},
{
name: "休眠用户",
size: 15400,
characteristics: ["极低活跃度", "低消费", "无明显内容偏好"],
},
],
},
},
{
id: "task-3",
name: "用户流失预测",
type: "churn-prediction",
status: "running",
progress: 65,
createdAt: "2023-07-21 09:45",
completedAt: null,
dataSource: "用户数据库 & 行为分析平台",
parameters: {
predictionHorizon: "30天",
modelType: "随机森林",
features: ["活跃度", "消费频率", "客服交互", "产品使用情况"],
},
result: null,
},
{
id: "task-4",
name: "营销策略生成",
type: "strategy-generation",
status: "pending",
progress: 0,
createdAt: "2023-07-21 10:30",
completedAt: null,
dataSource: "用户画像 & 行为分析",
parameters: {
targetSegment: "流失风险用户",
campaignGoal: "提高留存率",
budgetConstraint: "中等",
channelPreference: ["短信", "应用内推送", "邮件"],
},
result: null,
},
{
id: "task-5",
name: "自动标签生成",
type: "tag-generation",
status: "completed",
progress: 100,
createdAt: "2023-07-18 11:20",
completedAt: "2023-07-18 12:05",
dataSource: "用户数据库 & 行为分析平台",
parameters: {
tagCategories: ["兴趣", "消费习惯", "活跃模式"],
minConfidence: 0.7,
maxTagsPerUser: 10,
},
result: {
summary: "系统为85%的用户生成了新标签平均每用户5.3个标签。",
tagCategories: [
{
name: "兴趣标签",
count: 28,
coverage: "92%",
examples: ["科技爱好者", "旅游达人", "美食家", "运动健身"],
},
{
name: "消费习惯",
count: 15,
coverage: "88%",
examples: ["奢侈品偏好", "理性消费", "冲动购物", "价格敏感"],
},
{
name: "活跃模式",
count: 12,
coverage: "95%",
examples: ["工作时间活跃", "夜间活跃", "周末活跃", "低频高质"],
},
],
},
},
])
// 模拟AI模型
const [aiModels, setAiModels] = useState<AIModel[]>([
{
id: "model-1",
name: "GPT-4",
provider: "OpenAI",
type: "大语言模型",
capabilities: ["文本生成", "内容分析", "情感分析", "摘要生成"],
apiEndpoint: "https://api.openai.com/v1/chat/completions",
apiKey: "sk-***********",
status: "active",
},
{
id: "model-2",
name: "Claude 2",
provider: "Anthropic",
type: "大语言模型",
capabilities: ["文本生成", "内容分析", "代码生成", "问答"],
apiEndpoint: "https://api.anthropic.com/v1/complete",
apiKey: "sk-ant-***********",
status: "active",
},
{
id: "model-3",
name: "MCP SERVER AI",
provider: "内部服务",
type: "专用分析模型",
capabilities: ["用户行为分析", "分群发现", "标签生成", "预测模型"],
apiEndpoint: "https://mcp.example.com/api/analyze",
apiKey: "mcp-***********",
status: "active",
},
{
id: "model-4",
name: "DALL-E 3",
provider: "OpenAI",
type: "图像生成模型",
capabilities: ["图像生成", "图像编辑", "风格迁移"],
apiEndpoint: "https://api.openai.com/v1/images/generations",
apiKey: "sk-***********",
status: "inactive",
},
])
// 模拟分析模板
const analysisTemplates = [
{
id: "template-1",
name: "用户行为分析",
description: "分析用户的行为模式、偏好和习惯",
type: "behavior-analysis",
parameters: {
userSegment: "所有用户",
timeRange: "近30天",
analysisDepth: "标准",
},
},
{
id: "template-2",
name: "用户分群发现",
description: "自动发现数据中的用户分群",
type: "segment-discovery",
parameters: {
clusteringMethod: "K-Means",
features: ["消费行为", "活跃度", "内容偏好"],
maxClusters: 8,
},
},
{
id: "template-3",
name: "用户流失预测",
description: "预测未来可能流失的用户",
type: "churn-prediction",
parameters: {
predictionHorizon: "30天",
modelType: "随机森林",
features: ["活跃度", "消费频率", "客服交互", "产品使用情况"],
},
},
{
id: "template-4",
name: "营销策略生成",
description: "为特定用户分群生成营销策略",
type: "strategy-generation",
parameters: {
targetSegment: "所有用户",
campaignGoal: "提高转化率",
budgetConstraint: "中等",
channelPreference: ["短信", "应用内推送", "邮件"],
},
},
{
id: "template-5",
name: "自动标签生成",
description: "基于用户数据自动生成用户标签",
type: "tag-generation",
parameters: {
tagCategories: ["兴趣", "消费习惯", "活跃模式"],
minConfidence: 0.7,
maxTagsPerUser: 10,
},
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case "pending":
return <Badge className="bg-gray-100 text-gray-800"></Badge>
case "running":
return <Badge className="bg-blue-100 text-blue-800"></Badge>
case "completed":
return <Badge className="bg-green-100 text-green-800"></Badge>
case "failed":
return <Badge className="bg-red-100 text-red-800"></Badge>
case "active":
return <Badge className="bg-green-100 text-green-800"></Badge>
case "inactive":
return <Badge className="bg-gray-100 text-gray-800"></Badge>
default:
return <Badge></Badge>
}
}
const getTaskTypeIcon = (type: string) => {
switch (type) {
case "behavior-analysis":
return <Users className="h-5 w-5 text-blue-500" />
case "segment-discovery":
return <PieChart className="h-5 w-5 text-purple-500" />
case "churn-prediction":
return <BarChart className="h-5 w-5 text-orange-500" />
case "strategy-generation":
return <Lightbulb className="h-5 w-5 text-yellow-500" />
case "tag-generation":
return <Tag className="h-5 w-5 text-green-500" />
default:
return <Brain className="h-5 w-5" />
}
}
const selectedTask = selectedTaskId ? aiTasks.find((task) => task.id === selectedTaskId) : null
const handleCopyText = (text: string) => {
navigator.clipboard.writeText(text)
setCopiedText(text)
toast({
title: "已复制到剪贴板",
description: "文本已成功复制到剪贴板",
})
setTimeout(() => setCopiedText(null), 2000)
}
const toggleModelStatus = (id: string) => {
setAiModels(
aiModels.map((model) => {
if (model.id === id) {
return {
...model,
status: model.status === "active" ? "inactive" : "active",
}
}
return model
}),
)
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">AI分析工具</h2>
<div className="flex items-center gap-2">
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
AI设置
</Button>
<Button onClick={() => setIsCreatingTask(true)}>
<Brain className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="analysis-tasks"></TabsTrigger>
<TabsTrigger value="ai-models">AI模型</TabsTrigger>
<TabsTrigger value="templates"></TabsTrigger>
<TabsTrigger value="settings">AI设置</TabsTrigger>
</TabsList>
<TabsContent value="analysis-tasks" className="space-y-4">
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>AI分析任务</CardTitle>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="text" placeholder="搜索任务..." className="pl-8 w-[200px]" />
</div>
<Select defaultValue="all">
<SelectTrigger className="w-[150px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="任务状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="running"></SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{aiTasks.map((task) => (
<TableRow key={task.id}>
<TableCell>{getTaskTypeIcon(task.type)}</TableCell>
<TableCell className="font-medium">{task.name}</TableCell>
<TableCell>{task.dataSource}</TableCell>
<TableCell>{task.createdAt}</TableCell>
<TableCell>{getStatusBadge(task.status)}</TableCell>
<TableCell>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${
task.status === "completed"
? "bg-green-600"
: task.status === "running"
? "bg-blue-600"
: task.status === "failed"
? "bg-red-600"
: "bg-gray-400"
}`}
style={{ width: `${task.progress}%` }}
></div>
</div>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => setSelectedTaskId(task.id)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ai-models" className="space-y-4">
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>AI模型管理</CardTitle>
<Button onClick={() => setIsAddingModel(true)}></Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>API端点</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{aiModels.map((model) => (
<TableRow key={model.id}>
<TableCell className="font-medium">{model.name}</TableCell>
<TableCell>{model.provider}</TableCell>
<TableCell>{model.type}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{model.capabilities.slice(0, 2).map((capability, index) => (
<Badge key={index} variant="outline">
{capability}
</Badge>
))}
{model.capabilities.length > 2 && (
<Badge variant="outline">+{model.capabilities.length - 2}</Badge>
)}
</div>
</TableCell>
<TableCell className="max-w-[200px] truncate">{model.apiEndpoint}</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Switch
checked={model.status === "active"}
onCheckedChange={() => toggleModelStatus(model.id)}
aria-label="Toggle model"
/>
<span>{getStatusBadge(model.status)}</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm">
</Button>
<Button variant="ghost" size="sm">
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>MCP SERVER配置</CardTitle>
<CardDescription>MCP SERVER AI工具的分析参数</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mcp-server-url"></Label>
<Input id="mcp-server-url" defaultValue="https://mcp.example.com/api" />
</div>
<div className="space-y-2">
<Label htmlFor="mcp-api-key">API密钥</Label>
<Input id="mcp-api-key" type="password" defaultValue="••••••••••••••••" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="mcp-model">AI模型选择</Label>
<Select defaultValue="gpt-4">
<SelectTrigger id="mcp-model">
<SelectValue placeholder="选择AI模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="gpt-4">GPT-4</SelectItem>
<SelectItem value="gpt-3.5-turbo">GPT-3.5 Turbo</SelectItem>
<SelectItem value="claude-2">Claude 2</SelectItem>
<SelectItem value="llama-2">Llama 2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Switch id="behavior-analysis" defaultChecked />
<Label htmlFor="behavior-analysis"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="segment-discovery" defaultChecked />
<Label htmlFor="segment-discovery"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="churn-prediction" defaultChecked />
<Label htmlFor="churn-prediction"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="tag-generation" defaultChecked />
<Label htmlFor="tag-generation"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="strategy-generation" defaultChecked />
<Label htmlFor="strategy-generation"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="content-generation" defaultChecked />
<Label htmlFor="content-generation"></Label>
</div>
</div>
</div>
<div className="flex justify-end">
<Button>MCP配置</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="templates" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{analysisTemplates.map((template) => (
<Card key={template.id} className="border shadow-sm">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getTaskTypeIcon(template.type)}
<CardTitle className="text-lg">{template.name}</CardTitle>
</div>
<Button variant="ghost" size="icon">
<Settings className="h-4 w-4" />
</Button>
</div>
<CardDescription>{template.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<h4 className="text-sm font-medium"></h4>
<div className="text-sm">
{Object.entries(template.parameters).map(([key, value]) => (
<div key={key} className="flex justify-between py-1 border-b border-dashed border-gray-200">
<span className="text-muted-foreground">{key}:</span>
<span className="font-medium">
{Array.isArray(value) ? value.join(", ") : value.toString()}
</span>
</div>
))}
</div>
</div>
<div className="mt-4 flex justify-end">
<Button onClick={() => setIsCreatingTask(true)}>
<Play className="mr-2 h-4 w-4" />
使
</Button>
</div>
</CardContent>
</Card>
))}
<Card className="border shadow-sm border-dashed">
<CardContent className="p-6 flex flex-col items-center justify-center h-full">
<div className="rounded-full bg-muted p-3 mb-4">
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2"></h3>
<p className="text-sm text-muted-foreground text-center mb-4">
</p>
<Button variant="outline"></Button>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>AI分析设置</CardTitle>
<CardDescription>AI分析的全局设置</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="enable-ai">AI分析</Label>
<Switch id="enable-ai" defaultChecked />
</div>
<p className="text-sm text-muted-foreground">AI分析功能将暂停</p>
</div>
<div className="space-y-2">
<Label htmlFor="default-model">AI模型</Label>
<Select defaultValue="model-3">
<SelectTrigger id="default-model">
<SelectValue placeholder="选择默认模型" />
</SelectTrigger>
<SelectContent>
{aiModels
.filter((model) => model.status === "active")
.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name} ({model.provider})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="analysis-concurrency"></Label>
<Select defaultValue="3">
<SelectTrigger id="analysis-concurrency">
<SelectValue placeholder="选择并发任务数" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
AI分析任务数量
</p>
</div>
<Separator />
<div className="space-y-2">
<Label>访</Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Checkbox id="access-user-db" defaultChecked />
<Label htmlFor="access-user-db"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="access-transaction" defaultChecked />
<Label htmlFor="access-transaction"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="access-behavior" defaultChecked />
<Label htmlFor="access-behavior"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="access-user-value" defaultChecked />
<Label htmlFor="access-user-value"></Label>
</div>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Checkbox id="anonymize-data" defaultChecked />
<Label htmlFor="anonymize-data"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="cache-results" defaultChecked />
<Label htmlFor="cache-results"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="auto-update" defaultChecked />
<Label htmlFor="auto-update"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="save-history" defaultChecked />
<Label htmlFor="save-history"></Label>
</div>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Checkbox id="notify-completion" defaultChecked />
<Label htmlFor="notify-completion"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="notify-error" defaultChecked />
<Label htmlFor="notify-error"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="notify-insight" defaultChecked />
<Label htmlFor="notify-insight"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="notify-email" defaultChecked />
<Label htmlFor="notify-email"></Label>
</div>
</div>
</div>
<div className="flex justify-end">
<Button></Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 任务详情对话框 */}
<Dialog open={!!selectedTaskId} onOpenChange={(open) => !open && setSelectedTaskId(null)}>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>AI分析任务的详细信息和结果</DialogDescription>
</DialogHeader>
{selectedTask && (
<div className="space-y-4 py-4">
<div className="flex items-center gap-4">
{getTaskTypeIcon(selectedTask.type)}
<div>
<h3 className="font-medium text-lg">{selectedTask.name}</h3>
<p className="text-sm text-muted-foreground">
{selectedTask.dataSource} | {selectedTask.createdAt}
</p>
</div>
<div className="ml-auto">{getStatusBadge(selectedTask.status)}</div>
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-medium"></h4>
<div className="grid grid-cols-2 gap-2">
{Object.entries(selectedTask.parameters).map(([key, value]) => (
<div key={key} className="flex justify-between py-1 border-b border-dashed border-gray-200">
<span className="text-sm text-muted-foreground">{key}:</span>
<span className="text-sm font-medium">
{Array.isArray(value) ? value.join(", ") : value.toString()}
</span>
</div>
))}
</div>
</div>
{selectedTask.status === "running" && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="text-sm font-medium"></h4>
<span className="text-sm font-medium">{selectedTask.progress}%</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: `${selectedTask.progress}%` }}
></div>
</div>
<p className="text-xs text-muted-foreground">
...: {Math.round((100 - selectedTask.progress) / 10)}
</p>
</div>
)}
{selectedTask.result && (
<div className="space-y-4">
<Separator />
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="text-sm font-medium"></h4>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyText(selectedTask.result.summary)}
className="h-7"
>
{copiedText === selectedTask.result.summary ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
</Button>
</div>
<div className="p-3 bg-muted rounded-md text-sm">{selectedTask.result.summary}</div>
</div>
{selectedTask.result.insights && (
<div className="space-y-2">
<h4 className="text-sm font-medium"></h4>
<div className="space-y-1">
{selectedTask.result.insights.map((insight: string, index: number) => (
<div key={index} className="flex items-start gap-2">
<Lightbulb className="h-4 w-4 text-yellow-500 mt-0.5" />
<p className="text-sm">{insight}</p>
</div>
))}
</div>
</div>
)}
{selectedTask.result.recommendations && (
<div className="space-y-2">
<h4 className="text-sm font-medium"></h4>
<div className="space-y-1">
{selectedTask.result.recommendations.map((recommendation: string, index: number) => (
<div key={index} className="flex items-start gap-2">
<Zap className="h-4 w-4 text-blue-500 mt-0.5" />
<p className="text-sm">{recommendation}</p>
</div>
))}
</div>
</div>
)}
{selectedTask.result.clusters && (
<div className="space-y-2">
<h4 className="text-sm font-medium"></h4>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTask.result.clusters.map((cluster: any, index: number) => (
<TableRow key={

View File

@@ -0,0 +1,664 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Copy, Check, Code, FileJson, Play } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
interface ApiEndpoint {
id: string
name: string
method: "GET" | "POST" | "PUT" | "DELETE"
path: string
description: string
parameters: {
name: string
type: string
required: boolean
description: string
}[]
requestBody?: {
type: string
example: string
}
responses: {
code: string
description: string
example: string
}[]
authentication: "API Key" | "OAuth 2.0" | "None"
}
export function ApiDocumentation() {
const { toast } = useToast()
const [activeTab, setActiveTab] = useState("user-data")
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
// 模拟API端点数据
const apiEndpoints: Record<string, ApiEndpoint[]> = {
"user-data": [
{
id: "get-users",
name: "获取用户列表",
method: "GET",
path: "/api/users",
description: "获取系统中的用户列表,支持分页、筛选和排序",
parameters: [
{
name: "page",
type: "number",
required: false,
description: "页码默认为1",
},
{
name: "limit",
type: "number",
required: false,
description: "每页数量默认为20最大为100",
},
{
name: "sort",
type: "string",
required: false,
description: "排序字段例如name,-createdAt-表示降序)",
},
{
name: "filter",
type: "string",
required: false,
description: "筛选条件例如status=active",
},
],
responses: [
{
code: "200",
description: "成功",
example: `{
"data": [
{
"id": "user_123",
"name": "张三",
"phoneNumber": "138****1234",
"registrationDate": "2023-01-15T08:30:00Z",
"lastActiveTime": "2023-07-20T14:25:30Z",
"tags": ["高价值", "活跃用户"]
},
// 更多用户...
],
"pagination": {
"page": 1,
"limit": 20,
"total": 156,
"pages": 8
}
}`,
},
{
code: "400",
description: "请求参数错误",
example: `{
"error": "Bad Request",
"message": "Invalid filter format",
"code": "INVALID_FILTER"
}`,
},
{
code: "401",
description: "未授权",
example: `{
"error": "Unauthorized",
"message": "API key is invalid or expired",
"code": "INVALID_API_KEY"
}`,
},
],
authentication: "API Key",
},
{
id: "get-user",
name: "获取用户详情",
method: "GET",
path: "/api/users/{id}",
description: "根据用户ID获取用户详细信息",
parameters: [
{
name: "id",
type: "string",
required: true,
description: "用户ID",
},
],
responses: [
{
code: "200",
description: "成功",
example: `{
"id": "user_123",
"name": "张三",
"phoneNumber": "138****1234",
"identityNumber": "310******1234",
"email": "zhangsan@example.com",
"registrationDate": "2023-01-15T08:30:00Z",
"lastActiveTime": "2023-07-20T14:25:30Z",
"tags": ["高价值", "活跃用户"],
"devices": [
{
"id": "device_456",
"type": "mobile",
"model": "iPhone 13",
"imei": "123456789012345",
"lastActiveTime": "2023-07-20T14:25:30Z"
}
],
"userValue": {
"rfm": {
"recency": 5,
"frequency": 4,
"monetary": 5,
"score": 4.7
},
"lifetimeValue": 12500
}
}`,
},
{
code: "404",
description: "用户不存在",
example: `{
"error": "Not Found",
"message": "User with ID user_999 not found",
"code": "USER_NOT_FOUND"
}`,
},
],
authentication: "API Key",
},
],
"user-portrait": [
{
id: "get-user-portrait",
name: "获取用户画像",
method: "GET",
path: "/api/user-portrait/{id}",
description: "根据用户ID获取用户画像数据",
parameters: [
{
name: "id",
type: "string",
required: true,
description: "用户ID",
},
],
responses: [
{
code: "200",
description: "成功",
example: `{
"userId": "user_123",
"basicInfo": {
"name": "张三",
"age": 28,
"gender": "male",
"location": "上海市"
},
"behaviorTags": ["夜间活跃", "周末购物", "高频App使用"],
"interestTags": ["科技", "旅游", "美食"],
"consumptionPattern": {
"averageOrderValue": 320,
"purchaseFrequency": "每周2-3次",
"preferredCategories": ["电子产品", "服装"]
},
"rfmAnalysis": {
"recency": 5,
"frequency": 4,
"monetary": 5,
"score": 4.7,
"segment": "高价值用户"
},
"deviceInfo": [
{
"type": "mobile",
"model": "iPhone 13",
"osVersion": "iOS 16.5",
"usageFrequency": "高"
}
],
"channelPreference": ["App", "微信小程序"],
"riskScore": 0.2
}`,
},
],
authentication: "API Key",
},
],
"ai-analysis": [
{
id: "analyze-user-behavior",
name: "用户行为分析",
method: "POST",
path: "/api/ai/analyze-behavior",
description: "使用AI分析用户行为数据生成洞察报告",
parameters: [],
requestBody: {
type: "application/json",
example: `{
"userId": "user_123",
"timeRange": {
"start": "2023-01-01T00:00:00Z",
"end": "2023-07-20T23:59:59Z"
},
"analysisDepth": "deep",
"includeTags": true,
"includeRecommendations": true
}`,
},
responses: [
{
code: "200",
description: "成功",
example: `{
"userId": "user_123",
"analysisTime": "2023-07-21T10:15:30Z",
"timeRange": {
"start": "2023-01-01T00:00:00Z",
"end": "2023-07-20T23:59:59Z"
},
"behaviorPatterns": [
{
"pattern": "夜间活跃",
"confidence": 0.92,
"description": "用户主要在晚上9点至凌晨1点活跃",
"supportingData": {
"activeTimeDistribution": {
"morning": 0.15,
"afternoon": 0.25,
"evening": 0.60
}
}
},
// 更多行为模式...
],
"insights": [
{
"type": "preference",
"description": "用户对科技类产品有强烈兴趣,尤其是智能家居设备",
"confidence": 0.85
},
// 更多洞察...
],
"recommendations": [
{
"type": "marketing",
"description": "建议在晚间时段推送智能家居相关促销信息",
"expectedImpact": "高"
},
// 更多建议...
]
}`,
},
],
authentication: "API Key",
},
],
}
const handleCopyCode = (code: string, endpointId: string) => {
navigator.clipboard.writeText(code)
setCopiedEndpoint(endpointId)
toast({
title: "已复制到剪贴板",
description: "代码已成功复制到剪贴板",
})
setTimeout(() => setCopiedEndpoint(null), 2000)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>API文档</CardTitle>
<CardDescription>API接口文档和使用说明</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid grid-cols-3 w-full max-w-md">
<TabsTrigger value="user-data"></TabsTrigger>
<TabsTrigger value="user-portrait"></TabsTrigger>
<TabsTrigger value="ai-analysis">AI分析</TabsTrigger>
</TabsList>
{Object.entries(apiEndpoints).map(([category, endpoints]) => (
<TabsContent key={category} value={category} className="space-y-4">
{endpoints.map((endpoint) => (
<Card key={endpoint.id} className="border shadow-sm">
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-lg">{endpoint.name}</CardTitle>
<CardDescription>{endpoint.description}</CardDescription>
</div>
<Badge
className={
endpoint.method === "GET"
? "bg-blue-100 text-blue-800"
: endpoint.method === "POST"
? "bg-green-100 text-green-800"
: endpoint.method === "PUT"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}
>
{endpoint.method}
</Badge>
</div>
</CardHeader>
<CardContent className="pb-2">
<div className="flex items-center gap-2 mb-4">
<code className="bg-muted px-2 py-1 rounded text-sm font-mono">{endpoint.path}</code>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopyCode(endpoint.path, `path-${endpoint.id}`)}
>
{copiedEndpoint === `path-${endpoint.id}` ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<Accordion type="single" collapsible className="w-full">
{endpoint.parameters.length > 0 && (
<AccordionItem value="parameters">
<AccordionTrigger></AccordionTrigger>
<AccordionContent>
<div className="rounded-md border">
<table className="min-w-full divide-y divide-border">
<thead>
<tr className="bg-muted/50">
<th className="px-4 py-2 text-left text-sm font-medium"></th>
<th className="px-4 py-2 text-left text-sm font-medium"></th>
<th className="px-4 py-2 text-left text-sm font-medium"></th>
<th className="px-4 py-2 text-left text-sm font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{endpoint.parameters.map((param, index) => (
<tr key={index}>
<td className="px-4 py-2 text-sm font-mono">{param.name}</td>
<td className="px-4 py-2 text-sm">{param.type}</td>
<td className="px-4 py-2 text-sm">
{param.required ? (
<Badge className="bg-red-100 text-red-800"></Badge>
) : (
<Badge className="bg-gray-100 text-gray-800"></Badge>
)}
</td>
<td className="px-4 py-2 text-sm">{param.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</AccordionContent>
</AccordionItem>
)}
{endpoint.requestBody && (
<AccordionItem value="request-body">
<AccordionTrigger></AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge className="bg-purple-100 text-purple-800">{endpoint.requestBody.type}</Badge>
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => handleCopyCode(endpoint.requestBody!.example, `req-${endpoint.id}`)}
>
{copiedEndpoint === `req-${endpoint.id}` ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
</Button>
</div>
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
{endpoint.requestBody.example}
</pre>
</div>
</AccordionContent>
</AccordionItem>
)}
<AccordionItem value="responses">
<AccordionTrigger></AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
{endpoint.responses.map((response, index) => (
<div key={index} className="space-y-2">
<div className="flex items-center gap-2">
<Badge
className={
response.code.startsWith("2")
? "bg-green-100 text-green-800"
: response.code.startsWith("4")
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
}
>
{response.code}
</Badge>
<span className="text-sm font-medium">{response.description}</span>
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => handleCopyCode(response.example, `res-${endpoint.id}-${index}`)}
>
{copiedEndpoint === `res-${endpoint.id}-${index}` ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
</Button>
</div>
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
{response.example}
</pre>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="authentication">
<AccordionTrigger></AccordionTrigger>
<AccordionContent>
<div className="p-2">
<Badge className="bg-blue-100 text-blue-800">{endpoint.authentication}</Badge>
{endpoint.authentication === "API Key" && (
<div className="mt-2 text-sm">
<p></p>
<code className="bg-muted px-2 py-1 rounded text-sm font-mono mt-1 block">
X-API-Key: your_api_key_here
</code>
</div>
)}
{endpoint.authentication === "OAuth 2.0" && (
<div className="mt-2 text-sm">
<p></p>
<code className="bg-muted px-2 py-1 rounded text-sm font-mono mt-1 block">
Authorization: Bearer your_access_token_here
</code>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="code-examples">
<AccordionTrigger></AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-2">
<Badge className="bg-blue-100 text-blue-800">JavaScript</Badge>
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => handleCopyCode(jsExample, `js-${endpoint.id}`)}
>
{copiedEndpoint === `js-${endpoint.id}` ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
</Button>
</div>
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
{jsExample}
</pre>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<Badge className="bg-green-100 text-green-800">Python</Badge>
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => handleCopyCode(pythonExample, `py-${endpoint.id}`)}
>
{copiedEndpoint === `py-${endpoint.id}` ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
</Button>
</div>
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
{pythonExample}
</pre>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
))}
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API使用指南</CardTitle>
<CardDescription>使API</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<h3 className="text-lg font-medium">1. API密钥</h3>
<p className="text-sm text-muted-foreground">
使API之前API密钥API密钥
</p>
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">2. </h3>
<p className="text-sm text-muted-foreground">API请求都需要进行认证API密钥</p>
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
X-API-Key: your_api_key_here
</pre>
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">3. </h3>
<p className="text-sm text-muted-foreground">API支持JSON格式的请求和响应</p>
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
Content-Type: application/json Accept: application/json
</pre>
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">4. </h3>
<p className="text-sm text-muted-foreground">
API使用标准的HTTP状态码表示请求的结果
</p>
<pre className="bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
{`{
"error": "错误类型",
"message": "错误详细信息",
"code": "错误代码"
}`}
</pre>
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">5. </h3>
<p className="text-sm text-muted-foreground">
API有速率限制100429
</p>
</div>
<div className="flex justify-between items-center mt-4">
<Button variant="outline" className="gap-2">
<FileJson className="h-4 w-4" />
OpenAPI规范
</Button>
<Button variant="outline" className="gap-2">
<Code className="h-4 w-4" />
SDK
</Button>
<Button className="gap-2">
<Play className="h-4 w-4" />
API测试工具
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
// 示例代码
const jsExample = `// 使用fetch API调用
const apiKey = 'your_api_key_here';
fetch('https://api.example.com/api/users', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey
}
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));`
const pythonExample = `# 使用requests库调用
import requests
api_key = 'your_api_key_here'
headers = {
'Content-Type': 'application/json',
'X-API-Key': api_key
}
response = requests.get('https://api.example.com/api/users', headers=headers)
data = response.json()
print(data)`

View File

@@ -0,0 +1,698 @@
"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 { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Plus, Trash2, Clock, RefreshCw, Database, Server, FileText, Settings, HelpCircle } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
interface CollectionTask {
id: string
name: string
source: string
type: string
schedule: string
lastRun: string | null
nextRun: string | null
status: "active" | "paused" | "error" | "completed"
recordsCollected: number
}
export function DataCollectionSettings() {
const [activeTab, setActiveTab] = useState("tasks")
const [isAddingTask, setIsAddingTask] = useState(false)
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
// 模拟数据采集任务
const [collectionTasks, setCollectionTasks] = useState<CollectionTask[]>([
{
id: "1",
name: "用户基本信息采集",
source: "CRM系统",
type: "数据库",
schedule: "每日 00:00",
lastRun: "2023-07-20 00:00",
nextRun: "2023-07-21 00:00",
status: "active",
recordsCollected: 125678,
},
{
id: "2",
name: "用户行为数据采集",
source: "行为分析平台",
type: "API",
schedule: "每小时",
lastRun: "2023-07-20 15:00",
nextRun: "2023-07-20 16:00",
status: "active",
recordsCollected: 458921,
},
{
id: "3",
name: "社交媒体数据采集",
source: "MCP SERVER",
type: "AI工具",
schedule: "每日 02:00",
lastRun: "2023-07-20 02:00",
nextRun: "2023-07-21 02:00",
status: "active",
recordsCollected: 87452,
},
{
id: "4",
name: "历史交易数据导入",
source: "交易系统",
type: "文件",
schedule: "手动",
lastRun: "2023-07-15 10:30",
nextRun: null,
status: "completed",
recordsCollected: 254789,
},
{
id: "5",
name: "设备信息采集",
source: "设备管理系统",
type: "API",
schedule: "每日 03:00",
lastRun: "2023-07-20 03:00",
nextRun: "2023-07-21 03:00",
status: "error",
recordsCollected: 12547,
},
])
// 模拟数据源
const dataSources = [
{ id: "crm", name: "CRM系统", type: "数据库" },
{ id: "behavior", name: "行为分析平台", type: "API" },
{ id: "mcp", name: "MCP SERVER", type: "AI工具" },
{ id: "transaction", name: "交易系统", type: "文件" },
{ id: "device", name: "设备管理系统", type: "API" },
{ id: "social", name: "社交媒体平台", type: "API" },
{ id: "ecommerce", name: "电商平台", type: "API" },
]
// 模拟采集类型
const collectionTypes = [
{ id: "full", name: "全量采集", description: "采集所有数据" },
{ id: "incremental", name: "增量采集", description: "仅采集新增或变更的数据" },
{ id: "scheduled", name: "定时采集", description: "按照设定的时间计划采集数据" },
{ id: "event", name: "事件触发采集", description: "在特定事件发生时采集数据" },
{ id: "manual", name: "手动采集", description: "手动触发数据采集" },
]
const getStatusBadge = (status: CollectionTask["status"]) => {
switch (status) {
case "active":
return <Badge className="bg-green-100 text-green-800"></Badge>
case "paused":
return <Badge className="bg-yellow-100 text-yellow-800"></Badge>
case "error":
return <Badge className="bg-red-100 text-red-800"></Badge>
case "completed":
return <Badge className="bg-blue-100 text-blue-800"></Badge>
}
}
const getSourceIcon = (type: string) => {
switch (type) {
case "数据库":
return <Database className="h-4 w-4 text-blue-500" />
case "API":
return <Server className="h-4 w-4 text-purple-500" />
case "文件":
return <FileText className="h-4 w-4 text-green-500" />
case "AI工具":
return <Settings className="h-4 w-4 text-orange-500" />
default:
return <Database className="h-4 w-4" />
}
}
const addTask = (task: Omit<CollectionTask, "id" | "lastRun" | "recordsCollected">) => {
const newTask: CollectionTask = {
id: `task-${Date.now()}`,
...task,
lastRun: null,
recordsCollected: 0,
}
setCollectionTasks([...collectionTasks, newTask])
setIsAddingTask(false)
}
const deleteTask = (id: string) => {
setCollectionTasks(collectionTasks.filter((task) => task.id !== id))
}
const toggleTaskStatus = (id: string) => {
setCollectionTasks(
collectionTasks.map((task) => {
if (task.id === id) {
return {
...task,
status: task.status === "active" ? "paused" : "active",
}
}
return task
}),
)
}
const selectedTask = selectedTaskId ? collectionTasks.find((task) => task.id === selectedTaskId) : null
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="tasks"></TabsTrigger>
<TabsTrigger value="sources"></TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Badge className="bg-blue-100 text-blue-800"> {collectionTasks.length} </Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<HelpCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button onClick={() => setIsAddingTask(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-md border">
<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>
{collectionTasks.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-medium">{task.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{getSourceIcon(task.type)}
<span>{task.source}</span>
</div>
</TableCell>
<TableCell>{task.schedule}</TableCell>
<TableCell>{task.lastRun || "从未运行"}</TableCell>
<TableCell>{task.nextRun || "无计划"}</TableCell>
<TableCell>{getStatusBadge(task.status)}</TableCell>
<TableCell>{task.recordsCollected.toLocaleString()}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => toggleTaskStatus(task.id)}
disabled={task.status === "completed"}
>
{task.status === "active" ? (
<Clock className="h-4 w-4 text-yellow-500" />
) : (
<RefreshCw className="h-4 w-4 text-green-500" />
)}
</Button>
<Button variant="ghost" size="icon" onClick={() => setSelectedTaskId(task.id)}>
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => deleteTask(task.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="sources" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium"></h3>
<Button variant="outline"></Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{dataSources.map((source) => (
<Card key={source.id} className="border shadow-sm">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getSourceIcon(source.type)}
<div>
<h4 className="font-medium">{source.name}</h4>
<p className="text-sm text-muted-foreground">{source.type}</p>
</div>
</div>
<Button variant="ghost" size="icon">
<Settings className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<Card className="mt-4">
<CardHeader>
<CardTitle>MCP SERVER </CardTitle>
<CardDescription>MCP SERVER AI工具的采集参数</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mcp-server-url"></Label>
<Input id="mcp-server-url" defaultValue="https://mcp.example.com/api" />
</div>
<div className="space-y-2">
<Label htmlFor="mcp-api-key">API密钥</Label>
<Input id="mcp-api-key" type="password" defaultValue="••••••••••••••••" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="mcp-model">AI模型选择</Label>
<Select defaultValue="gpt-4">
<SelectTrigger id="mcp-model">
<SelectValue placeholder="选择AI模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="gpt-4">GPT-4</SelectItem>
<SelectItem value="gpt-3.5-turbo">GPT-3.5 Turbo</SelectItem>
<SelectItem value="claude-2">Claude 2</SelectItem>
<SelectItem value="llama-2">Llama 2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="mcp-auto-process"></Label>
<Switch id="mcp-auto-process" defaultChecked />
</div>
<p className="text-sm text-muted-foreground">
使AI处理采集的数据
</p>
</div>
<Separator />
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Switch id="collect-profile" defaultChecked />
<Label htmlFor="collect-profile"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="collect-posts" defaultChecked />
<Label htmlFor="collect-posts"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="collect-interactions" defaultChecked />
<Label htmlFor="collect-interactions"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="collect-followers" defaultChecked />
<Label htmlFor="collect-followers"></Label>
</div>
</div>
</div>
<div className="flex justify-end">
<Button>MCP配置</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="enable-collection"></Label>
<Switch id="enable-collection" defaultChecked />
</div>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="space-y-2">
<Label htmlFor="collection-threads">线</Label>
<Select defaultValue="5">
<SelectTrigger id="collection-threads">
<SelectValue placeholder="选择并发线程数" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="retry-attempts"></Label>
<Select defaultValue="3">
<SelectTrigger id="retry-attempts">
<SelectValue placeholder="选择重试次数" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="1">1</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="retry-delay"></Label>
<Input id="retry-delay" type="number" defaultValue="60" />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="data-deduplication"></Label>
<Switch id="data-deduplication" defaultChecked />
</div>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="data-validation"></Label>
<Switch id="data-validation" defaultChecked />
</div>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="notification"></Label>
<Switch id="notification" defaultChecked />
</div>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="flex justify-end">
<Button></Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* 添加采集任务对话框 */}
<Dialog open={isAddingTask} onOpenChange={setIsAddingTask}>
<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="task-name" className="text-right">
</Label>
<Input id="task-name" className="col-span-3" placeholder="例如:用户基本信息采集" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="data-source" className="text-right">
</Label>
<Select>
<SelectTrigger id="data-source" className="col-span-3">
<SelectValue placeholder="选择数据源" />
</SelectTrigger>
<SelectContent>
{dataSources.map((source) => (
<SelectItem key={source.id} value={source.id}>
{source.name} ({source.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="collection-type" className="text-right">
</Label>
<Select>
<SelectTrigger id="collection-type" className="col-span-3">
<SelectValue placeholder="选择采集类型" />
</SelectTrigger>
<SelectContent>
{collectionTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="schedule" className="text-right">
</Label>
<Select>
<SelectTrigger id="schedule" className="col-span-3">
<SelectValue placeholder="选择采集频率" />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly"></SelectItem>
<SelectItem value="daily"></SelectItem>
<SelectItem value="weekly"></SelectItem>
<SelectItem value="monthly"></SelectItem>
<SelectItem value="manual"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="time" className="text-right">
</Label>
<Input id="time" type="time" className="col-span-3" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="text-right">
<Label htmlFor="auto-process"></Label>
</div>
<div className="flex items-center space-x-2 col-span-3">
<Switch id="auto-process" />
<Label htmlFor="auto-process"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddingTask(false)}>
</Button>
<Button
onClick={() =>
addTask({
name: "新建采集任务",
source: "CRM系统",
type: "数据库",
schedule: "每日 00:00",
nextRun: "2023-07-21 00:00",
status: "active",
})
}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 任务详情对话框 */}
<Dialog open={!!selectedTaskId} onOpenChange={(open) => !open && setSelectedTaskId(null)}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{selectedTask && (
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedTask.name}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedTask.source}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedTask.schedule}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<div>{getStatusBadge(selectedTask.status)}</div>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedTask.lastRun || "从未运行"}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedTask.nextRun || "无计划"}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedTask.recordsCollected.toLocaleString()}</p>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label></Label>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>ID</TableCell>
<TableCell></TableCell>
<TableCell>
<Switch defaultChecked />
</TableCell>
</TableRow>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell>
<Switch defaultChecked />
</TableCell>
</TableRow>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell>
<Switch defaultChecked />
</TableCell>
</TableRow>
<TableRow>
<TableCell>IMEI</TableCell>
<TableCell></TableCell>
<TableCell>
<Switch defaultChecked />
</TableCell>
</TableRow>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell>
<Switch defaultChecked />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Switch defaultChecked />
<Label></Label>
</div>
<div className="flex items-center space-x-2">
<Switch defaultChecked />
<Label></Label>
</div>
<div className="flex items-center space-x-2">
<Switch defaultChecked />
<Label></Label>
</div>
<div className="flex items-center space-x-2">
<Switch defaultChecked />
<Label></Label>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedTaskId(null)}>
</Button>
<Button variant="outline" onClick={() => {}}>
</Button>
<Button onClick={() => setSelectedTaskId(null)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,950 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Search, Filter, Download, RefreshCw, Play, ArrowRight, Settings } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
// 模拟图表组件
const NetworkGraph = ({ data, title }: { data: any; title: string }) => (
<div className="w-full h-96 bg-muted/30 rounded-md flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{title}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)
const ScatterPlot = ({ data, title }: { data: any; title: string }) => (
<div className="w-full h-64 bg-muted/30 rounded-md flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{title}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)
const HeatMap = ({ data, title }: { data: any; title: string }) => (
<div className="w-full h-64 bg-muted/30 rounded-md flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{title}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)
interface CorrelationResult {
id: string
field1: string
field2: string
correlationCoefficient: number
significance: number
sampleSize: number
createdAt: string
dataSource: string
}
interface UserSegmentCorrelation {
id: string
segment1: string
segment2: string
overlapPercentage: number
uniqueUsers1: number
uniqueUsers2: number
commonUsers: number
}
export function DataCorrelationAnalysis() {
const [activeTab, setActiveTab] = useState("field-correlation")
const [isCreatingAnalysis, setIsCreatingAnalysis] = useState(false)
const [selectedFields, setSelectedFields] = useState<string[]>([])
const [selectedDataSources, setSelectedDataSources] = useState<string[]>([])
// 模拟字段列表
const fields = [
{ id: "userId", name: "用户ID", type: "string", dataSource: "所有数据源" },
{ id: "phoneNumber", name: "手机号", type: "string", dataSource: "所有数据源" },
{ id: "identityNumber", name: "身份证号", type: "string", dataSource: "所有数据源" },
{ id: "deviceImei", name: "IMEI设备号", type: "string", dataSource: "所有数据源" },
{ id: "registrationDate", name: "注册时间", type: "datetime", dataSource: "用户数据库" },
{ id: "lastActiveTime", name: "最后活跃时间", type: "datetime", dataSource: "行为分析平台" },
{ id: "age", name: "年龄", type: "number", dataSource: "用户数据库" },
{ id: "gender", name: "性别", type: "string", dataSource: "用户数据库" },
{ id: "location", name: "地区", type: "string", dataSource: "用户数据库" },
{ id: "totalSpent", name: "消费总额", type: "number", dataSource: "交易系统" },
{ id: "purchaseFrequency", name: "购买频率", type: "number", dataSource: "交易系统" },
{ id: "averageOrderValue", name: "平均订单金额", type: "number", dataSource: "交易系统" },
{ id: "preferredCategory", name: "偏好类别", type: "string", dataSource: "交易系统" },
{ id: "loginFrequency", name: "登录频率", type: "number", dataSource: "行为分析平台" },
{ id: "timeSpentPerSession", name: "单次会话时长", type: "number", dataSource: "行为分析平台" },
{ id: "clickThroughRate", name: "点击率", type: "number", dataSource: "行为分析平台" },
{ id: "conversionRate", name: "转化率", type: "number", dataSource: "行为分析平台" },
{ id: "rfmScore", name: "RFM评分", type: "number", dataSource: "用户价值模型" },
{ id: "lifetimeValue", name: "生命周期价值", type: "number", dataSource: "用户价值模型" },
{ id: "churnRisk", name: "流失风险", type: "number", dataSource: "用户价值模型" },
]
// 模拟数据源
const dataSources = [
{ id: "all", name: "所有数据源" },
{ id: "user-db", name: "用户数据库" },
{ id: "transaction", name: "交易系统" },
{ id: "behavior", name: "行为分析平台" },
{ id: "user-value", name: "用户价值模型" },
]
// 模拟用户分群
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 correlationResults: CorrelationResult[] = [
{
id: "corr-1",
field1: "年龄",
field2: "消费总额",
correlationCoefficient: 0.72,
significance: 0.001,
sampleSize: 45678,
createdAt: "2023-07-20 15:30",
dataSource: "用户数据库 & 交易系统",
},
{
id: "corr-2",
field1: "登录频率",
field2: "购买频率",
correlationCoefficient: 0.85,
significance: 0.001,
sampleSize: 38945,
createdAt: "2023-07-20 15:30",
dataSource: "行为分析平台 & 交易系统",
},
{
id: "corr-3",
field1: "单次会话时长",
field2: "转化率",
correlationCoefficient: 0.65,
significance: 0.01,
sampleSize: 42156,
createdAt: "2023-07-20 15:30",
dataSource: "行为分析平台",
},
{
id: "corr-4",
field1: "地区",
field2: "偏好类别",
correlationCoefficient: 0.58,
significance: 0.05,
sampleSize: 45678,
createdAt: "2023-07-20 15:30",
dataSource: "用户数据库 & 交易系统",
},
{
id: "corr-5",
field1: "RFM评分",
field2: "生命周期价值",
correlationCoefficient: 0.92,
significance: 0.001,
sampleSize: 45678,
createdAt: "2023-07-20 15:30",
dataSource: "用户价值模型",
},
]
// 模拟用户分群相关性
const segmentCorrelations: UserSegmentCorrelation[] = [
{
id: "seg-corr-1",
segment1: "高价值用户",
segment2: "忠诚用户",
overlapPercentage: 68.5,
uniqueUsers1: 12500,
uniqueUsers2: 9800,
commonUsers: 6713,
},
{
id: "seg-corr-2",
segment1: "新注册用户",
segment2: "潜在高转化用户",
overlapPercentage: 12.3,
uniqueUsers1: 45600,
uniqueUsers2: 18700,
commonUsers: 5609,
},
{
id: "seg-corr-3",
segment1: "非活跃用户",
segment2: "流失风险用户",
overlapPercentage: 75.2,
uniqueUsers1: 28900,
uniqueUsers2: 15400,
commonUsers: 11581,
},
{
id: "seg-corr-4",
segment1: "高价值用户",
segment2: "潜在高转化用户",
overlapPercentage: 8.7,
uniqueUsers1: 12500,
uniqueUsers2: 18700,
commonUsers: 1088,
},
{
id: "seg-corr-5",
segment1: "忠诚用户",
segment2: "流失风险用户",
overlapPercentage: 2.1,
uniqueUsers1: 9800,
uniqueUsers2: 15400,
commonUsers: 206,
},
]
const getCorrelationStrength = (coefficient: number) => {
const absCoefficient = Math.abs(coefficient)
if (absCoefficient >= 0.8) return "强"
if (absCoefficient >= 0.5) return "中"
return "弱"
}
const getCorrelationBadge = (coefficient: number) => {
const absCoefficient = Math.abs(coefficient)
if (absCoefficient >= 0.8) {
return <Badge className="bg-green-100 text-green-800"></Badge>
}
if (absCoefficient >= 0.5) {
return <Badge className="bg-yellow-100 text-yellow-800"></Badge>
}
return <Badge className="bg-gray-100 text-gray-800"></Badge>
}
const getSignificanceBadge = (significance: number) => {
if (significance <= 0.001) {
return <Badge className="bg-green-100 text-green-800"> (p0.001)</Badge>
}
if (significance <= 0.01) {
return <Badge className="bg-green-100 text-green-800"> (p0.01)</Badge>
}
if (significance <= 0.05) {
return <Badge className="bg-yellow-100 text-yellow-800"> (p0.05)</Badge>
}
return <Badge className="bg-red-100 text-red-800"> (p>0.05)</Badge>
}
const toggleFieldSelection = (fieldId: string) => {
if (selectedFields.includes(fieldId)) {
setSelectedFields(selectedFields.filter((id) => id !== fieldId))
} else {
setSelectedFields([...selectedFields, fieldId])
}
}
const toggleDataSourceSelection = (sourceId: string) => {
if (selectedDataSources.includes(sourceId)) {
setSelectedDataSources(selectedDataSources.filter((id) => id !== sourceId))
} else {
setSelectedDataSources([...selectedDataSources, sourceId])
}
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold"></h2>
<div className="flex items-center gap-2">
<Button variant="outline">
<Download className="mr-2 h-4 w-4" &gt;
导出分析结果
</Button>
<Button onClick={() => setIsCreatingAnalysis(true)}>
<Play className="mr-2 h-4 w-4" &gt;
创建新分析
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="field-correlation">字段相关性</TabsTrigger>
<TabsTrigger value="segment-correlation">分群相关性</TabsTrigger>
<TabsTrigger value="user-network">用户关联网络</TabsTrigger>
<TabsTrigger value="path-analysis">用户路径分析</TabsTrigger>
</TabsList>
<TabsContent value="field-correlation" className="space-y-4">
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>字段相关性分析</CardTitle>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="text" placeholder="搜索字段..." className="pl-8 w-[200px]" />
</div>
<Select defaultValue="all">
<SelectTrigger className="w-[150px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="相关性强度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">所有强度</SelectItem>
<SelectItem value="strong">强相关</SelectItem>
<SelectItem value="medium">中等相关</SelectItem>
<SelectItem value="weak">弱相关</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>字段1</TableHead>
<TableHead>字段2</TableHead>
<TableHead>相关系数</TableHead>
<TableHead>相关强度</TableHead>
<TableHead>显著性</TableHead>
<TableHead>样本量</TableHead>
<TableHead>数据源</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{correlationResults.map((result) => (
<TableRow key={result.id}>
<TableCell className="font-medium">{result.field1}</TableCell>
<TableCell className="font-medium">{result.field2}</TableCell>
<TableCell>{result.correlationCoefficient.toFixed(2)}</TableCell>
<TableCell>{getCorrelationBadge(result.correlationCoefficient)}</TableCell>
<TableCell>{getSignificanceBadge(result.significance)}</TableCell>
<TableCell>{result.sampleSize.toLocaleString()}</TableCell>
<TableCell>{result.dataSource}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
查看详情
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>相关性热力图</CardTitle>
<CardDescription>字段间相关性强度可视化</CardDescription>
</CardHeader>
<CardContent>
<HeatMap data={{}} title="字段相关性热力图" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>散点图分析</CardTitle>
<CardDescription>选择两个字段查看其相关性散点图</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<Label>X轴字段</Label>
<Select defaultValue="age">
<SelectTrigger>
<SelectValue placeholder="选择字段" />
</SelectTrigger>
<SelectContent>
{fields
.filter((field) => field.type === "number")
.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Label>Y轴字段</Label>
<Select defaultValue="totalSpent">
<SelectTrigger>
<SelectValue placeholder="选择字段" />
</SelectTrigger>
<SelectContent>
{fields
.filter((field) => field.type === "number")
.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<ScatterPlot data={{}} title="年龄 vs 消费总额" />
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="segment-correlation" className="space-y-4">
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle></CardTitle>
<Button></Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>1</TableHead>
<TableHead>2</TableHead>
<TableHead></TableHead>
<TableHead>1</TableHead>
<TableHead>2</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{segmentCorrelations.map((correlation) => (
<TableRow key={correlation.id}>
<TableCell className="font-medium">{correlation.segment1}</TableCell>
<TableCell className="font-medium">{correlation.segment2}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span>{correlation.overlapPercentage.toFixed(1)}%</span>
{correlation.overlapPercentage > 50 ? (
<Badge className="bg-green-100 text-green-800"></Badge>
) : correlation.overlapPercentage > 20 ? (
<Badge className="bg-yellow-100 text-yellow-800"></Badge>
) : (
<Badge className="bg-gray-100 text-gray-800"></Badge>
)}
</div>
</TableCell>
<TableCell>{correlation.uniqueUsers1.toLocaleString()}</TableCell>
<TableCell>{correlation.uniqueUsers2.toLocaleString()}</TableCell>
<TableCell>{correlation.commonUsers.toLocaleString()}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<Label>1</Label>
<Select defaultValue="high-value">
<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="flex-1">
<Label>2</Label>
<Select defaultValue="loyal">
<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>
<div className="p-6 border rounded-md">
<div className="flex justify-center items-center">
<div className="w-32 h-32 rounded-full bg-blue-100 flex items-center justify-center text-blue-800 font-bold text-lg">
</div>
<div className="w-24 h-24 bg-blue-200 mx-4 flex items-center justify-center text-blue-800 font-bold">
<br />
68.5%
</div>
<div className="w-32 h-32 rounded-full bg-green-100 flex items-center justify-center text-green-800 font-bold text-lg">
</div>
</div>
<div className="mt-4 text-center">
<p className="text-sm text-muted-foreground">
共同用户: 6,713 | 高价值用户: 12,500 | 忠诚用户: 9,800
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="user-network" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<Select defaultValue="all">
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="关联维度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="id">ID</SelectItem>
<SelectItem value="phone"></SelectItem>
<SelectItem value="identity"></SelectItem>
<SelectItem value="imei">IMEI设备号</SelectItem>
</SelectContent>
</Select>
<Select defaultValue="high-value">
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="用户分群" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{userSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<NetworkGraph data={{}} title="用户关联网络图" />
<div className="flex justify-between items-center">
<div>
<h3 className="text-sm font-medium"></h3>
<p className="text-xs text-muted-foreground">节点: 1,245 | 连接: 3,567 | 平均连接度: 2.87</p>
</div>
<div className="flex gap-2">
<Badge className="bg-blue-100 text-blue-800">ID</Badge>
<Badge className="bg-green-100 text-green-800"></Badge>
<Badge className="bg-purple-100 text-purple-800"></Badge>
<Badge className="bg-orange-100 text-orange-800">IMEI设备号</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">-001</TableCell>
<TableCell>125</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>, IMEI设备号</TableCell>
<TableCell>2023-07-20 15:30</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">-002</TableCell>
<TableCell>87</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>, ID</TableCell>
<TableCell>2023-07-20 15:30</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">-003</TableCell>
<TableCell>215</TableCell>
<TableCell>
<Badge className="bg-yellow-100 text-yellow-800"></Badge>
</TableCell>
<TableCell>, ID</TableCell>
<TableCell>2023-07-20 15:30</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">-004</TableCell>
<TableCell>56</TableCell>
<TableCell>
<Badge className="bg-gray-100 text-gray-800"></Badge>
</TableCell>
<TableCell>IMEI设备号</TableCell>
<TableCell>2023-07-20 15:30</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="path-analysis" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<Select defaultValue="registration-to-purchase">
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="选择路径类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="registration-to-purchase"></SelectItem>
<SelectItem value="browse-to-purchase"></SelectItem>
<SelectItem value="multi-purchase"></SelectItem>
<SelectItem value="churn-path"></SelectItem>
</SelectContent>
</Select>
<Select defaultValue="all">
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="用户分群" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{userSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="p-4 border rounded-md">
<div className="flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-32 h-12 bg-blue-100 rounded-md flex items-center justify-center text-blue-800 font-medium">
</div>
<div className="text-xs text-muted-foreground mt-1">100%</div>
</div>
<div className="flex flex-col items-center mx-2">
<ArrowRight className="h-6 w-6 text-muted-foreground" />
<div className="text-xs text-muted-foreground">85%</div>
</div>
<div className="flex flex-col items-center">
<div className="w-32 h-12 bg-blue-100 rounded-md flex items-center justify-center text-blue-800 font-medium">
</div>
<div className="text-xs text-muted-foreground mt-1">85%</div>
</div>
<div className="flex flex-col items-center mx-2">
<ArrowRight className="h-6 w-6 text-muted-foreground" />
<div className="text-xs text-muted-foreground">65%</div>
</div>
<div className="flex flex-col items-center">
<div className="w-32 h-12 bg-blue-100 rounded-md flex items-center justify-center text-blue-800 font-medium">
</div>
<div className="text-xs text-muted-foreground mt-1">55%</div>
</div>
<div className="flex flex-col items-center mx-2">
<ArrowRight className="h-6 w-6 text-muted-foreground" />
<div className="text-xs text-muted-foreground">40%</div>
</div>
<div className="flex flex-col items-center">
<div className="w-32 h-12 bg-green-100 rounded-md flex items-center justify-center text-green-800 font-medium">
</div>
<div className="text-xs text-muted-foreground mt-1">22%</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium mb-2"></h3>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">85%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "85%" }}></div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">65%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "65%" }}></div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">40%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-yellow-600 h-1.5 rounded-full" style={{ width: "40%" }}></div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">22%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-yellow-600 h-1.5 rounded-full" style={{ width: "22%" }}></div>
</div>
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-2"></h3>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">15%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-red-600 h-1.5 rounded-full" style={{ width: "15%" }}></div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">35%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-red-600 h-1.5 rounded-full" style={{ width: "35%" }}></div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">60%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-red-600 h-1.5 rounded-full" style={{ width: "60%" }}></div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">78%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-red-600 h-1.5 rounded-full" style={{ width: "78%" }}></div>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 创建分析对话框 */}
<Dialog open={isCreatingAnalysis} onOpenChange={setIsCreatingAnalysis}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 gap-2">
{dataSources.map((source) => (
<div key={source.id} className="flex items-center space-x-2">
<Checkbox
id={`source-${source.id}`}
checked={selectedDataSources.includes(source.id)}
onCheckedChange={() => toggleDataSourceSelection(source.id)}
/>
<Label htmlFor={`source-${source.id}`}>{source.name}</Label>
</div>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label></Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="text" placeholder="搜索字段..." className="pl-8" />
</div>
<div className="h-60 overflow-y-auto border rounded-md p-2">
{fields.map((field) => (
<div key={field.id} className="flex items-center space-x-2 py-1">
<Checkbox
id={`field-${field.id}`}
checked={selectedFields.includes(field.id)}
onCheckedChange={() => toggleFieldSelection(field.id)}
/>
<Label htmlFor={`field-${field.id}`} className="flex-1">
{field.name}
</Label>
<span className="text-xs text-muted-foreground">{field.type}</span>
<Badge variant="outline">{field.dataSource}</Badge>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="correlation-method"></Label>
<Select defaultValue="pearson">
<SelectTrigger id="correlation-method">
<SelectValue placeholder="选择方法" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pearson">Pearson相关系数</SelectItem>
<SelectItem value="spearman">Spearman等级相关</SelectItem>
<SelectItem value="kendall">Kendall's Tau</SelectItem>
<SelectItem value="chi-square"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="sample-size"></Label>
<Select defaultValue="all">
<SelectTrigger id="sample-size">
<SelectValue placeholder="选择样本大小" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="10000">10,000</SelectItem>
<SelectItem value="50000">50,000</SelectItem>
<SelectItem value="100000">100,000</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox id="save-result" defaultChecked />
<Label htmlFor="save-result"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreatingAnalysis(false)}>
</Button>
<Button onClick={() => setIsCreatingAnalysis(false)}>
<Play className="mr-2 h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,449 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { ArrowUp, ArrowDown, Download, RefreshCw, Calendar } from "lucide-react"
import { getGrowthClass } from "@/lib/utils"
// 模拟图表组件
const LineChart = ({ data, title }: { data: any; title: string }) => (
<div className="w-full h-64 bg-muted/30 rounded-md flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{title}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)
const BarChart = ({ data, title }: { data: any; title: string }) => (
<div className="w-full h-64 bg-muted/30 rounded-md flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{title}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)
const PieChart = ({ data, title }: { data: any; title: string }) => (
<div className="w-full h-64 bg-muted/30 rounded-md flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{title}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)
export function DataDashboard() {
const [timeRange, setTimeRange] = useState("7d")
const [activeTab, setActiveTab] = useState("overview")
// 模拟数据
const overviewData = {
totalUsers: 1245678,
userGrowth: 12.5,
totalDataSources: 8,
dataSourceGrowth: 33.3,
totalRecords: 45678921,
recordsGrowth: 8.7,
dataQuality: 92,
dataQualityGrowth: 3.2,
}
const dataSourceStats = [
{ name: "CRM系统", records: 12500000, growth: 5.2, quality: 95 },
{ name: "交易系统", records: 8750000, growth: 12.3, quality: 90 },
{ name: "行为分析平台", records: 15800000, growth: 8.7, quality: 88 },
{ name: "社交媒体", records: 4500000, growth: 15.2, quality: 85 },
{ name: "设备管理系统", records: 3200000, growth: -2.1, quality: 93 },
]
const dataQualityIssues = [
{ type: "缺失值", count: 12500, percentage: 0.027, severity: "medium" },
{ type: "格式错误", count: 8750, percentage: 0.019, severity: "high" },
{ type: "重复数据", count: 23800, percentage: 0.052, severity: "low" },
{ type: "异常值", count: 4500, percentage: 0.01, severity: "high" },
{ type: "不一致数据", count: 9200, percentage: 0.02, severity: "medium" },
]
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + "K"
}
return num.toString()
}
const getSeverityBadge = (severity: string) => {
switch (severity) {
case "high":
return <Badge className="bg-red-100 text-red-800"></Badge>
case "medium":
return <Badge className="bg-yellow-100 text-yellow-800"></Badge>
case "low":
return <Badge className="bg-green-100 text-green-800"></Badge>
default:
return <Badge></Badge>
}
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold"></h2>
<div className="flex items-center gap-2">
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[180px]">
<Calendar className="mr-2 h-4 w-4" />
<SelectValue placeholder="选择时间范围" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1d"></SelectItem>
<SelectItem value="7d">7</SelectItem>
<SelectItem value="30d">30</SelectItem>
<SelectItem value="90d">90</SelectItem>
<SelectItem value="1y">1</SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
<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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-6">
<div className="flex flex-col space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex items-end justify-between">
<h3 className="text-2xl font-bold">{formatNumber(overviewData.totalUsers)}</h3>
<div className={`flex items-center ${getGrowthClass(overviewData.userGrowth)}`}>
{overviewData.userGrowth > 0 ? (
<ArrowUp className="h-4 w-4 mr-1" />
) : (
<ArrowDown className="h-4 w-4 mr-1" />
)}
<span>{Math.abs(overviewData.userGrowth)}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex flex-col space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex items-end justify-between">
<h3 className="text-2xl font-bold">{overviewData.totalDataSources}</h3>
<div className={`flex items-center ${getGrowthClass(overviewData.dataSourceGrowth)}`}>
{overviewData.dataSourceGrowth > 0 ? (
<ArrowUp className="h-4 w-4 mr-1" />
) : (
<ArrowDown className="h-4 w-4 mr-1" />
)}
<span>{Math.abs(overviewData.dataSourceGrowth)}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex flex-col space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex items-end justify-between">
<h3 className="text-2xl font-bold">{formatNumber(overviewData.totalRecords)}</h3>
<div className={`flex items-center ${getGrowthClass(overviewData.recordsGrowth)}`}>
{overviewData.recordsGrowth > 0 ? (
<ArrowUp className="h-4 w-4 mr-1" />
) : (
<ArrowDown className="h-4 w-4 mr-1" />
)}
<span>{Math.abs(overviewData.recordsGrowth)}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex flex-col space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex items-end justify-between">
<h3 className="text-2xl font-bold">{overviewData.dataQuality}/100</h3>
<div className={`flex items-center ${getGrowthClass(overviewData.dataQualityGrowth)}`}>
{overviewData.dataQualityGrowth > 0 ? (
<ArrowUp className="h-4 w-4 mr-1" />
) : (
<ArrowDown className="h-4 w-4 mr-1" />
)}
<span>{Math.abs(overviewData.dataQualityGrowth)}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="sources"></TabsTrigger>
<TabsTrigger value="quality"></TabsTrigger>
<TabsTrigger value="integration"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<LineChart data={{}} title="用户数据增长趋势图" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<PieChart data={{}} title="数据源分布图" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<LineChart data={{}} title="数据质量趋势图" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<BarChart data={{}} title="数据整合进度图" />
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="sources" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="min-w-full divide-y divide-border">
<thead>
<tr className="bg-muted/50">
<th className="px-4 py-3 text-left text-sm font-medium"></th>
<th className="px-4 py-3 text-left text-sm font-medium"></th>
<th className="px-4 py-3 text-left text-sm font-medium"></th>
<th className="px-4 py-3 text-left text-sm font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{dataSourceStats.map((source, index) => (
<tr key={index}>
<td className="px-4 py-3 text-sm font-medium">{source.name}</td>
<td className="px-4 py-3 text-sm">{formatNumber(source.records)}</td>
<td className="px-4 py-3 text-sm">
<div className={`flex items-center ${getGrowthClass(source.growth)}`}>
{source.growth > 0 ? (
<ArrowUp className="h-4 w-4 mr-1" />
) : (
<ArrowDown className="h-4 w-4 mr-1" />
)}
<span>{Math.abs(source.growth)}%</span>
</div>
</td>
<td className="px-4 py-3 text-sm">{source.quality}/100</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<BarChart data={{}} title="数据源记录数对比图" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<BarChart data={{}} title="数据源质量对比图" />
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="quality" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="min-w-full divide-y divide-border">
<thead>
<tr className="bg-muted/50">
<th className="px-4 py-3 text-left text-sm font-medium"></th>
<th className="px-4 py-3 text-left text-sm font-medium"></th>
<th className="px-4 py-3 text-left text-sm font-medium"></th>
<th className="px-4 py-3 text-left text-sm font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{dataQualityIssues.map((issue, index) => (
<tr key={index}>
<td className="px-4 py-3 text-sm font-medium">{issue.type}</td>
<td className="px-4 py-3 text-sm">{formatNumber(issue.count)}</td>
<td className="px-4 py-3 text-sm">{(issue.percentage * 100).toFixed(2)}%</td>
<td className="px-4 py-3 text-sm">{getSeverityBadge(issue.severity)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<PieChart data={{}} title="数据质量问题分布图" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<LineChart data={{}} title="数据质量趋势图" />
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="integration" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="font-medium">ID整合率</span>
<span className="text-green-600 font-medium">95%</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: "95%" }}></div>
</div>
</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-yellow-600 font-medium">72%</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: "72%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="font-medium">IMEI设备号整合率</span>
<span className="text-yellow-600 font-medium">68%</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: "68%" }}></div>
</div>
</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">82%</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: "82%" }}></div>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<LineChart data={{}} title="数据整合趋势图" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<BarChart data={{}} title="标识符覆盖率图" />
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,321 @@
"use client"
import { useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { ArrowRight, Plus, Save, Trash2, HelpCircle } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
interface FieldMapping {
id: string
sourceField: string
targetField: string
dataType: string
required: boolean
transformation: string
primaryKey: boolean
}
export function DataFieldMapping() {
// 模拟数据
const [mappings, setMappings] = useState<FieldMapping[]>([
{
id: "1",
sourceField: "user_id",
targetField: "userId",
dataType: "string",
required: true,
transformation: "none",
primaryKey: true,
},
{
id: "2",
sourceField: "phone",
targetField: "phoneNumber",
dataType: "string",
required: true,
transformation: "formatPhoneNumber",
primaryKey: false,
},
{
id: "3",
sourceField: "id_card",
targetField: "identityNumber",
dataType: "string",
required: true,
transformation: "mask",
primaryKey: false,
},
{
id: "4",
sourceField: "imei",
targetField: "deviceImei",
dataType: "string",
required: false,
transformation: "none",
primaryKey: false,
},
{
id: "5",
sourceField: "name",
targetField: "fullName",
dataType: "string",
required: true,
transformation: "none",
primaryKey: false,
},
{
id: "6",
sourceField: "register_time",
targetField: "registrationDate",
dataType: "datetime",
required: true,
transformation: "parseDateTime",
primaryKey: false,
},
{
id: "7",
sourceField: "last_login",
targetField: "lastActiveTime",
dataType: "datetime",
required: false,
transformation: "parseDateTime",
primaryKey: false,
},
])
const addNewMapping = () => {
const newMapping: FieldMapping = {
id: `new-${Date.now()}`,
sourceField: "",
targetField: "",
dataType: "string",
required: false,
transformation: "none",
primaryKey: false,
}
setMappings([...mappings, newMapping])
}
const updateMapping = (id: string, field: keyof FieldMapping, value: any) => {
setMappings(mappings.map((mapping) => (mapping.id === id ? { ...mapping, [field]: value } : mapping)))
}
const deleteMapping = (id: string) => {
setMappings(mappings.filter((mapping) => mapping.id !== id))
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
IDIMEI设备号为标准整合客户数据
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Badge className="bg-blue-100 text-blue-800"></Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<HelpCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">
便
IDIMEI设备号
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button onClick={addNewMapping}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-md border">
<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>
{mappings.map((mapping) => (
<TableRow key={mapping.id}>
<TableCell>
<Input
value={mapping.sourceField}
onChange={(e) => updateMapping(mapping.id, "sourceField", e.target.value)}
placeholder="源字段名"
/>
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<Input
value={mapping.targetField}
onChange={(e) => updateMapping(mapping.id, "targetField", e.target.value)}
placeholder="目标字段名"
/>
</TableCell>
<TableCell>
<Select
value={mapping.dataType}
onValueChange={(value) => updateMapping(mapping.id, "dataType", value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择数据类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="string"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="boolean"></SelectItem>
<SelectItem value="datetime"></SelectItem>
<SelectItem value="array"></SelectItem>
<SelectItem value="object"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Switch
checked={mapping.required}
onCheckedChange={(checked) => updateMapping(mapping.id, "required", checked)}
/>
</div>
</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Switch
checked={mapping.primaryKey}
onCheckedChange={(checked) => updateMapping(mapping.id, "primaryKey", checked)}
/>
</div>
</TableCell>
<TableCell>
<Select
value={mapping.transformation}
onValueChange={(value) => updateMapping(mapping.id, "transformation", value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择转换方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="formatPhoneNumber"></SelectItem>
<SelectItem value="mask"></SelectItem>
<SelectItem value="parseDateTime"></SelectItem>
<SelectItem value="uppercase"></SelectItem>
<SelectItem value="lowercase"></SelectItem>
<SelectItem value="trim"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => deleteMapping(mapping.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline"></Button>
<Button>
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">userId</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">phoneNumber</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">identityNumber</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">deviceImei</TableCell>
<TableCell></TableCell>
<TableCell>IMEI号码</TableCell>
<TableCell></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">fullName</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">registrationDate</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">lastActiveTime</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,210 @@
"use client"
import { useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { ArrowRight, Plus, Save, Trash2 } from "lucide-react"
interface FieldMapping {
id: string
sourceField: string
targetField: string
dataType: string
required: boolean
transformation: string
}
export function DataMappingConfig() {
// 模拟数据
const [mappings, setMappings] = useState<FieldMapping[]>([
{
id: "1",
sourceField: "phone",
targetField: "phoneNumber",
dataType: "string",
required: true,
transformation: "formatPhoneNumber",
},
{
id: "2",
sourceField: "id_card",
targetField: "identityNumber",
dataType: "string",
required: true,
transformation: "mask",
},
{
id: "3",
sourceField: "imei",
targetField: "deviceImei",
dataType: "string",
required: false,
transformation: "none",
},
{
id: "4",
sourceField: "address",
targetField: "userAddress",
dataType: "string",
required: false,
transformation: "none",
},
{
id: "5",
sourceField: "name",
targetField: "fullName",
dataType: "string",
required: true,
transformation: "none",
},
{
id: "6",
sourceField: "register_time",
targetField: "registrationDate",
dataType: "datetime",
required: true,
transformation: "parseDateTime",
},
{
id: "7",
sourceField: "last_login",
targetField: "lastActiveTime",
dataType: "datetime",
required: false,
transformation: "parseDateTime",
},
])
const addNewMapping = () => {
const newMapping: FieldMapping = {
id: `new-${Date.now()}`,
sourceField: "",
targetField: "",
dataType: "string",
required: false,
transformation: "none",
}
setMappings([...mappings, newMapping])
}
const updateMapping = (id: string, field: keyof FieldMapping, value: any) => {
setMappings(mappings.map((mapping) => (mapping.id === id ? { ...mapping, [field]: value } : mapping)))
}
const deleteMapping = (id: string) => {
setMappings(mappings.filter((mapping) => mapping.id !== id))
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium"></h3>
<Button onClick={addNewMapping}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mappings.map((mapping) => (
<TableRow key={mapping.id}>
<TableCell>
<Input
value={mapping.sourceField}
onChange={(e) => updateMapping(mapping.id, "sourceField", e.target.value)}
placeholder="源字段名"
/>
</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<Input
value={mapping.targetField}
onChange={(e) => updateMapping(mapping.id, "targetField", e.target.value)}
placeholder="目标字段名"
/>
</TableCell>
<TableCell>
<Select
value={mapping.dataType}
onValueChange={(value) => updateMapping(mapping.id, "dataType", value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择数据类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="string"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="boolean"></SelectItem>
<SelectItem value="datetime"></SelectItem>
<SelectItem value="array"></SelectItem>
<SelectItem value="object"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Switch
checked={mapping.required}
onCheckedChange={(checked) => updateMapping(mapping.id, "required", checked)}
/>
</div>
</TableCell>
<TableCell>
<Select
value={mapping.transformation}
onValueChange={(value) => updateMapping(mapping.id, "transformation", value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择转换方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="formatPhoneNumber"></SelectItem>
<SelectItem value="mask"></SelectItem>
<SelectItem value="parseDateTime"></SelectItem>
<SelectItem value="uppercase"></SelectItem>
<SelectItem value="lowercase"></SelectItem>
<SelectItem value="trim"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => deleteMapping(mapping.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline"></Button>
<Button>
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,235 @@
"use client"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export function DataPreview() {
// 模拟数据
const previewData = [
{
id: "1",
name: "张三",
phone: "13812345678",
idCard: "3101********1234",
imei: "86723*****5432",
address: "上海市浦东新区张江高科技园区",
registerDate: "2023-01-15",
lastActive: "2023-07-12",
source: "CRM系统",
status: "matched",
},
{
id: "2",
name: "李四",
phone: "13987654321",
idCard: "4401********5678",
imei: "35871*****7890",
address: "广州市天河区珠江新城",
registerDate: "2022-11-20",
lastActive: "2023-07-10",
source: "电商平台",
status: "matched",
},
{
id: "3",
name: "王五",
phone: "13765432198",
idCard: "1101********7890",
imei: "",
address: "北京市海淀区中关村",
registerDate: "2023-03-05",
lastActive: "2023-07-15",
source: "营销活动",
status: "partial",
},
{
id: "4",
name: "赵六",
phone: "13698765432",
idCard: "",
imei: "86723*****1234",
address: "深圳市南山区科技园",
registerDate: "2023-05-18",
lastActive: "2023-07-14",
source: "APP注册",
status: "partial",
},
{
id: "5",
name: "钱七",
phone: "",
idCard: "",
imei: "",
address: "杭州市西湖区",
registerDate: "2023-06-30",
lastActive: "2023-07-01",
source: "社交媒体",
status: "unmatched",
},
]
const getStatusBadge = (status: string) => {
switch (status) {
case "matched":
return <Badge className="bg-green-100 text-green-800"></Badge>
case "partial":
return <Badge className="bg-yellow-100 text-yellow-800"></Badge>
case "unmatched":
return <Badge className="bg-red-100 text-red-800"></Badge>
}
}
return (
<div className="space-y-4">
<Tabs defaultValue="preview">
<TabsList>
<TabsTrigger value="preview"></TabsTrigger>
<TabsTrigger value="matched"></TabsTrigger>
<TabsTrigger value="partial"></TabsTrigger>
<TabsTrigger value="unmatched"></TabsTrigger>
</TabsList>
<TabsContent value="preview" className="pt-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IMEI</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewData.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.phone || "-"}</TableCell>
<TableCell>{item.idCard || "-"}</TableCell>
<TableCell>{item.imei || "-"}</TableCell>
<TableCell>{item.address}</TableCell>
<TableCell>{item.registerDate}</TableCell>
<TableCell>{item.lastActive}</TableCell>
<TableCell>{item.source}</TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="matched" className="pt-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IMEI</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewData
.filter((item) => item.status === "matched")
.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.phone || "-"}</TableCell>
<TableCell>{item.idCard || "-"}</TableCell>
<TableCell>{item.imei || "-"}</TableCell>
<TableCell>{item.address}</TableCell>
<TableCell>{item.registerDate}</TableCell>
<TableCell>{item.lastActive}</TableCell>
<TableCell>{item.source}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="partial" className="pt-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IMEI</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewData
.filter((item) => item.status === "partial")
.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.phone || "-"}</TableCell>
<TableCell>{item.idCard || "-"}</TableCell>
<TableCell>{item.imei || "-"}</TableCell>
<TableCell>{item.address}</TableCell>
<TableCell>{item.registerDate}</TableCell>
<TableCell>{item.lastActive}</TableCell>
<TableCell>{item.source}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="unmatched" className="pt-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IMEI</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewData
.filter((item) => item.status === "unmatched")
.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.phone || "-"}</TableCell>
<TableCell>{item.idCard || "-"}</TableCell>
<TableCell>{item.imei || "-"}</TableCell>
<TableCell>{item.address}</TableCell>
<TableCell>{item.registerDate}</TableCell>
<TableCell>{item.lastActive}</TableCell>
<TableCell>{item.source}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,987 @@
"use client"
import { Textarea } from "@/components/ui/textarea"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Separator } from "@/components/ui/separator"
import { AlertTriangle, CheckCircle, Bell, RefreshCw, Filter, Download, Search, Clock } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
interface DataQualityIssue {
id: string
type: string
description: string
affectedRecords: number
affectedFields: string[]
severity: "high" | "medium" | "low"
status: "open" | "in_progress" | "resolved"
createdAt: string
dataSource: string
}
interface DataQualityRule {
id: string
name: string
description: string
type: string
enabled: boolean
severity: "high" | "medium" | "low"
dataSource: string
lastRun: string
issuesDetected: number
}
export function DataQualityMonitor() {
const [activeTab, setActiveTab] = useState("issues")
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null)
const [isAddingRule, setIsAddingRule] = useState(false)
const [filterSeverity, setFilterSeverity] = useState<string>("all")
const [filterStatus, setFilterStatus] = useState<string>("all")
// 模拟数据质量问题
const [dataQualityIssues, setDataQualityIssues] = useState<DataQualityIssue[]>([
{
id: "issue-1",
type: "缺失值",
description: "用户表中存在大量手机号字段为空的记录",
affectedRecords: 1250,
affectedFields: ["phoneNumber"],
severity: "high",
status: "open",
createdAt: "2023-07-20 10:15",
dataSource: "CRM系统",
},
{
id: "issue-2",
type: "格式错误",
description: "部分用户的身份证号格式不正确",
affectedRecords: 458,
affectedFields: ["identityNumber"],
severity: "medium",
status: "in_progress",
createdAt: "2023-07-19 15:30",
dataSource: "用户数据库",
},
{
id: "issue-3",
type: "重复数据",
description: "存在多条使用相同手机号的用户记录",
affectedRecords: 789,
affectedFields: ["userId", "phoneNumber"],
severity: "medium",
status: "open",
createdAt: "2023-07-18 09:45",
dataSource: "交易系统",
},
{
id: "issue-4",
type: "异常值",
description: "部分用户的年龄字段值异常超过150岁",
affectedRecords: 23,
affectedFields: ["age"],
severity: "low",
status: "resolved",
createdAt: "2023-07-17 14:20",
dataSource: "用户数据库",
},
{
id: "issue-5",
type: "不一致数据",
description: "用户在不同系统中的姓名不一致",
affectedRecords: 567,
affectedFields: ["fullName"],
severity: "high",
status: "open",
createdAt: "2023-07-16 11:05",
dataSource: "多系统",
},
])
// 模拟数据质量规则
const [dataQualityRules, setDataQualityRules] = useState<DataQualityRule[]>([
{
id: "rule-1",
name: "手机号格式检查",
description: "检查手机号是否符合中国大陆手机号格式",
type: "格式验证",
enabled: true,
severity: "high",
dataSource: "所有数据源",
lastRun: "2023-07-20 00:00",
issuesDetected: 458,
},
{
id: "rule-2",
name: "身份证号验证",
description: "验证身份证号的格式和校验码",
type: "格式验证",
enabled: true,
severity: "high",
dataSource: "所有数据源",
lastRun: "2023-07-20 00:00",
issuesDetected: 789,
},
{
id: "rule-3",
name: "必填字段检查",
description: "检查关键字段是否有值",
type: "完整性检查",
enabled: true,
severity: "medium",
dataSource: "所有数据源",
lastRun: "2023-07-20 00:00",
issuesDetected: 1250,
},
{
id: "rule-4",
name: "重复数据检测",
description: "检测并标记重复的用户记录",
type: "唯一性检查",
enabled: true,
severity: "medium",
dataSource: "所有数据源",
lastRun: "2023-07-20 00:00",
issuesDetected: 567,
},
{
id: "rule-5",
name: "数据一致性检查",
description: "检查用户在不同系统中的数据是否一致",
type: "一致性检查",
enabled: true,
severity: "high",
dataSource: "所有数据源",
lastRun: "2023-07-20 00:00",
issuesDetected: 567,
},
])
// 模拟数据质量统计
const qualityStats = {
totalIssues: 3087,
openIssues: 2606,
resolvedIssues: 481,
highSeverity: 1245,
mediumSeverity: 1356,
lowSeverity: 486,
dataQualityScore: 87,
completenessScore: 92,
accuracyScore: 85,
consistencyScore: 88,
validityScore: 84,
}
const getSeverityBadge = (severity: string) => {
switch (severity) {
case "high":
return <Badge className="bg-red-100 text-red-800"></Badge>
case "medium":
return <Badge className="bg-yellow-100 text-yellow-800"></Badge>
case "low":
return <Badge className="bg-green-100 text-green-800"></Badge>
default:
return <Badge></Badge>
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case "open":
return <Badge className="bg-red-100 text-red-800"></Badge>
case "in_progress":
return <Badge className="bg-yellow-100 text-yellow-800"></Badge>
case "resolved":
return <Badge className="bg-green-100 text-green-800"></Badge>
default:
return <Badge></Badge>
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case "open":
return <AlertTriangle className="h-5 w-5 text-red-500" />
case "in_progress":
return <RefreshCw className="h-5 w-5 text-yellow-500" />
case "resolved":
return <CheckCircle className="h-5 w-5 text-green-500" />
default:
return <AlertTriangle className="h-5 w-5" />
}
}
const filteredIssues = dataQualityIssues.filter((issue) => {
if (filterSeverity !== "all" && issue.severity !== filterSeverity) return false
if (filterStatus !== "all" && issue.status !== filterStatus) return false
return true
})
const selectedIssue = selectedIssueId ? dataQualityIssues.find((issue) => issue.id === selectedIssueId) : null
const updateIssueStatus = (id: string, status: "open" | "in_progress" | "resolved") => {
setDataQualityIssues(
dataQualityIssues.map((issue) => {
if (issue.id === id) {
return { ...issue, status }
}
return issue
}),
)
}
const toggleRuleStatus = (id: string) => {
setDataQualityRules(
dataQualityRules.map((rule) => {
if (rule.id === id) {
return { ...rule, enabled: !rule.enabled }
}
return rule
}),
)
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold"></h2>
<div className="flex items-center gap-2">
<Button variant="outline">
<Bell className="mr-2 h-4 w-4" />
</Button>
<Button>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-6">
<div className="flex flex-col space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex items-end justify-between">
<h3 className="text-2xl font-bold">{qualityStats.dataQualityScore}/100</h3>
</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: `${qualityStats.dataQualityScore}%` }}
></div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex flex-col space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">{qualityStats.openIssues}</h3>
<AlertTriangle className="h-5 w-5 text-red-500" />
</div>
<p className="text-xs text-muted-foreground">: {qualityStats.highSeverity} </p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex flex-col space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">
{dataQualityIssues.filter((issue) => issue.status === "in_progress").length}
</h3>
<RefreshCw className="h-5 w-5 text-yellow-500" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex flex-col space-y-2">
<p className="text-sm text-muted-foreground"></p>
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">{qualityStats.resolvedIssues}</h3>
<CheckCircle className="h-5 w-5 text-green-500" />
</div>
<p className="text-xs text-muted-foreground">
: {((qualityStats.resolvedIssues / qualityStats.totalIssues) * 100).toFixed(1)}%
</p>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="issues"></TabsTrigger>
<TabsTrigger value="rules"></TabsTrigger>
<TabsTrigger value="metrics"></TabsTrigger>
<TabsTrigger value="reports"></TabsTrigger>
</TabsList>
<TabsContent value="issues" className="space-y-4">
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle></CardTitle>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="text" placeholder="搜索问题..." className="pl-8 w-[200px]" />
</div>
<Select value={filterSeverity} onValueChange={setFilterSeverity}>
<SelectTrigger className="w-[130px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="严重性" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="high"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="low"></SelectItem>
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[130px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="open"></SelectItem>
<SelectItem value="in_progress"></SelectItem>
<SelectItem value="resolved"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredIssues.map((issue) => (
<TableRow key={issue.id}>
<TableCell>{getStatusIcon(issue.status)}</TableCell>
<TableCell className="font-medium">{issue.type}</TableCell>
<TableCell>{issue.description}</TableCell>
<TableCell>{issue.affectedRecords.toLocaleString()}</TableCell>
<TableCell>{getSeverityBadge(issue.severity)}</TableCell>
<TableCell>{issue.dataSource}</TableCell>
<TableCell>{issue.createdAt}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => setSelectedIssueId(issue.id)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules" className="space-y-4">
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle></CardTitle>
<Button onClick={() => setIsAddingRule(true)}></Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<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>
{dataQualityRules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell>{rule.type}</TableCell>
<TableCell>{getSeverityBadge(rule.severity)}</TableCell>
<TableCell>{rule.dataSource}</TableCell>
<TableCell>{rule.lastRun}</TableCell>
<TableCell>{rule.issuesDetected.toLocaleString()}</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Switch
checked={rule.enabled}
onCheckedChange={() => toggleRuleStatus(rule.id)}
aria-label="Toggle rule"
/>
<span>{rule.enabled ? "启用" : "禁用"}</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm">
</Button>
<Button variant="ghost" size="sm">
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="metrics" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium"></span>
<span className="text-green-600 font-medium">{qualityStats.completenessScore}/100</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: `${qualityStats.completenessScore}%` }}
></div>
</div>
<div className="space-y-2 mt-4">
<h4 className="text-sm font-medium"></h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm">ID</span>
<span className="text-sm">100%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "100%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">87%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "87%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">72%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-yellow-600 h-1.5 rounded-full" style={{ width: "72%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm">IMEI设备号</span>
<span className="text-sm">68%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-yellow-600 h-1.5 rounded-full" style={{ width: "68%" }}></div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium"></span>
<span className="text-green-600 font-medium">{qualityStats.accuracyScore}/100</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: `${qualityStats.accuracyScore}%` }}
></div>
</div>
<div className="space-y-2 mt-4">
<h4 className="text-sm font-medium"></h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">92%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "92%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">85%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "85%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">95%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "95%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">78%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-yellow-600 h-1.5 rounded-full" style={{ width: "78%" }}></div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium"></span>
<span className="text-green-600 font-medium">{qualityStats.consistencyScore}/100</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: `${qualityStats.consistencyScore}%` }}
></div>
</div>
<div className="space-y-2 mt-4">
<h4 className="text-sm font-medium"></h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm">CRM与交易系统</span>
<span className="text-sm">90%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "90%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">85%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "85%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">78%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-yellow-600 h-1.5 rounded-full" style={{ width: "78%" }}></div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium"></span>
<span className="text-green-600 font-medium">{qualityStats.validityScore}/100</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: `${qualityStats.validityScore}%` }}
></div>
</div>
<div className="space-y-2 mt-4">
<h4 className="text-sm font-medium"></h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">92%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "92%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">88%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-green-600 h-1.5 rounded-full" style={{ width: "88%" }}></div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm"></span>
<span className="text-sm">75%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-yellow-600 h-1.5 rounded-full" style={{ width: "75%" }}></div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="reports" className="space-y-4">
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle></CardTitle>
<div className="flex items-center gap-2">
<Select defaultValue="weekly">
<SelectTrigger className="w-[150px]">
<Clock className="mr-2 h-4 w-4" />
<SelectValue placeholder="报告频率" />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily"></SelectItem>
<SelectItem value="weekly"></SelectItem>
<SelectItem value="monthly"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>2023-07-21</TableCell>
<TableCell>87/100</TableCell>
<TableCell>156</TableCell>
<TableCell>23</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>2023-07-14</TableCell>
<TableCell>85/100</TableCell>
<TableCell>178</TableCell>
<TableCell>31</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>2023-07-07</TableCell>
<TableCell>82/100</TableCell>
<TableCell>203</TableCell>
<TableCell>45</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>2023-06-30</TableCell>
<TableCell>80/100</TableCell>
<TableCell>245</TableCell>
<TableCell>52</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 问题详情对话框 */}
<Dialog open={!!selectedIssueId} onOpenChange={(open) => !open && setSelectedIssueId(null)}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{selectedIssue && (
<div className="space-y-4 py-4">
<div className="flex items-center gap-4">
{getStatusIcon(selectedIssue.status)}
<div>
<h3 className="font-medium text-lg">{selectedIssue.type}</h3>
<p className="text-sm text-muted-foreground">{selectedIssue.description}</p>
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{getSeverityBadge(selectedIssue.severity)}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{getStatusBadge(selectedIssue.status)}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedIssue.dataSource}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedIssue.createdAt}</p>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<p className="font-medium">{selectedIssue.affectedRecords.toLocaleString()}</p>
</div>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<div className="flex flex-wrap gap-2 mt-1">
{selectedIssue.affectedFields.map((field, index) => (
<Badge key={index} variant="outline">
{field}
</Badge>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label></Label>
<Select
value={selectedIssue.status}
onValueChange={(value) =>
updateIssueStatus(selectedIssue.id, value as "open" | "in_progress" | "resolved")
}
>
<SelectTrigger>
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="open"></SelectItem>
<SelectItem value="in_progress"></SelectItem>
<SelectItem value="resolved"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea placeholder="输入处理备注..." rows={3} />
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedIssueId(null)}>
</Button>
<Button onClick={() => setSelectedIssueId(null)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 添加规则对话框 */}
<Dialog open={isAddingRule} onOpenChange={setIsAddingRule}>
<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="rule-name" className="text-right">
</Label>
<Input id="rule-name" className="col-span-3" placeholder="例如:手机号格式检查" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="rule-type" className="text-right">
</Label>
<Select>
<SelectTrigger id="rule-type" className="col-span-3">
<SelectValue placeholder="选择规则类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="format"></SelectItem>
<SelectItem value="completeness"></SelectItem>
<SelectItem value="uniqueness"></SelectItem>
<SelectItem value="consistency"></SelectItem>
<SelectItem value="range"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="rule-severity" className="text-right">
</Label>
<Select>
<SelectTrigger id="rule-severity" className="col-span-3">
<SelectValue placeholder="选择严重性" />
</SelectTrigger>
<SelectContent>
<SelectItem value="high"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="low"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="rule-datasource" className="text-right">
</Label>
<Select>
<SelectTrigger id="rule-datasource" className="col-span-3">
<SelectValue placeholder="选择数据源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="crm">CRM系统</SelectItem>
<SelectItem value="transaction"></SelectItem>
<SelectItem value="user-db"></SelectItem>
<SelectItem value="behavior"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label htmlFor="rule-description" className="text-right pt-2">
</Label>
<Textarea
id="rule-description"
className="col-span-3"
placeholder="描述规则的用途和检查内容..."
rows={3}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="text-right">
<Label htmlFor="rule-enabled"></Label>
</div>
<div className="flex items-center space-x-2 col-span-3">
<Switch id="rule-enabled" defaultChecked />
<Label htmlFor="rule-enabled"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddingRule(false)}>
</Button>
<Button onClick={() => setIsAddingRule(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,185 @@
"use client"
import { useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Edit, ExternalLink, MoreHorizontal, Search, RefreshCw } from "lucide-react"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
interface DataSource {
id: string
name: string
type: "api" | "file" | "database" | "third-party"
status: "active" | "inactive" | "error" | "pending"
lastSync: Date | null
recordCount: number
createdAt: Date
}
export function DataSourceList() {
const [searchQuery, setSearchQuery] = useState("")
// 模拟数据
const dataSources: DataSource[] = [
{
id: "1",
name: "CRM系统用户数据",
type: "api",
status: "active",
lastSync: new Date(2023, 6, 15, 14, 30),
recordCount: 45892,
createdAt: new Date(2023, 3, 10),
},
{
id: "2",
name: "电商平台订单数据",
type: "api",
status: "active",
lastSync: new Date(2023, 6, 15, 10, 15),
recordCount: 128456,
createdAt: new Date(2023, 2, 5),
},
{
id: "3",
name: "营销活动参与用户",
type: "file",
status: "active",
lastSync: new Date(2023, 6, 14, 9, 45),
recordCount: 8754,
createdAt: new Date(2023, 5, 20),
},
{
id: "4",
name: "APP用户行为数据",
type: "api",
status: "error",
lastSync: new Date(2023, 6, 10, 16, 20),
recordCount: 65432,
createdAt: new Date(2023, 1, 15),
},
{
id: "5",
name: "社交媒体用户数据",
type: "third-party",
status: "active",
lastSync: new Date(2023, 6, 15, 8, 0),
recordCount: 32145,
createdAt: new Date(2023, 4, 25),
},
]
// 过滤数据源
const filteredDataSources = dataSources.filter(
(source) =>
source.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
source.type.toLowerCase().includes(searchQuery.toLowerCase()),
)
const formatDate = (date: Date | null) => {
if (!date) return "从未同步"
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
const getStatusBadge = (status: DataSource["status"]) => {
switch (status) {
case "active":
return <Badge className="bg-green-100 text-green-800"></Badge>
case "inactive":
return <Badge className="bg-gray-100 text-gray-800"></Badge>
case "error":
return <Badge className="bg-red-100 text-red-800"></Badge>
case "pending":
return <Badge className="bg-yellow-100 text-yellow-800"></Badge>
}
}
const getTypeIcon = (type: DataSource["type"]) => {
switch (type) {
case "api":
return <Badge className="bg-blue-100 text-blue-800">API接口</Badge>
case "file":
return <Badge className="bg-purple-100 text-purple-800"></Badge>
case "database":
return <Badge className="bg-green-100 text-green-800"></Badge>
case "third-party":
return <Badge className="bg-orange-100 text-orange-800"></Badge>
}
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="relative w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索数据源..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDataSources.map((source) => (
<TableRow key={source.id}>
<TableCell className="font-medium">{source.name}</TableCell>
<TableCell>{getTypeIcon(source.type)}</TableCell>
<TableCell>{getStatusBadge(source.status)}</TableCell>
<TableCell>{formatDate(source.lastSync)}</TableCell>
<TableCell>{source.recordCount.toLocaleString()}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="icon">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem className="text-red-600"></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,293 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Database, TableIcon, Key, Download, RefreshCw } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
interface TableField {
Field: string
Type: string
Null: string
Key: string
Default: string | null
Extra: string
}
interface DatabaseStructure {
[tableName: string]: TableField[]
}
export function DatabaseStructureViewer() {
const [databases, setDatabases] = useState<string[]>([])
const [selectedDatabase, setSelectedDatabase] = useState<string>("")
const [structure, setStructure] = useState<DatabaseStructure | null>(null)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
// 获取数据库列表
useEffect(() => {
const fetchDatabases = async () => {
try {
setLoading(true)
const response = await fetch("/api/database-structure")
const result = await response.json()
if (result.success) {
const dbList = result.data.map((item: any) => Object.values(item)[0] as string)
setDatabases(dbList)
// 默认选择第一个非系统数据库
const nonSystemDbs = dbList.filter(
(db: string) => !["information_schema", "mysql", "performance_schema", "sys"].includes(db),
)
if (nonSystemDbs.length > 0) {
setSelectedDatabase(nonSystemDbs[0])
}
} else {
setError(result.message || "获取数据库列表失败")
}
} catch (err) {
setError("获取数据库列表失败")
console.error(err)
} finally {
setLoading(false)
}
}
fetchDatabases()
}, [])
// 获取数据库结构
useEffect(() => {
if (!selectedDatabase) return
const fetchDatabaseStructure = async () => {
try {
setLoading(true)
setStructure(null)
const response = await fetch(`/api/database-structure?database=${selectedDatabase}`)
const result = await response.json()
if (result.success) {
setStructure(result.data)
setError(null)
} else {
setError(result.message || "获取数据库结构失败")
}
} catch (err) {
setError("获取数据库结构失败")
console.error(err)
} finally {
setLoading(false)
}
}
fetchDatabaseStructure()
}, [selectedDatabase])
// 获取字段类型的简化描述
const getSimplifiedType = (type: string) => {
if (type.includes("int")) return "Integer"
if (type.includes("varchar") || type.includes("text") || type.includes("char")) return "String"
if (type.includes("datetime") || type.includes("timestamp")) return "DateTime"
if (type.includes("date")) return "Date"
if (type.includes("decimal") || type.includes("float") || type.includes("double")) return "Decimal"
if (type.includes("bool")) return "Boolean"
if (type.includes("json")) return "JSON"
if (type.includes("blob")) return "Binary"
return type
}
// 获取字段类型的标签颜色
const getTypeColor = (type: string) => {
if (type.includes("int")) return "bg-blue-100 text-blue-800"
if (type.includes("varchar") || type.includes("text") || type.includes("char")) return "bg-green-100 text-green-800"
if (type.includes("datetime") || type.includes("timestamp") || type.includes("date"))
return "bg-purple-100 text-purple-800"
if (type.includes("decimal") || type.includes("float") || type.includes("double"))
return "bg-yellow-100 text-yellow-800"
if (type.includes("bool")) return "bg-orange-100 text-orange-800"
if (type.includes("json")) return "bg-indigo-100 text-indigo-800"
if (type.includes("blob")) return "bg-red-100 text-red-800"
return "bg-gray-100 text-gray-800"
}
// 导出数据库结构为JSON
const exportDatabaseStructure = () => {
if (!structure) return
const dataStr = JSON.stringify(structure, null, 2)
const dataUri = `data:application/json;charset=utf-8,${encodeURIComponent(dataStr)}`
const exportFileDefaultName = `${selectedDatabase}-structure.json`
const linkElement = document.createElement("a")
linkElement.setAttribute("href", dataUri)
linkElement.setAttribute("download", exportFileDefaultName)
linkElement.click()
}
// 刷新数据库结构
const refreshDatabaseStructure = async () => {
if (!selectedDatabase) return
try {
setLoading(true)
setStructure(null)
const response = await fetch(`/api/database-structure?database=${selectedDatabase}`)
const result = await response.json()
if (result.success) {
setStructure(result.data)
setError(null)
} else {
setError(result.message || "刷新数据库结构失败")
}
} catch (err) {
setError("刷新数据库结构失败")
console.error(err)
} finally {
setLoading(false)
}
}
return (
<Card className="w-full">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={refreshDatabaseStructure}
disabled={loading || !selectedDatabase}
>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" size="sm" onClick={exportDatabaseStructure} disabled={!structure}>
<Download className="h-4 w-4 mr-2" />
JSON
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{loading && (
<div className="space-y-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)}
{error && (
<div className="p-4 bg-red-50 text-red-800 rounded-md">
<p>{error}</p>
</div>
)}
{!loading && !error && (
<div className="space-y-4">
<div className="flex space-x-2 overflow-x-auto pb-2">
{databases.map((db) => (
<Button
key={db}
variant={selectedDatabase === db ? "default" : "outline"}
size="sm"
onClick={() => setSelectedDatabase(db)}
>
<Database className="h-4 w-4 mr-2" />
{db}
</Button>
))}
</div>
{structure && (
<div>
<h3 className="text-lg font-medium mb-2">: {selectedDatabase}</h3>
<p className="text-sm text-muted-foreground mb-4"> {Object.keys(structure).length} </p>
<Accordion type="multiple" className="w-full">
{Object.entries(structure).map(([tableName, fields]) => (
<AccordionItem key={tableName} value={tableName}>
<AccordionTrigger className="hover:bg-muted/50 px-4">
<div className="flex items-center">
<TableIcon className="h-4 w-4 mr-2" />
<span>{tableName}</span>
<Badge className="ml-2 bg-gray-100 text-gray-800">{fields.length} </Badge>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={index}>
<TableCell className="font-medium">
{field.Field}
{field.Key === "PRI" && <Key className="h-3 w-3 ml-1 inline text-amber-500" />}
</TableCell>
<TableCell>
<Badge className={getTypeColor(field.Type)}>{getSimplifiedType(field.Type)}</Badge>
<span className="text-xs text-muted-foreground ml-2">{field.Type}</span>
</TableCell>
<TableCell>
{field.Null === "YES" ? (
<Badge variant="outline"></Badge>
) : (
<Badge className="bg-red-100 text-red-800"></Badge>
)}
</TableCell>
<TableCell>
{field.Key === "PRI" && <Badge></Badge>}
{field.Key === "UNI" && <Badge className="bg-blue-100 text-blue-800"></Badge>}
{field.Key === "MUL" && (
<Badge className="bg-purple-100 text-purple-800"></Badge>
)}
</TableCell>
<TableCell>
{field.Default !== null ? (
field.Default
) : (
<span className="text-muted-foreground">NULL</span>
)}
</TableCell>
<TableCell>{field.Extra}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,240 @@
"use client"
import { useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Search, FileDown, Eye, RefreshCw } from "lucide-react"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
interface IntegrationRecord {
id: string
sourceId: string
sourceName: string
startTime: Date
endTime: Date
status: "success" | "failed" | "partial" | "running"
totalRecords: number
successRecords: number
failedRecords: number
notes: string
}
export function IntegrationHistory() {
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
// 模拟数据
const integrationRecords: IntegrationRecord[] = [
{
id: "1",
sourceId: "1",
sourceName: "CRM系统用户数据",
startTime: new Date(2023, 6, 15, 14, 30),
endTime: new Date(2023, 6, 15, 14, 45),
status: "success",
totalRecords: 5280,
successRecords: 5280,
failedRecords: 0,
notes: "完全同步成功",
},
{
id: "2",
sourceId: "2",
sourceName: "电商平台订单数据",
startTime: new Date(2023, 6, 15, 10, 15),
endTime: new Date(2023, 6, 15, 10, 40),
status: "partial",
totalRecords: 12500,
successRecords: 12350,
failedRecords: 150,
notes: "部分记录格式错误",
},
{
id: "3",
sourceId: "3",
sourceName: "营销活动参与用户",
startTime: new Date(2023, 6, 14, 9, 45),
endTime: new Date(2023, 6, 14, 9, 50),
status: "success",
totalRecords: 875,
successRecords: 875,
failedRecords: 0,
notes: "同步成功",
},
{
id: "4",
sourceId: "4",
sourceName: "APP用户行为数据",
startTime: new Date(2023, 6, 10, 16, 20),
endTime: new Date(2023, 6, 10, 16, 35),
status: "failed",
totalRecords: 8500,
successRecords: 0,
failedRecords: 8500,
notes: "API连接超时",
},
{
id: "5",
sourceId: "5",
sourceName: "社交媒体用户数据",
startTime: new Date(2023, 6, 15, 8, 0),
endTime: new Date(2023, 6, 15, 8, 15),
status: "success",
totalRecords: 3200,
successRecords: 3200,
failedRecords: 0,
notes: "同步成功",
},
{
id: "6",
sourceId: "7",
sourceName: "新用户注册数据",
startTime: new Date(2023, 6, 15, 16, 0),
endTime: null,
status: "running",
totalRecords: 0,
successRecords: 0,
failedRecords: 0,
notes: "首次同步进行中",
},
]
// 过滤记录
const filteredRecords = integrationRecords.filter((record) => {
const matchesSearch = record.sourceName.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === "all" || record.status === statusFilter
return matchesSearch && matchesStatus
})
const formatDate = (date: Date | null) => {
if (!date) return "进行中"
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
const getStatusBadge = (status: IntegrationRecord["status"]) => {
switch (status) {
case "success":
return <Badge className="bg-green-100 text-green-800"></Badge>
case "failed":
return <Badge className="bg-red-100 text-red-800"></Badge>
case "partial":
return <Badge className="bg-yellow-100 text-yellow-800"></Badge>
case "running":
return <Badge className="bg-blue-100 text-blue-800"></Badge>
}
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex space-x-2">
<div className="relative w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索数据源..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="success"></SelectItem>
<SelectItem value="failed"></SelectItem>
<SelectItem value="partial"></SelectItem>
<SelectItem value="running"></SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRecords.map((record) => (
<TableRow key={record.id}>
<TableCell className="font-medium">{record.sourceName}</TableCell>
<TableCell>{formatDate(record.startTime)}</TableCell>
<TableCell>{formatDate(record.endTime)}</TableCell>
<TableCell>{getStatusBadge(record.status)}</TableCell>
<TableCell>{record.totalRecords.toLocaleString()}</TableCell>
<TableCell>{record.successRecords.toLocaleString()}</TableCell>
<TableCell>{record.failedRecords.toLocaleString()}</TableCell>
<TableCell>{record.notes}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="icon">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon">
<FileDown className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">1</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}

104
components/device-grid.tsx Normal file
View File

@@ -0,0 +1,104 @@
"use client"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Smartphone, Battery, Users, MessageCircle } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
export interface Device {
id: string
imei: string
name: string
remark: string
status: "online" | "offline"
battery: number
wechatId: string
friendCount: number
todayAdded: number
messageCount: number
lastActive: string
addFriendStatus: "normal" | "abnormal"
}
interface DeviceGridProps {
devices: Device[]
selectable?: boolean
selectedDevices?: string[]
onSelect?: (deviceIds: string[]) => void
deviceStatuses?: Record<string, { status: "online" | "offline"; battery: number }>
}
export function DeviceGrid({ devices, selectable, selectedDevices, onSelect, deviceStatuses }: DeviceGridProps) {
return (
<div className="grid grid-cols-2 gap-4">
{devices.map((device) => {
const currentStatus = deviceStatuses?.[device.id] || device
return (
<Card
key={device.id}
className={`p-4 relative ${selectable && selectedDevices?.includes(device.id) ? "ring-2 ring-blue-500" : ""}`}
>
{selectable && (
<div className="absolute top-2 left-2">
<Checkbox
checked={selectedDevices?.includes(device.id)}
onCheckedChange={(checked) => {
if (checked) {
onSelect?.([...(selectedDevices ?? []), device.id])
} else {
onSelect?.(selectedDevices?.filter((id) => id !== device.id) ?? [])
}
}}
/>
</div>
)}
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<Badge variant={currentStatus.status === "online" ? "success" : "secondary"}>
{currentStatus.status === "online" ? "在线" : "离线"}
</Badge>
<div className="flex items-center space-x-2">
<Battery className={`w-4 h-4 ${currentStatus.battery < 20 ? "text-red-500" : "text-green-500"}`} />
<span className="text-sm">{currentStatus.battery}%</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Smartphone className="w-4 h-4 text-gray-400" />
<div>
<div className="text-sm font-medium">{device.name}</div>
<div className="text-xs text-gray-500">IMEI-{device.imei}</div>
</div>
</div>
{device.remark && <div className="text-xs text-gray-500">: {device.remark}</div>}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-1">
<Users className="w-4 h-4 text-gray-400" />
<span className="text-sm">{device.friendCount}</span>
</div>
<div className="flex items-center space-x-1">
<MessageCircle className="w-4 h-4 text-gray-400" />
<span className="text-sm">{device.messageCount}</span>
</div>
</div>
<div className="text-xs text-gray-500">
<div>{device.wechatId}</div>
<div>{device.todayAdded}</div>
<div>
<Badge variant={device.addFriendStatus === "normal" ? "success" : "destructive"}>
{device.addFriendStatus === "normal" ? "正常" : "异常"}
</Badge>
</div>
</div>
</div>
</Card>
)
})}
</div>
)
}

View File

@@ -0,0 +1,176 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Loader2, FileText, AlertTriangle } from "lucide-react"
import { generateDocx } from "@/lib/documentation/docx-generator"
interface DocumentSection {
title: string
path: string
description: string
screenshot: string
}
interface DocumentGeneratorProps {
pages: Array<{ path: string; title: string; description: string }>
screenshots: Record<string, string>
onDocumentGenerated: (url: string) => void
}
export default function DocumentGenerator({ pages, screenshots, onDocumentGenerated }: DocumentGeneratorProps) {
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sections, setSections] = useState<DocumentSection[]>([])
const [descriptions, setDescriptions] = useState<Record<string, string>>({})
// 初始化页面描述
useEffect(() => {
const initialDescriptions: Record<string, string> = {}
pages.forEach((page) => {
initialDescriptions[page.path] = page.description || ""
})
setDescriptions(initialDescriptions)
}, [pages])
// 更新页面描述
const updateDescription = (path: string, description: string) => {
setDescriptions((prev) => ({
...prev,
[path]: description,
}))
}
// 准备文档部分
useEffect(() => {
const newSections = pages
.filter((page) => screenshots[page.path])
.map((page) => ({
title: page.title,
path: page.path,
description: descriptions[page.path] || page.description || "",
screenshot: screenshots[page.path],
}))
setSections(newSections)
}, [pages, screenshots, descriptions])
// 生成文档
const generateDocument = async () => {
if (sections.length === 0) {
setError("没有可用的截图,请先捕获页面截图")
return
}
setIsGenerating(true)
setError(null)
try {
console.log("开始生成文档...")
console.log("文档部分数量:", sections.length)
// 准备文档数据
const documentData = {
title: "用户数据资产中台使用手册",
author: "系统管理员",
date: new Date().toLocaleDateString("zh-CN"),
sections: sections,
}
console.log("文档数据准备完成")
// 生成Word文档
const docxUrl = await generateDocx(documentData)
console.log("文档生成成功:", docxUrl)
// 调用回调函数
onDocumentGenerated(docxUrl)
} catch (error) {
console.error("生成文档时出错:", error)
setError(`生成文档时出错: ${error instanceof Error ? error.message : String(error)}`)
} finally {
setIsGenerating(false)
}
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold"></h2>
<Button
onClick={generateDocument}
disabled={isGenerating || sections.length === 0}
className="flex items-center gap-2"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<FileText className="h-4 w-4" />
</>
)}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{sections.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400 mt-1"></p>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-gray-500">
{sections.length}
</p>
{sections.map((section) => (
<Card key={section.path} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="md:w-1/3">
<div className="border rounded-md overflow-hidden">
<img
src={section.screenshot || "/placeholder.svg"}
alt={section.title}
className="w-full h-auto object-contain"
/>
</div>
<p className="text-sm font-medium mt-2">{section.title}</p>
<p className="text-xs text-gray-500">{section.path}</p>
</div>
<div className="md:w-2/3">
<div className="space-y-2">
<Label htmlFor={`desc-${section.path}`}></Label>
<Textarea
id={`desc-${section.path}`}
value={descriptions[section.path] || ""}
onChange={(e) => updateDescription(section.path, e.target.value)}
placeholder="输入此页面的详细描述"
rows={6}
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,100 @@
"use client"
import { Button } from "@/components/ui/button"
import { FileDown, FileText, Copy } from "lucide-react"
interface ExportControlsProps {
documentUrl: string | null
onError: (error: string) => void
}
export default function ExportControls({ documentUrl, onError }: ExportControlsProps) {
const handleDownload = () => {
if (!documentUrl) {
onError("没有可下载的文档")
return
}
try {
// 创建一个临时链接并触发下载
const link = document.createElement("a")
link.href = documentUrl
link.download = "用户数据资产中台使用手册.docx"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (err) {
onError(`下载文档时出错: ${err instanceof Error ? err.message : String(err)}`)
}
}
const handlePreview = () => {
if (!documentUrl) {
onError("没有可预览的文档")
return
}
try {
// 在新窗口中打开文档
window.open(documentUrl, "_blank")
} catch (err) {
onError(`预览文档时出错: ${err instanceof Error ? err.message : String(err)}`)
}
}
const handleCopyLink = () => {
if (!documentUrl) {
onError("没有可复制的文档链接")
return
}
try {
// 复制链接到剪贴板
navigator.clipboard
.writeText(documentUrl)
.then(() => {
alert("文档链接已复制到剪贴板")
})
.catch((err) => {
onError(`复制链接时出错: ${err instanceof Error ? err.message : String(err)}`)
})
} catch (err) {
onError(`复制链接时出错: ${err instanceof Error ? err.message : String(err)}`)
}
}
return (
<div className="space-y-6">
{documentUrl ? (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-3">
<Button onClick={handleDownload} className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
<span>Word文档</span>
</Button>
<Button onClick={handlePreview} variant="outline" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<span></span>
</Button>
<Button onClick={handleCopyLink} variant="outline" className="flex items-center gap-2">
<Copy className="h-4 w-4" />
<span></span>
</Button>
</div>
<div className="p-4 bg-gray-50 rounded-md border text-sm">
<p className="font-medium mb-2"></p>
<p className="text-gray-500 break-all">{documentUrl}</p>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<p></p>
<p className="text-sm mt-2"></p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { captureScreenshot } from "@/lib/documentation/screenshot-service"
interface ScreenshotCaptureProps {
pages: Array<{ path: string; title: string; description: string }>
isCapturing: boolean
onScreenshotCaptured: (path: string, dataUrl: string) => void
onCaptureComplete: () => void
onError: (error: string) => void
}
export default function ScreenshotCapture({
pages,
isCapturing,
onScreenshotCaptured,
onCaptureComplete,
onError,
}: ScreenshotCaptureProps) {
const [currentPageIndex, setCurrentPageIndex] = useState(-1)
const [currentStatus, setCurrentStatus] = useState("")
const iframeRef = useRef<HTMLIFrameElement>(null)
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
useEffect(() => {
if (isCapturing && pages.length > 0) {
// 开始捕获过程
setCurrentPageIndex(0)
} else if (!isCapturing) {
setCurrentPageIndex(-1)
setCurrentStatus("")
}
}, [isCapturing, pages])
useEffect(() => {
if (currentPageIndex >= 0 && currentPageIndex < pages.length) {
const capturePage = async () => {
const page = pages[currentPageIndex]
const fullPath = `${baseUrl}${page.path}`
try {
setCurrentStatus(`正在加载页面: ${page.title}`)
// 等待iframe加载完成
if (iframeRef.current) {
iframeRef.current.src = fullPath
// 监听iframe加载完成事件
const handleLoad = async () => {
try {
setCurrentStatus(`正在捕获页面: ${page.title}`)
// 给页面一些时间完全渲染
await new Promise((resolve) => setTimeout(resolve, 2000))
// 捕获截图
const screenshot = await captureScreenshot(iframeRef.current!)
onScreenshotCaptured(page.path, screenshot)
// 移动到下一页
if (currentPageIndex < pages.length - 1) {
setCurrentPageIndex(currentPageIndex + 1)
} else {
// 所有页面都已捕获
onCaptureComplete()
}
} catch (err) {
onError(`捕获页面 ${page.title} 截图时出错: ${err instanceof Error ? err.message : String(err)}`)
}
}
iframeRef.current.onload = handleLoad
}
} catch (err) {
onError(`加载页面 ${page.title} 时出错: ${err instanceof Error ? err.message : String(err)}`)
// 尝试继续下一页
if (currentPageIndex < pages.length - 1) {
setCurrentPageIndex(currentPageIndex + 1)
} else {
onCaptureComplete()
}
}
}
capturePage()
}
}, [currentPageIndex, pages, onScreenshotCaptured, onCaptureComplete, onError, baseUrl])
return (
<div className="space-y-4">
{isCapturing && currentPageIndex >= 0 && currentPageIndex < pages.length && (
<div className="space-y-2">
<p className="text-sm text-gray-500">{currentStatus}</p>
<p className="text-sm font-medium">
: {currentPageIndex + 1} / {pages.length} - {pages[currentPageIndex].title}
</p>
<Card className="overflow-hidden border-2 border-blue-200 bg-blue-50">
<div className="relative pt-[56.25%]">
{" "}
{/* 16:9 宽高比 */}
<iframe ref={iframeRef} className="absolute top-0 left-0 w-full h-full" title="页面预览" />
</div>
</Card>
</div>
)}
{!isCapturing && (
<div className="text-center py-8 text-gray-500">
<p>"开始捕获"</p>
<p className="text-sm mt-2"> {pages.length} </p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,11 @@
import type React from "react"
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
<path d="M14.94 5.19A4.38 4.38 0 0016 2a4.44 4.44 0 00-3 1.52 4.17 4.17 0 00-1 3.09 3.69 3.69 0 002.94-1.42zm2.52 7.44a4.51 4.51 0 012.16-3.81 4.66 4.66 0 00-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87a4.92 4.92 0 00-4.14 2.53C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 001.38-2.85 4.41 4.41 0 01-2.68-4.04z" />
</svg>
)
}
export default AppleIcon

View File

@@ -0,0 +1,12 @@
import type React from "react"
export function WeChatIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.81-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.595-6.348zM5.959 5.48c.609 0 1.104.498 1.104 1.112 0 .612-.495 1.11-1.104 1.11-.612 0-1.108-.498-1.108-1.11 0-.614.496-1.112 1.108-1.112zm5.315 0c.61 0 1.107.498 1.107 1.112 0 .612-.497 1.11-1.107 1.11-.611 0-1.105-.498-1.105-1.11 0-.614.494-1.112 1.105-1.112z" />
<path d="M23.002 15.816c0-3.309-3.136-6-7-6-3.863 0-7 2.691-7 6 0 3.31 3.137 6 7 6 .814 0 1.601-.099 2.338-.285a.7.7 0 0 1 .579.08l1.5.87a.267.267 0 0 0 .135.044c.13 0 .236-.108.236-.241 0-.06-.023-.118-.038-.17l-.309-1.167a.476.476 0 0 1 .172-.534c1.645-1.17 2.387-2.835 2.387-4.597zm-9.498-1.19c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908zm4.998 0c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908z" />
</svg>
)
}
export default WeChatIcon

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

View File

@@ -0,0 +1,41 @@
"use client"
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts"
const data = [
{ name: "用户价值", value: 25 },
{ name: "人口属性", value: 18 },
{ name: "兴趣爱好", value: 22 },
{ name: "流失风险", value: 12 },
{ name: "地理位置", value: 15 },
{ name: "活跃时间", value: 10 },
{ name: "消费能力", value: 14 },
{ name: "渠道来源", value: 12 },
]
const COLORS = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042", "#8884d8", "#82ca9d", "#ffc658", "#8dd1e1"]
export function TagCategoryChart() {
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,149 @@
"use client"
import { useEffect, useRef } from "react"
import * as d3 from "d3"
interface Node {
id: string
name: string
category: string
value: number
}
interface Link {
source: string
target: string
value: number
}
const mockData = {
nodes: [
{ id: "1", name: "高价值用户", category: "用户价值", value: 20 },
{ id: "2", name: "90后", category: "人口属性", value: 15 },
{ id: "3", name: "游戏爱好者", category: "兴趣爱好", value: 18 },
{ id: "4", name: "流失风险高", category: "流失风险", value: 12 },
{ id: "5", name: "北京地区", category: "地理位置", value: 10 },
{ id: "6", name: "周末活跃", category: "活跃时间", value: 14 },
{ id: "7", name: "高消费", category: "消费能力", value: 16 },
{ id: "8", name: "App渠道", category: "渠道来源", value: 8 },
],
links: [
{ source: "1", target: "7", value: 5 },
{ source: "2", target: "3", value: 8 },
{ source: "2", target: "6", value: 3 },
{ source: "3", target: "7", value: 6 },
{ source: "4", target: "6", value: 4 },
{ source: "5", target: "8", value: 2 },
{ source: "6", target: "3", value: 7 },
{ source: "7", target: "8", value: 3 },
{ source: "1", target: "3", value: 5 },
{ source: "2", target: "5", value: 4 },
{ source: "4", target: "1", value: 6 },
],
}
const categoryColors = {
: "#0088FE",
: "#00C49F",
: "#FFBB28",
: "#FF8042",
: "#8884d8",
: "#82ca9d",
: "#ffc658",
: "#8dd1e1",
}
export function TagRelationshipGraph() {
const svgRef = useRef<SVGSVGElement>(null)
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll("*").remove()
const width = svgRef.current.clientWidth
const height = svgRef.current.clientHeight
// 创建力导向图
const simulation = d3
.forceSimulation(mockData.nodes as d3.SimulationNodeDatum[])
.force(
"link",
d3
.forceLink(mockData.links)
.id((d: any) => d.id)
.distance(100),
)
.force("charge", d3.forceManyBody().strength(-200))
.force("center", d3.forceCenter(width / 2, height / 2))
// 绘制连线
const link = svg
.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(mockData.links)
.join("line")
.attr("stroke-width", (d) => Math.sqrt(d.value))
// 绘制节点
const node = svg
.append("g")
.selectAll("circle")
.data(mockData.nodes)
.join("circle")
.attr("r", (d) => d.value * 0.8)
.attr("fill", (d) => categoryColors[d.category as keyof typeof categoryColors])
.call(d3.drag<SVGCircleElement, Node>().on("start", dragstarted).on("drag", dragged).on("end", dragended) as any)
// 添加标签
const text = svg
.append("g")
.selectAll("text")
.data(mockData.nodes)
.join("text")
.text((d) => d.name)
.attr("font-size", 10)
.attr("dx", 15)
.attr("dy", 4)
// 更新位置
simulation.on("tick", () => {
link
.attr("x1", (d: any) => d.source.x)
.attr("y1", (d: any) => d.source.y)
.attr("x2", (d: any) => d.target.x)
.attr("y2", (d: any) => d.target.y)
node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y)
text.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y)
})
// 拖拽函数
function dragstarted(event: any, d: any) {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
}
function dragged(event: any, d: any) {
d.fx = event.x
d.fy = event.y
}
function dragended(event: any, d: any) {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
}
return () => {
simulation.stop()
}
}, [])
return <svg ref={svgRef} width="100%" height="100%" />
}

View File

@@ -0,0 +1,49 @@
"use client"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
const data = [
{
name: "营销系统",
使用标签数: 85,
},
{
name: "推荐系统",
使用标签数: 72,
},
{
name: "客服系统",
使用标签数: 45,
},
{
name: "风控系统",
使用标签数: 38,
},
{
name: "内容系统",
使用标签数: 65,
},
]
export function TagUsageStats() {
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="使用标签数" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,277 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertTriangle, Check } from "lucide-react"
interface RuleEditorProps {
ruleId: string | null
}
export function RuleEditor({ ruleId }: RuleEditorProps) {
const [rule, setRule] = useState({
name: "",
description: "",
targetTag: "",
condition: "",
priority: "2",
status: "draft",
sql: "",
})
const [activeTab, setActiveTab] = useState("visual")
const [validationResult, setValidationResult] = useState<{
isValid: boolean
message: string
} | null>(null)
useEffect(() => {
if (ruleId) {
// 这里应该是获取规则详情的逻辑
// 模拟从服务器获取数据
setTimeout(() => {
setRule({
name: "高价值用户识别",
description: "根据用户消费金额和频次识别高价值用户",
targetTag: "高价值用户",
condition: "消费金额 > 5000 AND 消费频次 > 10",
priority: "1",
status: "active",
sql: "SELECT user_id FROM user_behavior WHERE total_amount > 5000 AND purchase_count > 10",
})
}, 300)
}
}, [ruleId])
const handleValidate = () => {
// 模拟验证逻辑
if (rule.condition.trim() === "") {
setValidationResult({
isValid: false,
message: "规则条件不能为空",
})
return
}
if (activeTab === "sql" && rule.sql.trim() === "") {
setValidationResult({
isValid: false,
message: "SQL 语句不能为空",
})
return
}
// 模拟验证成功
setValidationResult({
isValid: true,
message: "规则验证通过,可以保存或执行",
})
}
const handleSave = () => {
// 这里应该是保存规则的逻辑
console.log("保存规则:", rule)
// 模拟保存成功
alert("规则保存成功")
}
const handleExecute = () => {
// 这里应该是执行规则的逻辑
console.log("执行规则:", rule)
// 模拟执行成功
alert("规则执行成功,影响用户 1250 人")
}
return (
<Card>
<CardHeader>
<CardTitle>{ruleId ? "编辑规则" : "创建新规则"}</CardTitle>
<CardDescription>
{ruleId ? "修改现有标签规则" : "创建新的标签生成规则"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<Label htmlFor="rule-name"></Label>
<Input
id="rule-name"
value={rule.name}
onChange={(e) => setRule({ ...rule, name: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="rule-description"></Label>
<Textarea
id="rule-description"
value={rule.description}
onChange={(e) => setRule({ ...rule, description: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="target-tag"></Label>
<Input
id="target-tag"
value={rule.targetTag}
onChange={(e) => setRule({ ...rule, targetTag: e.target.value })}
className="mt-1"
/>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="rule-priority"></Label>
<Select
value={rule.priority}
onValueChange={(value) => setRule({ ...rule, priority: value })}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="选择优先级" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 - </SelectItem>
<SelectItem value="2">2 - </SelectItem>
<SelectItem value="3">3 - </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="rule-status"></Label>
<div className="flex items-center space-x-2 mt-1">
<Switch
id="rule-status"
checked={rule.status === "active"}
onCheckedChange={(checked) => setRule({ ...rule, status: checked ? "active" : "draft" })}
/>
<Label htmlFor="rule-status">
{rule.status === "active" ? "启用" : "草稿"}
</Label>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<Label></Label>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="visual"></TabsTrigger>
<TabsTrigger value="sql">SQL </TabsTrigger>
</TabsList>
<TabsContent value="visual" className="space-y-4 mt-4">
<Textarea
value={rule.condition}
onChange={(e) => setRule({ ...rule, condition: e.target.value })}
placeholder="例如: 消费金额 > 5000 AND 消费频次 > 10"
className="min-h-[200px]"
/>
<div className="bg-gray-50 p-4 rounded-md">
<h4 className="text-sm font-medium mb-2"></h4>
<div className="grid grid-cols-3 gap-2">
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " 消费金额" })}>
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " 消费频次" })}>
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " 最近登录时间" })}>
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " 活跃度" })}>
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " 注册时间" })}>
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " 年龄" })}>
</Button>
</div>
<h4 className="text-sm font-medium mt-4 mb-2"></h4>
<div className="grid grid-cols-4 gap-2">
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " >" })}>
(>)
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " <" })}>
(<)
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " =" })}>
(=)
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " !=" })}>
(!=)
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " AND" })}>
(AND)
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " OR" })}>
(OR)
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " NOT" })}>
(NOT)
</Button>
<Button variant="outline" size="sm" onClick={() => setRule({ ...rule, condition: rule.condition + " IN" })}>
(IN)
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="sql" className="space-y-4 mt-4">
<Textarea
value={rule.sql}
onChange={(e) => setRule({ ...rule, sql: e.target.value })}
placeholder="输入 SQL 查询语句"
className="min-h-[200px] font-mono"
/>
<div className="bg-gray-50 p-4 rounded-md">
<h4 className="text-sm font-medium mb-2">SQL </h4>
<div className="text-xs font-mono bg-gray-100 p-2 rounded">
SELECT user_id FROM user_behavior WHERE total_amount > 5000 AND purchase_count > 10
</div>
<p className="text-xs text-gray-500 mt-2">
SQL user_id
</p>
</div>
</TabsContent>
</Tabs>
</div>
{validationResult && (
<Alert variant={validationResult.isValid ? "default" : "destructive"}>
{validationResult.isValid ? (
<Check className="h-4 w-4" />
) : (
<AlertTriangle className="h-4 w-4" />
)}
<AlertTitle>{validationResult.isValid ? "验证通过" : "验证失败"}</AlertTitle>
<AlertDescription>{validationResult.message}</AlertDescription>
</Alert>
)}
</CardContent>
<CardFooter className="flex justify-between">
<div className="flex space-x-2">
<Button variant="outline" onClick={handleValidate}>
</Button>
</div>
<div className="flex space-x-2">
<Button variant="outline"></Button>
<Button onClick={handleSave}></Button>
<Button variant="default" className="bg-green-600 hover:bg-green-700" onClick={handleExecute}>
</Button>
</div>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,258 @@
"use client"
import { useState } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, Filter, Calendar, FileText } from "lucide-react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
interface ExecutionRecord {
id: string
ruleName: string
executionTime: string
duration: string
status: "success" | "failed" | "running"
affectedUsers: number
executedBy: string
}
const mockExecutionHistory: ExecutionRecord[] = [
{
id: "exec-001",
ruleName: "高价值用户识别",
executionTime: "2023-07-21 15:30:45",
duration: "45秒",
status: "success",
affectedUsers: 1250,
executedBy: "系统自动",
},
{
id: "exec-002",
ruleName: "游戏爱好者标记",
executionTime: "2023-07-21 14:45:12",
duration: "38秒",
status: "success",
affectedUsers: 2840,
executedBy: "系统自动",
},
{
id: "exec-003",
ruleName: "流失风险预警",
executionTime: "2023-07-21 13:20:33",
duration: "52秒",
status: "success",
affectedUsers: 890,
executedBy: "系统自动",
},
{
id: "exec-004",
ruleName: "高价值用户识别",
executionTime: "2023-07-20 15:30:18",
duration: "47秒",
status: "success",
affectedUsers: 1235,
executedBy: "系统自动",
},
{
id: "exec-005",
ruleName: "周末活跃用户",
executionTime: "2023-07-20 12:15:42",
duration: "1分15秒",
status: "success",
affectedUsers: 3520,
executedBy: "admin",
},
{
id: "exec-006",
ruleName: "潜在高转化用户",
executionTime: "2023-07-19 16:45:30",
duration: "2分08秒",
status: "failed",
affectedUsers: 0,
executedBy: "admin",
},
{
id: "exec-007",
ruleName: "高价值用户识别",
executionTime: "2023-07-19 15:30:22",
duration: "46秒",
status: "success",
affectedUsers: 1228,
executedBy: "系统自动",
},
]
export function RuleExecutionHistory() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedRecord, setSelectedRecord] = useState<ExecutionRecord | null>(null)
const [isDetailsOpen, setIsDetailsOpen] = useState(false)
const filteredHistory = mockExecutionHistory.filter(
(record) =>
record.ruleName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.executedBy.toLowerCase().includes(searchQuery.toLowerCase()),
)
const getStatusBadge = (status: ExecutionRecord["status"]) => {
switch (status) {
case "success":
return <Badge className="bg-green-100 text-green-800"></Badge>
case "failed":
return <Badge className="bg-red-100 text-red-800"></Badge>
case "running":
return <Badge className="bg-blue-100 text-blue-800"></Badge>
}
}
const handleViewDetails = (record: ExecutionRecord) => {
setSelectedRecord(record)
setIsDetailsOpen(true)
}
return (
<>
<Card>
<CardContent className="p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-medium"></h3>
<div className="flex space-x-2">
<div className="relative w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="搜索规则名称或执行人..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon">
<Calendar className="h-4 w-4" />
</Button>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredHistory.map((record) => (
<TableRow key={record.id}>
<TableCell className="font-mono text-xs">{record.id}</TableCell>
<TableCell>{record.ruleName}</TableCell>
<TableCell>{record.executionTime}</TableCell>
<TableCell>{record.duration}</TableCell>
<TableCell>{getStatusBadge(record.status)}</TableCell>
<TableCell>{record.affectedUsers.toLocaleString()}</TableCell>
<TableCell>{record.executedBy}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleViewDetails(record)}>
<FileText className="h-4 w-4 mr-2" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 执行详情对话框 */}
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{selectedRecord && (
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">ID</p>
<p className="font-mono">{selectedRecord.id}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p>{selectedRecord.ruleName}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p>{selectedRecord.executionTime}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p>{selectedRecord.duration}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p>{getStatusBadge(selectedRecord.status)}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p>{selectedRecord.affectedUsers.toLocaleString()}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p>{selectedRecord.executedBy}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-500 mb-2"></p>
<div className="bg-gray-50 p-4 rounded-md font-mono text-xs h-40 overflow-y-auto">
{selectedRecord.status === "success" ? (
<>
<p>
[INFO] {selectedRecord.executionTime} - "{selectedRecord.ruleName}"
</p>
<p>[INFO] {selectedRecord.executionTime} - </p>
<p>[INFO] {selectedRecord.executionTime} - </p>
<p>
[INFO] {selectedRecord.executionTime} - {selectedRecord.affectedUsers}{" "}
</p>
<p>[INFO] {selectedRecord.executionTime} - </p>
<p>[INFO] {selectedRecord.executionTime} - </p>
<p>
[INFO] {selectedRecord.executionTime} - {selectedRecord.duration}
</p>
</>
) : (
<>
<p>
[INFO] {selectedRecord.executionTime} - "{selectedRecord.ruleName}"
</p>
<p>[INFO] {selectedRecord.executionTime} - </p>
<p>[INFO] {selectedRecord.executionTime} - </p>
<p>[ERROR] {selectedRecord.executionTime} - 查询执行失败: 数据库连接超时</p>
<p>
[ERROR] {selectedRecord.executionTime} - {selectedRecord.duration}
</p>
</>
)}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,307 @@
"use client"
import { useState } from "react"
import { Card, CardContent, 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 { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { CalendarIcon, Plus, Tag, X } from "lucide-react"
import { format } from "date-fns"
import { cn } from "@/lib/utils"
export function TaskCreation() {
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [newTag, setNewTag] = useState("")
const [date, setDate] = useState<Date>()
const addTag = () => {
if (newTag && !selectedTags.includes(newTag)) {
setSelectedTags([...selectedTags, newTag])
setNewTag("")
}
}
const removeTag = (tag: string) => {
setSelectedTags(selectedTags.filter((t) => t !== tag))
}
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="basic" className="space-y-4">
<TabsList>
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="target"></TabsTrigger>
<TabsTrigger value="action"></TabsTrigger>
<TabsTrigger value="schedule"></TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="task-name"></Label>
<Input id="task-name" placeholder="输入任务名称" />
</div>
<div className="space-y-2">
<Label htmlFor="task-description"></Label>
<Textarea id="task-description" placeholder="输入任务描述" />
</div>
<div className="space-y-2">
<Label htmlFor="platform"></Label>
<Select>
<SelectTrigger id="platform">
<SelectValue placeholder="选择目标平台" />
</SelectTrigger>
<SelectContent>
<SelectItem value="jd"></SelectItem>
<SelectItem value="taobao"></SelectItem>
<SelectItem value="dangdang"></SelectItem>
<SelectItem value="xiaohongshu"></SelectItem>
<SelectItem value="zhihu"></SelectItem>
<SelectItem value="bilibili"></SelectItem>
<SelectItem value="douyin"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<Select defaultValue="medium">
<SelectTrigger id="priority">
<SelectValue placeholder="选择优先级" />
</SelectTrigger>
<SelectContent>
<SelectItem value="high"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="low"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</TabsContent>
<TabsContent value="target" className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2 mb-2">
{selectedTags.map((tag) => (
<Badge key={tag} variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
{tag}
<button onClick={() => removeTag(tag)} className="ml-1">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex space-x-2">
<Input
placeholder="输入标签名称"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTag()}
/>
<Button type="button" size="sm" onClick={addTag}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="user-segment"></Label>
<Select>
<SelectTrigger id="user-segment">
<SelectValue placeholder="选择用户分群" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="new"></SelectItem>
<SelectItem value="high-value"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="user-count"></Label>
<Input id="user-count" type="number" placeholder="输入目标用户数量" />
</div>
<div className="space-y-2">
<Label htmlFor="sampling-method"></Label>
<Select defaultValue="random">
<SelectTrigger id="sampling-method">
<SelectValue placeholder="选择抽样方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="random"></SelectItem>
<SelectItem value="stratified"></SelectItem>
<SelectItem value="systematic"></SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</TabsContent>
<TabsContent value="action" className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="action-type"></Label>
<Select>
<SelectTrigger id="action-type">
<SelectValue placeholder="选择动作类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="login"></SelectItem>
<SelectItem value="purchase"></SelectItem>
<SelectItem value="browse"></SelectItem>
<SelectItem value="search"></SelectItem>
<SelectItem value="content"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="action-params"></Label>
<Textarea id="action-params" placeholder="输入动作参数JSON格式" />
</div>
<div className="space-y-2">
<Label htmlFor="success-criteria"></Label>
<Select>
<SelectTrigger id="success-criteria">
<SelectValue placeholder="选择成功标准" />
</SelectTrigger>
<SelectContent>
<SelectItem value="login-success"></SelectItem>
<SelectItem value="purchase-complete"></SelectItem>
<SelectItem value="browse-time"></SelectItem>
<SelectItem value="search-count"></SelectItem>
<SelectItem value="content-interaction"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Input placeholder="输入新标签名称" />
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="标签分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="behavior"></SelectItem>
<SelectItem value="preference"></SelectItem>
<SelectItem value="value"></SelectItem>
<SelectItem value="lifecycle"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
<Button type="button" size="sm">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Switch id="auto-apply" />
<Label htmlFor="auto-apply"></Label>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="schedule" className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="execution-mode"></Label>
<Select defaultValue="immediate">
<SelectTrigger id="execution-mode">
<SelectValue placeholder="选择执行模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate"></SelectItem>
<SelectItem value="scheduled"></SelectItem>
<SelectItem value="recurring"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn("w-full justify-start text-left font-normal", !date && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP") : "选择日期"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar mode="single" selected={date} onSelect={setDate} initialFocus />
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="execution-time"></Label>
<Input id="execution-time" type="time" />
</div>
<div className="space-y-2">
<Label htmlFor="recurrence-pattern"></Label>
<Select disabled={true}>
<SelectTrigger id="recurrence-pattern">
<SelectValue placeholder="选择重复模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily"></SelectItem>
<SelectItem value="weekly"></SelectItem>
<SelectItem value="monthly"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Switch id="notify-completion" />
<Label htmlFor="notify-completion"></Label>
</div>
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end space-x-2 mt-6">
<Button variant="outline"></Button>
<Button></Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,273 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Pause, AlertCircle, CheckCircle, RefreshCw, Tag, User } from "lucide-react"
export function TaskExecution() {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm">
<Pause className="mr-2 h-4 w-4" />
</Button>
<Button size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center">
<Badge className="bg-blue-100 text-blue-800 mr-2"></Badge>
<span className="text-sm text-muted-foreground">预计剩余时间: 45分钟</span>
</div>
<span className="text-sm font-medium">65%</span>
</div>
<Progress value={65} className="h-2" />
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center space-x-2">
<User className="h-5 w-5 text-blue-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="mt-2">
<span className="text-2xl font-bold">12,500</span>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center space-x-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="mt-2">
<span className="text-2xl font-bold">8,125</span>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center space-x-2">
<Tag className="h-5 w-5 text-purple-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="mt-2">
<span className="text-2xl font-bold">5,840</span>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center space-x-2">
<AlertCircle className="h-5 w-5 text-red-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="mt-2">
<span className="text-2xl font-bold">120</span>
</div>
</div>
</div>
<Tabs defaultValue="logs" className="space-y-4">
<TabsList>
<TabsTrigger value="logs"></TabsTrigger>
<TabsTrigger value="users"></TabsTrigger>
<TabsTrigger value="errors"></TabsTrigger>
</TabsList>
<TabsContent value="logs" className="space-y-4">
<div className="bg-black text-green-400 p-4 rounded-md font-mono text-sm h-60 overflow-y-auto">
<div>[2023-07-15 14:30:15] </div>
<div>[2023-07-15 14:30:16] ...</div>
<div>[2023-07-15 14:30:20] 12,500 </div>
<div>[2023-07-15 14:30:25] #1 (1000 users)</div>
<div>[2023-07-15 14:35:10] #1 处理完成: 成功 950, 50</div>
<div>[2023-07-15 14:35:15] #2 (1000 users)</div>
<div>[2023-07-15 14:40:05] #2 处理完成: 成功 980, 20</div>
<div>[2023-07-15 14:40:10] #3 (1000 users)</div>
<div>[2023-07-15 14:45:00] #3 处理完成: 成功 990, 10</div>
<div>[2023-07-15 14:45:05] #4 (1000 users)</div>
<div>[2023-07-15 14:50:00] #4 处理完成: 成功 995, 5</div>
<div>[2023-07-15 14:50:05] #5 (1000 users)</div>
<div>[2023-07-15 14:55:00] #5 处理完成: 成功 985, 15</div>
<div>[2023-07-15 14:55:05] #6 (1000 users)</div>
<div>[2023-07-15 15:00:00] #6 处理完成: 成功 975, 25</div>
<div>[2023-07-15 15:00:05] #7 (1000 users)</div>
<div>[2023-07-15 15:05:00] #7 处理完成: 成功 990, 10</div>
<div>[2023-07-15 15:05:05] #8 (1000 users)</div>
<div>[2023-07-15 15:10:00] #8 处理完成: 成功 975, 25</div>
<div>[2023-07-15 15:10:05] 8000 , 4500 </div>
<div>[2023-07-15 15:10:10] #9 (1000 users)</div>
</div>
</TabsContent>
<TabsContent value="users" className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>user_12345</TableCell>
<TableCell>2023-07-15 14:32:15</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
</div>
</TableCell>
<TableCell>, 30</TableCell>
</TableRow>
<TableRow>
<TableCell>user_12346</TableCell>
<TableCell>2023-07-15 14:32:18</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
</div>
</TableCell>
<TableCell>, </TableCell>
</TableRow>
<TableRow>
<TableCell>user_12347</TableCell>
<TableCell>2023-07-15 14:32:20</TableCell>
<TableCell>
<Badge className="bg-red-100 text-red-800"></Badge>
</TableCell>
<TableCell>-</TableCell>
<TableCell>, </TableCell>
</TableRow>
<TableRow>
<TableCell>user_12348</TableCell>
<TableCell>2023-07-15 14:32:25</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
</div>
</TableCell>
<TableCell>, </TableCell>
</TableRow>
<TableRow>
<TableCell>user_12349</TableCell>
<TableCell>2023-07-15 14:32:30</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
</div>
</TableCell>
<TableCell>, </TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="errors" className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>err_001</TableCell>
<TableCell>2023-07-15 14:32:20</TableCell>
<TableCell>user_12347</TableCell>
<TableCell></TableCell>
<TableCell>, </TableCell>
</TableRow>
<TableRow>
<TableCell>err_002</TableCell>
<TableCell>2023-07-15 14:33:15</TableCell>
<TableCell>user_12356</TableCell>
<TableCell></TableCell>
<TableCell>, </TableCell>
</TableRow>
<TableRow>
<TableCell>err_003</TableCell>
<TableCell>2023-07-15 14:35:05</TableCell>
<TableCell>user_12378</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
<TableRow>
<TableCell>err_004</TableCell>
<TableCell>2023-07-15 14:38:30</TableCell>
<TableCell>user_12390</TableCell>
<TableCell></TableCell>
<TableCell>访</TableCell>
</TableRow>
<TableRow>
<TableCell>err_005</TableCell>
<TableCell>2023-07-15 14:42:10</TableCell>
<TableCell>user_12405</TableCell>
<TableCell>API限流</TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,197 @@
"use client"
import { useState } from "react"
export function TaskList() {
const [searchQuery, setSearchQuery] = useState("")
const [openSections, setOpenSections] = useState({
taskList: true,
})
const toggleSection = (section) => {
setOpenSections((prev) => ({
...prev,
[section]: !prev[section],
}))
}
// 模拟数据
const tasks = [
{
id: "1",
name: "高价值用户标签更新",
type: "用户价值评估",
status: "running",
progress: 65,
createdAt: "2023-07-15",
lastRun: "2023-07-20",
frequency: "每日",
affectedTags: ["高价值用户", "中价值用户", "低价值用户"],
},
{
id: "2",
name: "行为标签生成",
type: "行为分析",
status: "completed",
progress: 100,
createdAt: "2023-07-10",
lastRun: "2023-07-20",
frequency: "每日",
affectedTags: ["消费地点", "消费时间", "消费菜品"],
},
{
id: "3",
name: "流失风险评估",
type: "风险预警",
status: "paused",
progress: 0,
createdAt: "2023-06-25",
lastRun: "2023-07-15",
frequency: "每周",
affectedTags: ["流失风险高", "流失风险中", "流失风险低"],
},
{
id: "4",
name: "兴趣标签更新",
type: "兴趣分析",
status: "scheduled",
progress: 0,
createdAt: "2023-07-01",
lastRun: "2023-07-18",
frequency: "每周",
affectedTags: ["美食", "旅游", "电影", "音乐"],
},
{
id: "5",
name: "消费能力评估",
type: "用户价值评估",
status: "completed",
progress: 100,
createdAt: "2023-06-20",
lastRun: "2023-07-19",
frequency: "每周",
affectedTags: ["高消费", "中消费", "低消费"],
},
]
return (
<div className="space-y-4">
{/* 任务列表内容 */}
<div className="bg-white rounded-lg shadow">
<div className="p-4">
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-gray-500"></p>
</div>
<div className="border-t">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tasks.map((task) => (
<tr key={task.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{task.name}</div>
<div className="text-sm text-gray-500"> {task.createdAt}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{task.type}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${
task.status === "running"
? "bg-green-100 text-green-800"
: task.status === "completed"
? "bg-blue-100 text-blue-800"
: task.status === "paused"
? "bg-yellow-100 text-yellow-800"
: "bg-gray-100 text-gray-800"
}`}
>
{task.status === "running"
? "运行中"
: task.status === "completed"
? "已完成"
: task.status === "paused"
? "已暂停"
: "已计划"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${
task.status === "completed"
? "bg-blue-600"
: task.status === "running"
? "bg-green-600"
: "bg-gray-400"
}`}
style={{ width: `${task.progress}%` }}
></div>
</div>
<div className="text-xs text-gray-500 mt-1">{task.progress}%</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{task.lastRun}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="#" className="text-indigo-600 hover:text-indigo-900 mr-3">
</a>
{task.status === "running" ? (
<a href="#" className="text-yellow-600 hover:text-yellow-900">
</a>
) : task.status === "paused" || task.status === "scheduled" ? (
<a href="#" className="text-green-600 hover:text-green-900">
</a>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,420 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from "recharts"
import { Download, FileDown, Tag, User, CheckCircle, Clock } from "lucide-react"
export function TaskResults() {
// 模拟数据
const tagDistributionData = [
{ name: "京东活跃用户", value: 5840 },
{ name: "电商偏好", value: 4250 },
{ name: "高消费用户", value: 1850 },
{ name: "品质生活", value: 2100 },
{ name: "数码爱好者", value: 1450 },
]
const userActionData = [
{ name: "登录成功", value: 8125 },
{ name: "浏览商品", value: 6540 },
{ name: "加入购物车", value: 3250 },
{ name: "完成购买", value: 1850 },
{ name: "评价商品", value: 980 },
]
const timeDistributionData = [
{ name: "0-5分钟", count: 2450 },
{ name: "5-10分钟", count: 3650 },
{ name: "10-20分钟", count: 2850 },
{ name: "20-30分钟", count: 1950 },
{ name: "30+分钟", count: 1600 },
]
const COLORS = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042", "#8884D8"]
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<Button variant="outline" size="sm">
<FileDown className="mr-2 h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-medium"></h3>
<div className="flex items-center space-x-2 mt-1">
<Badge className="bg-green-100 text-green-800"></Badge>
<span className="text-sm text-muted-foreground">完成时间: 2023-07-15 16:15:30</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center space-x-2">
<User className="h-5 w-5 text-blue-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="mt-2">
<span className="text-2xl font-bold">12,500</span>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center space-x-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="mt-2">
<span className="text-2xl font-bold">12,380</span>
<span className="text-sm text-muted-foreground ml-2">99.0%</span>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center space-x-2">
<Tag className="h-5 w-5 text-purple-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="mt-2">
<span className="text-2xl font-bold">8,450</span>
<span className="text-sm text-muted-foreground ml-2">67.6%</span>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center space-x-2">
<Clock className="h-5 w-5 text-orange-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="mt-2">
<span className="text-2xl font-bold">1h 45m</span>
</div>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="tags"></TabsTrigger>
<TabsTrigger value="users"></TabsTrigger>
<TabsTrigger value="details"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="text-sm font-medium mb-4"></h4>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={tagDistributionData}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{tagDistributionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-4"></h4>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={userActionData}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="value" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-4"></h4>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={timeDistributionData}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#82ca9d" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</TabsContent>
<TabsContent value="tags" className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>5,840</TableCell>
<TableCell>46.7%</TableCell>
<TableCell></TableCell>
<TableCell>2023-07-15 15:30:25</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>4,250</TableCell>
<TableCell>34.0%</TableCell>
<TableCell></TableCell>
<TableCell>2023-07-15 15:35:10</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>1,850</TableCell>
<TableCell>14.8%</TableCell>
<TableCell></TableCell>
<TableCell>2023-07-15 15:40:30</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>2,100</TableCell>
<TableCell>16.8%</TableCell>
<TableCell></TableCell>
<TableCell>2023-07-15 15:45:15</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>1,450</TableCell>
<TableCell>11.6%</TableCell>
<TableCell></TableCell>
<TableCell>2023-07-15 15:50:05</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="users" className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>3,250</TableCell>
<TableCell>26.0%</TableCell>
<TableCell>25</TableCell>
<TableCell>12.5</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>4,850</TableCell>
<TableCell>38.8%</TableCell>
<TableCell>15</TableCell>
<TableCell>8.2</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">访</TableCell>
<TableCell>2,450</TableCell>
<TableCell>19.6%</TableCell>
<TableCell>3</TableCell>
<TableCell>2.1</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>1,830</TableCell>
<TableCell>14.6%</TableCell>
<TableCell>1</TableCell>
<TableCell>0.5</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium"></TableCell>
<TableCell>120</TableCell>
<TableCell>1.0%</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="details" className="space-y-4">
<div className="flex justify-end mb-4">
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>user_12345</TableCell>
<TableCell>2023-07-15 14:32:15</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
</div>
</TableCell>
<TableCell>18</TableCell>
<TableCell>9</TableCell>
<TableCell>, 30</TableCell>
</TableRow>
<TableRow>
<TableCell>user_12346</TableCell>
<TableCell>2023-07-15 14:32:18</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
</div>
</TableCell>
<TableCell>25</TableCell>
<TableCell>15</TableCell>
<TableCell>, </TableCell>
</TableRow>
<TableRow>
<TableCell>user_12347</TableCell>
<TableCell>2023-07-15 14:32:20</TableCell>
<TableCell>
<Badge className="bg-red-100 text-red-800"></Badge>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>, </TableCell>
</TableRow>
<TableRow>
<TableCell>user_12348</TableCell>
<TableCell>2023-07-15 14:32:25</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
</div>
</TableCell>
<TableCell>32</TableCell>
<TableCell>12</TableCell>
<TableCell>, </TableCell>
</TableRow>
<TableRow>
<TableCell>user_12349</TableCell>
<TableCell>2023-07-15 14:32:30</TableCell>
<TableCell>
<Badge className="bg-green-100 text-green-800"></Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Tag className="h-3 w-3" />
</Badge>
</div>
</TableCell>
<TableCell>28</TableCell>
<TableCell>18</TableCell>
<TableCell>, </TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,7 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,212 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
export function PoolAnalytics() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold"></h2>
<div className="flex space-x-2">
<Select defaultValue="7days">
<SelectTrigger className="w-[180px]">
<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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">12</div>
<p className="text-xs text-muted-foreground"> 2</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">8</div>
<p className="text-xs text-muted-foreground"> 1</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">24.8%</div>
<p className="text-xs text-muted-foreground"> 2.3%</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="performance"></TabsTrigger>
<TabsTrigger value="conversion"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card className="p-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="performance" className="space-y-4">
<Card className="p-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="conversion" className="space-y-4">
<Card className="p-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
// 保持已有的 KeywordAnalytics 函数
export function KeywordAnalytics() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold"></h2>
<div className="flex space-x-2">
<Select defaultValue="7days">
<SelectTrigger className="w-[180px]">
<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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">125</div>
<p className="text-xs text-muted-foreground"> 12%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">24,521</div>
<p className="text-xs text-muted-foreground"> 8%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">68.3%</div>
<p className="text-xs text-muted-foreground"> 2.1%</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="trends">
<TabsList>
<TabsTrigger value="trends"></TabsTrigger>
<TabsTrigger value="distribution"></TabsTrigger>
<TabsTrigger value="performance"></TabsTrigger>
</TabsList>
<TabsContent value="trends" className="space-y-4">
<Card className="p-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="distribution" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="p-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="h-[250px] flex items-center justify-center">
<p className="text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card className="p-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="h-[250px] flex items-center justify-center">
<p className="text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="performance" className="space-y-4">
<Card className="p-4">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,246 @@
"use client"
import { useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Checkbox } from "@/components/ui/checkbox"
import { MoreHorizontal, Edit, Trash2, BarChart2, Tag } from "lucide-react"
interface TrafficPool {
id: string
keyword: string
source: string
category: string
volume: "high" | "medium" | "low"
matchedUsers: number
createdAt: Date
updatedAt: Date
tags: string[]
}
interface TrafficPoolListProps {
searchQuery: string
}
export function TrafficPoolList({ searchQuery }: TrafficPoolListProps) {
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([])
// 模拟数据
const keywords: TrafficPool[] = [
{
id: "1",
keyword: "数字营销",
source: "搜索引擎",
category: "营销",
volume: "high",
matchedUsers: 1250,
createdAt: new Date(2023, 5, 15),
updatedAt: new Date(2023, 6, 20),
tags: ["高意向", "B端客户"],
},
{
id: "2",
keyword: "用户画像",
source: "社交媒体",
category: "数据分析",
volume: "high",
matchedUsers: 980,
createdAt: new Date(2023, 4, 10),
updatedAt: new Date(2023, 6, 18),
tags: ["数据分析", "企业客户"],
},
{
id: "3",
keyword: "流量获取",
source: "广告投放",
category: "营销",
volume: "medium",
matchedUsers: 645,
createdAt: new Date(2023, 3, 5),
updatedAt: new Date(2023, 5, 25),
tags: ["获客", "营销人员"],
},
{
id: "4",
keyword: "数据可视化",
source: "内容营销",
category: "数据分析",
volume: "medium",
matchedUsers: 520,
createdAt: new Date(2023, 2, 20),
updatedAt: new Date(2023, 6, 19),
tags: ["技术", "数据分析师"],
},
{
id: "5",
keyword: "精准营销",
source: "搜索引擎",
category: "营销",
volume: "high",
matchedUsers: 1100,
createdAt: new Date(2023, 1, 15),
updatedAt: new Date(2023, 4, 10),
tags: ["高转化", "营销经理"],
},
]
// 过滤关键词
const filteredKeywords = keywords.filter(
(keyword) =>
keyword.keyword.toLowerCase().includes(searchQuery.toLowerCase()) ||
keyword.source.toLowerCase().includes(searchQuery.toLowerCase()) ||
keyword.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
keyword.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase())),
)
const toggleSelectAll = () => {
if (selectedKeywords.length === filteredKeywords.length) {
setSelectedKeywords([])
} else {
setSelectedKeywords(filteredKeywords.map((k) => k.id))
}
}
const toggleSelectKeyword = (id: string) => {
if (selectedKeywords.includes(id)) {
setSelectedKeywords(selectedKeywords.filter((k) => k !== id))
} else {
setSelectedKeywords([...selectedKeywords, id])
}
}
const getVolumeBadge = (volume: string) => {
switch (volume) {
case "high":
return <Badge className="bg-green-500"></Badge>
case "medium":
return <Badge className="bg-blue-500"></Badge>
case "low":
return <Badge className="bg-gray-500"></Badge>
default:
return <Badge></Badge>
}
}
const formatDate = (date: Date) => {
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
return (
<div className="space-y-4">
{selectedKeywords.length > 0 && (
<div className="bg-muted p-2 rounded-md flex items-center justify-between">
<span className="text-sm"> {selectedKeywords.length} </span>
<div className="space-x-2">
<Button variant="outline" size="sm">
<Tag className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm" className="text-red-500">
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedKeywords.length === filteredKeywords.length && filteredKeywords.length > 0}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredKeywords.map((keyword) => (
<TableRow key={keyword.id}>
<TableCell>
<Checkbox
checked={selectedKeywords.includes(keyword.id)}
onCheckedChange={() => toggleSelectKeyword(keyword.id)}
aria-label={`Select ${keyword.keyword}`}
/>
</TableCell>
<TableCell className="font-medium">{keyword.keyword}</TableCell>
<TableCell>{keyword.source}</TableCell>
<TableCell>{keyword.category}</TableCell>
<TableCell>{getVolumeBadge(keyword.volume)}</TableCell>
<TableCell>{keyword.matchedUsers.toLocaleString()}</TableCell>
<TableCell>{formatDate(keyword.createdAt)}</TableCell>
<TableCell>{formatDate(keyword.updatedAt)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{keyword.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<BarChart2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem>
<Tag className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-500">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,263 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { PlusCircle, Search, Tag, ArrowRight } from "lucide-react"
interface TagMapping {
id: string
keywordId: string
keyword: string
tagId: string
tagName: string
tagCategory: string
matchRule: string
priority: number
createdAt: Date
status: "active" | "inactive"
}
export function UserTagMapping() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedMappings, setSelectedMappings] = useState<string[]>([])
// 模拟数据
const mappings: TagMapping[] = [
{
id: "1",
keywordId: "1",
keyword: "数字营销",
tagId: "101",
tagName: "营销决策者",
tagCategory: "职业角色",
matchRule: "精确匹配",
priority: 1,
createdAt: new Date(2023, 5, 15),
status: "active",
},
{
id: "2",
keywordId: "2",
keyword: "用户画像",
tagId: "102",
tagName: "数据分析师",
tagCategory: "职业角色",
matchRule: "包含匹配",
priority: 2,
createdAt: new Date(2023, 4, 10),
status: "active",
},
{
id: "3",
keywordId: "3",
keyword: "流量获取",
tagId: "103",
tagName: "营销专员",
tagCategory: "职业角色",
matchRule: "精确匹配",
priority: 3,
createdAt: new Date(2023, 3, 5),
status: "active",
},
{
id: "4",
keywordId: "4",
keyword: "数据可视化",
tagId: "104",
tagName: "技术人员",
tagCategory: "职业角色",
matchRule: "包含匹配",
priority: 2,
createdAt: new Date(2023, 2, 20),
status: "inactive",
},
{
id: "5",
keywordId: "5",
keyword: "精准营销",
tagId: "105",
tagName: "营销经理",
tagCategory: "职业角色",
matchRule: "精确匹配",
priority: 1,
createdAt: new Date(2023, 1, 15),
status: "active",
},
]
// 过滤映射
const filteredMappings = mappings.filter(
(mapping) =>
mapping.keyword.toLowerCase().includes(searchQuery.toLowerCase()) ||
mapping.tagName.toLowerCase().includes(searchQuery.toLowerCase()) ||
mapping.tagCategory.toLowerCase().includes(searchQuery.toLowerCase()),
)
const toggleSelectAll = () => {
if (selectedMappings.length === filteredMappings.length) {
setSelectedMappings([])
} else {
setSelectedMappings(filteredMappings.map((m) => m.id))
}
}
const toggleSelectMapping = (id: string) => {
if (selectedMappings.includes(id)) {
setSelectedMappings(selectedMappings.filter((m) => m !== id))
} else {
setSelectedMappings([...selectedMappings, id])
}
}
const formatDate = (date: Date) => {
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold"></h2>
<Button>
<PlusCircle className="mr-2 h-4 w-4" />
</Button>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex space-x-2">
<Select defaultValue="all">
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
<Select defaultValue="all">
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="标签分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="role"></SelectItem>
<SelectItem value="interest"></SelectItem>
<SelectItem value="behavior"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索关键词或标签..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{selectedMappings.length > 0 && (
<div className="bg-muted p-2 rounded-md flex items-center justify-between">
<span className="text-sm"> {selectedMappings.length} </span>
<div className="space-x-2">
<Button variant="outline" size="sm">
</Button>
<Button variant="outline" size="sm">
</Button>
<Button variant="outline" size="sm" className="text-red-500">
</Button>
</div>
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedMappings.length === filteredMappings.length && filteredMappings.length > 0}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</TableHead>
<TableHead></TableHead>
<TableHead className="w-12 text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMappings.map((mapping) => (
<TableRow key={mapping.id}>
<TableCell>
<Checkbox
checked={selectedMappings.includes(mapping.id)}
onCheckedChange={() => toggleSelectMapping(mapping.id)}
aria-label={`Select ${mapping.keyword} to ${mapping.tagName} mapping`}
/>
</TableCell>
<TableCell className="font-medium">{mapping.keyword}</TableCell>
<TableCell className="text-center">
<ArrowRight className="h-4 w-4 mx-auto" />
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
<Tag className="mr-1 h-3 w-3" />
{mapping.tagName}
</Badge>
</TableCell>
<TableCell>{mapping.tagCategory}</TableCell>
<TableCell>{mapping.matchRule}</TableCell>
<TableCell>{mapping.priority}</TableCell>
<TableCell>{formatDate(mapping.createdAt)}</TableCell>
<TableCell>
<Badge
variant={mapping.status === "active" ? "default" : "secondary"}
className={mapping.status === "active" ? "bg-green-500" : ""}
>
{mapping.status === "active" ? "已启用" : "已禁用"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "@radix-ui/react-icons"
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 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground 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="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.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}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

59
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,7 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

38
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,38 @@
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
components/ui/badge.tsx Normal file
View 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 }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

47
components/ui/button.tsx Normal file
View File

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

View File

@@ -0,0 +1,59 @@
import type * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"
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-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(buttonVariants({ variant: "ghost" }), "h-8 w-8 p-0 font-normal aria-selected:opacity-100"),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
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 }) => <ChevronLeftIcon className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRightIcon className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

43
components/ui/card.tsx Normal file
View File

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

262
components/ui/carousel.tsx Normal file
View File

@@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

365
components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "@radix-ui/react-icons"
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 shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring 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")}>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
import { cn } from "@/lib/utils"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
className={cn(
"data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden transition-all",
className,
)}
{...props}
>
{children}
</CollapsiblePrimitive.CollapsibleContent>
))
CollapsibleContent.displayName = "CollapsibleContent"
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

153
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.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 focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
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}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.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 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">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.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 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">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,57 @@
"use client"
import { CalendarIcon } from "lucide-react"
import type { DateRange } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button } from "./button"
import { Calendar } from "./calendar"
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
interface DateRangePickerProps {
className?: string
value?: DateRange
onChange?: (date: DateRange | undefined) => void
}
export function DateRangePicker({ className, value, onChange }: DateRangePickerProps) {
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn("w-[300px] justify-start text-left font-normal", !value && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value?.from ? (
value.to ? (
<>
{format(value.from, "yyyy年MM月dd日", { locale: zhCN })} -{" "}
{format(value.to, "yyyy年MM月dd日", { locale: zhCN })}
</>
) : (
format(value.from, "yyyy年MM月dd日", { locale: zhCN })
)
) : (
<span></span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={value?.from}
selected={value}
onSelect={onChange}
numberOfMonths={2}
locale={zhCN}
/>
</PopoverContent>
</Popover>
</div>
)
}

95
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,95 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
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-black/80 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">
<Cross2Icon 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,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

118
components/ui/drawer.tsx Normal file
View File

@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

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

178
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

22
components/ui/input.tsx Normal file
View 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 }

17
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,17 @@
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 }

236
components/ui/menubar.tsx Normal file
View File

@@ -0,0 +1,236 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.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 focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.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 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">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.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 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">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,81 @@
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
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}>
<ChevronLeftIcon 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>
<ChevronRightIcon 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}>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

29
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

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

View File

@@ -0,0 +1,23 @@
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-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "@radix-ui/react-icons"
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, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<CircleIcon className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,38 @@
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 }

145
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,145 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } 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 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.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 max-h-96 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}
>
<SelectScrollUpButton />
<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>
<SelectScrollDownButton />
</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,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

140
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.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-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

763
components/ui/sidebar.tsx Normal file
View File

@@ -0,0 +1,763 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

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

25
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

31
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

27
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,27 @@
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-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm 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-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

72
components/ui/table.tsx Normal file
View 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
components/ui/tabs.tsx Normal file
View 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 }

View 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-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

113
components/ui/toast.ts Normal file
View File

@@ -0,0 +1,113 @@
"use client"
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,
}

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