327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
|
|
"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>
|
|||
|
|
)
|
|||
|
|
}
|