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:
103
components/AIAssistant.tsx
Normal file
103
components/AIAssistant.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
components/BindDouyinQRCode.tsx
Normal file
31
components/BindDouyinQRCode.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { QrCode } from "lucide-react"
|
||||
|
||||
export function BindDouyinQRCode() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(true)}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>绑定抖音号</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center p-4">
|
||||
<div className="w-64 h-64 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<img src="/placeholder.svg?height=256&width=256" alt="抖音二维码" className="w-full h-full" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-600">请使用抖音APP扫描二维码进行绑定</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
47
components/Charts.tsx
Normal file
47
components/Charts.tsx
Normal 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} />
|
||||
}
|
||||
149
components/TrafficTeamSettings.tsx
Normal file
149
components/TrafficTeamSettings.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react"
|
||||
|
||||
interface TrafficTeam {
|
||||
id: string
|
||||
name: string
|
||||
commission: number
|
||||
}
|
||||
|
||||
interface TrafficTeamSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
}
|
||||
|
||||
export function TrafficTeamSettings({ formData, onChange }: TrafficTeamSettingsProps) {
|
||||
const [teams, setTeams] = useState<TrafficTeam[]>(formData.trafficTeams || [])
|
||||
const [isAddTeamOpen, setIsAddTeamOpen] = useState(false)
|
||||
const [editingTeam, setEditingTeam] = useState<TrafficTeam | null>(null)
|
||||
const [newTeam, setNewTeam] = useState<Partial<TrafficTeam>>({
|
||||
name: "",
|
||||
commission: 0,
|
||||
})
|
||||
|
||||
const handleAddTeam = () => {
|
||||
if (!newTeam.name) return
|
||||
|
||||
if (editingTeam) {
|
||||
setTeams(teams.map((team) => (team.id === editingTeam.id ? { ...team, ...newTeam } : team)))
|
||||
} else {
|
||||
setTeams([
|
||||
...teams,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
name: newTeam.name,
|
||||
commission: newTeam.commission || 0,
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
63
components/VideoTutorialButton.tsx
Normal file
63
components/VideoTutorialButton.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
117
components/WechatFriendSelector.tsx
Normal file
117
components/WechatFriendSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
components/WechatGroupSelector.tsx
Normal file
110
components/WechatGroupSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
components/acquisition/PlanSettingsDialog.tsx
Normal file
152
components/acquisition/PlanSettingsDialog.tsx
Normal 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
121
components/charts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
components/dashboard/dashboard-header.tsx
Normal file
27
components/dashboard/dashboard-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
179
components/dashboard/dashboard-overview.tsx
Normal file
179
components/dashboard/dashboard-overview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
components/dashboard/recent-activities.tsx
Normal file
74
components/dashboard/recent-activities.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
components/dashboard/traffic-source-analysis.tsx
Normal file
36
components/dashboard/traffic-source-analysis.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
components/dashboard/user-assets-summary.tsx
Normal file
40
components/dashboard/user-assets-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
components/dashboard/user-behavior-analysis.tsx
Normal file
138
components/dashboard/user-behavior-analysis.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
components/dashboard/user-growth-chart.tsx
Normal file
37
components/dashboard/user-growth-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
components/dashboard/user-lifecycle-analysis.tsx
Normal file
126
components/dashboard/user-lifecycle-analysis.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
components/dashboard/user-portrait-overview.tsx
Normal file
138
components/dashboard/user-portrait-overview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
components/dashboard/user-tags-distribution.tsx
Normal file
153
components/dashboard/user-tags-distribution.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
components/dashboard/user-value-distribution.tsx
Normal file
36
components/dashboard/user-value-distribution.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
components/data-analysis/rfm-analysis.tsx
Normal file
62
components/data-analysis/rfm-analysis.tsx
Normal 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\
|
||||
326
components/data-analysis/user-selector.tsx
Normal file
326
components/data-analysis/user-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
923
components/data-integration/ai-analysis-tools.tsx
Normal file
923
components/data-integration/ai-analysis-tools.tsx
Normal 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={
|
||||
664
components/data-integration/api-documentation.tsx
Normal file
664
components/data-integration/api-documentation.tsx
Normal 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有速率限制,默认为每分钟100个请求。超过限制会返回429状态码。
|
||||
</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)`
|
||||
698
components/data-integration/data-collection-settings.tsx
Normal file
698
components/data-integration/data-collection-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
950
components/data-integration/data-correlation-analysis.tsx
Normal file
950
components/data-integration/data-correlation-analysis.tsx
Normal 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">高度显著 (p≤0.001)</Badge>
|
||||
}
|
||||
if (significance <= 0.01) {
|
||||
return <Badge className="bg-green-100 text-green-800">显著 (p≤0.01)</Badge>
|
||||
}
|
||||
if (significance <= 0.05) {
|
||||
return <Badge className="bg-yellow-100 text-yellow-800">边际显著 (p≤0.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" >
|
||||
导出分析结果
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreatingAnalysis(true)}>
|
||||
<Play className="mr-2 h-4 w-4" >
|
||||
创建新分析
|
||||
</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>
|
||||
)
|
||||
}
|
||||
449
components/data-integration/data-dashboard.tsx
Normal file
449
components/data-integration/data-dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
321
components/data-integration/data-field-mapping.tsx
Normal file
321
components/data-integration/data-field-mapping.tsx
Normal 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>
|
||||
配置源数据字段与目标数据字段的映射关系,以ID、手机号、身份证号和IMEI设备号为标准整合客户数据
|
||||
</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">
|
||||
标准字段映射用于将不同数据源的字段映射到统一的标准字段,以便于数据整合和分析。
|
||||
主键字段用于唯一标识用户,通常为用户ID、手机号、身份证号或IMEI设备号。
|
||||
</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>
|
||||
)
|
||||
}
|
||||
210
components/data-integration/data-mapping-config.tsx
Normal file
210
components/data-integration/data-mapping-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
235
components/data-integration/data-preview.tsx
Normal file
235
components/data-integration/data-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
987
components/data-integration/data-quality-monitor.tsx
Normal file
987
components/data-integration/data-quality-monitor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
components/data-integration/data-source-list.tsx
Normal file
185
components/data-integration/data-source-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
components/data-integration/database-structure-viewer.tsx
Normal file
293
components/data-integration/database-structure-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
240
components/data-integration/integration-history.tsx
Normal file
240
components/data-integration/integration-history.tsx
Normal 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
104
components/device-grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
176
components/documentation/document-generator.tsx
Normal file
176
components/documentation/document-generator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
components/documentation/export-controls.tsx
Normal file
100
components/documentation/export-controls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
components/documentation/screenshot-capture.tsx
Normal file
119
components/documentation/screenshot-capture.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
components/icons/apple-icon.tsx
Normal file
11
components/icons/apple-icon.tsx
Normal 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
|
||||
12
components/icons/wechat-icon.tsx
Normal file
12
components/icons/wechat-icon.tsx
Normal 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
|
||||
84
components/poster-selector.tsx
Normal file
84
components/poster-selector.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
interface PosterTemplate {
|
||||
id: string
|
||||
title: string
|
||||
type: "领取" | "了解"
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
interface PosterSelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect: (template: PosterTemplate) => void
|
||||
}
|
||||
|
||||
const templates: PosterTemplate[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "点击领取",
|
||||
type: "领取",
|
||||
imageUrl: "/placeholder.svg?height=400&width=300",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "点击了解",
|
||||
type: "了解",
|
||||
imageUrl: "/placeholder.svg?height=400&width=300",
|
||||
},
|
||||
// ... 其他模板
|
||||
]
|
||||
|
||||
export function PosterSelector({ open, onOpenChange, onSelect }: PosterSelectorProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择海报</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm text-gray-500 mb-4">点击下方海报使用该模板</h3>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="group relative cursor-pointer"
|
||||
onClick={() => {
|
||||
onSelect(template)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={template.imageUrl || "/placeholder.svg"}
|
||||
alt={template.title}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 bg-black/50 group-hover:opacity-100 transition-opacity">
|
||||
<Check className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<div className="font-medium">{template.title}</div>
|
||||
<div className="text-sm text-gray-500">{template.type}类型</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button>新建海报</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
41
components/tag-management/tag-category-chart.tsx
Normal file
41
components/tag-management/tag-category-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
components/tag-management/tag-relationship-graph.tsx
Normal file
149
components/tag-management/tag-relationship-graph.tsx
Normal 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%" />
|
||||
}
|
||||
49
components/tag-management/tag-usage-stats.tsx
Normal file
49
components/tag-management/tag-usage-stats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
277
components/tag-rules/rule-editor.tsx
Normal file
277
components/tag-rules/rule-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
258
components/tag-rules/rule-execution-history.tsx
Normal file
258
components/tag-rules/rule-execution-history.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
307
components/tag-tasks/task-creation.tsx
Normal file
307
components/tag-tasks/task-creation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
273
components/tag-tasks/task-execution.tsx
Normal file
273
components/tag-tasks/task-execution.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
197
components/tag-tasks/task-list.tsx
Normal file
197
components/tag-tasks/task-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
420
components/tag-tasks/task-results.tsx
Normal file
420
components/tag-tasks/task-results.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
components/theme-provider.tsx
Normal file
7
components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
212
components/traffic-pool/pool-analytics.tsx
Normal file
212
components/traffic-pool/pool-analytics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
246
components/traffic-pool/traffic-pool-list.tsx
Normal file
246
components/traffic-pool/traffic-pool-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
263
components/traffic-pool/user-tag-mapping.tsx
Normal file
263
components/traffic-pool/user-tag-mapping.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
components/ui/accordion.tsx
Normal file
51
components/ui/accordion.tsx
Normal 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 }
|
||||
141
components/ui/alert-dialog.tsx
Normal file
141
components/ui/alert-dialog.tsx
Normal 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
59
components/ui/alert.tsx
Normal 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 }
|
||||
7
components/ui/aspect-ratio.tsx
Normal file
7
components/ui/aspect-ratio.tsx
Normal 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
38
components/ui/avatar.tsx
Normal 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
29
components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
115
components/ui/breadcrumb.tsx
Normal file
115
components/ui/breadcrumb.tsx
Normal 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
47
components/ui/button.tsx
Normal 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 }
|
||||
59
components/ui/calendar.tsx
Normal file
59
components/ui/calendar.tsx
Normal 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
43
components/ui/card.tsx
Normal 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
262
components/ui/carousel.tsx
Normal 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
365
components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
26
components/ui/checkbox.tsx
Normal file
26
components/ui/checkbox.tsx
Normal 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 }
|
||||
29
components/ui/collapsible.tsx
Normal file
29
components/ui/collapsible.tsx
Normal 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
153
components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
200
components/ui/context-menu.tsx
Normal file
200
components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
57
components/ui/date-range-picker.tsx
Normal file
57
components/ui/date-range-picker.tsx
Normal 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
95
components/ui/dialog.tsx
Normal 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
118
components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
181
components/ui/dropdown-menu.tsx
Normal file
181
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
178
components/ui/form.tsx
Normal file
178
components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
29
components/ui/hover-card.tsx
Normal file
29
components/ui/hover-card.tsx
Normal 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 }
|
||||
71
components/ui/input-otp.tsx
Normal file
71
components/ui/input-otp.tsx
Normal 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
22
components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
17
components/ui/label.tsx
Normal file
17
components/ui/label.tsx
Normal 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
236
components/ui/menubar.tsx
Normal 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,
|
||||
}
|
||||
128
components/ui/navigation-menu.tsx
Normal file
128
components/ui/navigation-menu.tsx
Normal 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,
|
||||
}
|
||||
81
components/ui/pagination.tsx
Normal file
81
components/ui/pagination.tsx
Normal 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
29
components/ui/popover.tsx
Normal 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 }
|
||||
34
components/ui/preview-dialog.tsx
Normal file
34
components/ui/preview-dialog.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./dialog"
|
||||
import { Button } from "./button"
|
||||
import { Eye } from "lucide-react"
|
||||
|
||||
interface PreviewDialogProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function PreviewDialog({ children, title = "预览效果" }: PreviewDialogProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
预览
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-[360px] p-0">
|
||||
<DialogHeader className="p-4 border-b">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative bg-gray-50">
|
||||
<div className="w-full overflow-hidden">{children}</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
23
components/ui/progress.tsx
Normal file
23
components/ui/progress.tsx
Normal 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 }
|
||||
36
components/ui/radio-group.tsx
Normal file
36
components/ui/radio-group.tsx
Normal 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 }
|
||||
45
components/ui/resizable.tsx
Normal file
45
components/ui/resizable.tsx
Normal 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 }
|
||||
38
components/ui/scroll-area.tsx
Normal file
38
components/ui/scroll-area.tsx
Normal 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
145
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal 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
140
components/ui/sheet.tsx
Normal 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
763
components/ui/sidebar.tsx
Normal 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,
|
||||
}
|
||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal 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
25
components/ui/slider.tsx
Normal 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
31
components/ui/sonner.tsx
Normal 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
27
components/ui/switch.tsx
Normal 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
72
components/ui/table.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
|
||||
)
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot ref={ref} className={cn("bg-primary font-medium text-primary-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
55
components/ui/tabs.tsx
Normal file
55
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
21
components/ui/textarea.tsx
Normal file
21
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[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
113
components/ui/toast.ts
Normal 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
Reference in New Issue
Block a user