feat: 本次提交更新内容如下

场景获客列表搞定
This commit is contained in:
笔记本里的永平
2025-07-07 17:08:27 +08:00
parent 6543da9167
commit 5ff15472f5
352 changed files with 24040 additions and 18575 deletions

3
Cunkebao/.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["next/babel"]
}

2
Cunkebao/.gitignore vendored
View File

@@ -7,8 +7,6 @@
/.next/
/out/
/.history/
# production
/build

View File

@@ -0,0 +1,250 @@
"use client"
import { useState } from "react"
import { Pencil, Trash2, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
interface MobileAccount {
id: string
name: string
phone: string
createdAt: string
status: "active" | "inactive"
}
export default function MobileAccountsPage() {
const [accounts, setAccounts] = useState<MobileAccount[]>([
{
id: "1",
name: "用户1",
phone: "13809076043",
createdAt: "2023-01-15",
status: "active",
},
{
id: "2",
name: "用户2",
phone: "13819176143",
createdAt: "2023-02-15",
status: "inactive",
},
{
id: "3",
name: "用户3",
phone: "13829276243",
createdAt: "2023-03-15",
status: "active",
},
{
id: "4",
name: "用户4",
phone: "13839376343",
createdAt: "2023-04-15",
status: "inactive",
},
{
id: "5",
name: "用户5",
phone: "13849476443",
createdAt: "2023-05-15",
status: "active",
},
])
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [newAccount, setNewAccount] = useState({
name: "",
password: "",
phone: "",
role: "",
permissions: {
notifications: false,
dataView: false,
remoteControl: false,
},
})
const handleCreateAccount = () => {
// 这里应该有API调用来创建账号
const newId = (accounts.length + 1).toString()
setAccounts([
...accounts,
{
id: newId,
name: newAccount.name,
phone: newAccount.phone,
createdAt: new Date().toISOString().split("T")[0],
status: "active",
},
])
setIsDialogOpen(false)
setNewAccount({
name: "",
password: "",
phone: "",
role: "",
permissions: {
notifications: false,
dataView: false,
remoteControl: false,
},
})
}
const handleDeleteAccount = (id: string) => {
setAccounts(accounts.filter((account) => account.id !== id))
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold"></h1>
<Button onClick={() => setIsDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="bg-white rounded-md shadow">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.id}>
<TableCell>{account.name}</TableCell>
<TableCell>{account.phone}</TableCell>
<TableCell>{account.createdAt}</TableCell>
<TableCell>
<Badge variant={account.status === "active" ? "success" : "secondary"}>
{account.status === "active" ? "活跃" : "非活跃"}
</Badge>
</TableCell>
<TableCell className="text-right space-x-2">
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteAccount(account.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={newAccount.name}
onChange={(e) => setNewAccount({ ...newAccount, name: e.target.value })}
placeholder="请输入账号名称"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={newAccount.password}
onChange={(e) => setNewAccount({ ...newAccount, password: e.target.value })}
placeholder="请输入初始密码"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
value={newAccount.phone}
onChange={(e) => setNewAccount({ ...newAccount, phone: e.target.value })}
placeholder="请输入手机号码"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="role"></Label>
<Select value={newAccount.role} onValueChange={(value) => setNewAccount({ ...newAccount, role: value })}>
<SelectTrigger id="role">
<SelectValue placeholder="选择角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin"></SelectItem>
<SelectItem value="user"><EFBFBD><EFBFBD><EFBFBD></SelectItem>
<SelectItem value="guest">访</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label></Label>
<div className="flex flex-col gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="notifications"
checked={newAccount.permissions.notifications}
onCheckedChange={(checked) =>
setNewAccount({
...newAccount,
permissions: { ...newAccount.permissions, notifications: !!checked },
})
}
/>
<Label htmlFor="notifications"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="dataView"
checked={newAccount.permissions.dataView}
onCheckedChange={(checked) =>
setNewAccount({
...newAccount,
permissions: { ...newAccount.permissions, dataView: !!checked },
})
}
/>
<Label htmlFor="dataView"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remoteControl"
checked={newAccount.permissions.remoteControl}
onCheckedChange={(checked) =>
setNewAccount({
...newAccount,
permissions: { ...newAccount.permissions, remoteControl: !!checked },
})
}
/>
<Label htmlFor="remoteControl"></Label>
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleCreateAccount}></Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,197 @@
"use client"
import { useState } from "react"
import { Pencil, Trash2, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
interface OperatorAccount {
id: string
phone: string
nickname: string
deviceCount: number
friendCount: number
createdAt: string
status: "active" | "inactive"
}
export default function OperatorAccountsPage() {
const [accounts, setAccounts] = useState<OperatorAccount[]>([
{
id: "1",
phone: "13809076043",
nickname: "操盘手1",
deviceCount: 1,
friendCount: 25,
createdAt: "2023-01-15",
status: "active",
},
{
id: "2",
phone: "13819176143",
nickname: "操盘手2",
deviceCount: 2,
friendCount: 50,
createdAt: "2023-02-15",
status: "inactive",
},
{
id: "3",
phone: "13829276243",
nickname: "操盘手3",
deviceCount: 3,
friendCount: 75,
createdAt: "2023-03-15",
status: "active",
},
{
id: "4",
phone: "13839376343",
nickname: "操盘手4",
deviceCount: 4,
friendCount: 100,
createdAt: "2023-04-15",
status: "inactive",
},
{
id: "5",
phone: "13849476443",
nickname: "操盘手5",
deviceCount: 5,
friendCount: 125,
createdAt: "2023-05-15",
status: "active",
},
])
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [newAccount, setNewAccount] = useState({
phone: "",
nickname: "",
password: "",
})
const handleCreateAccount = () => {
// 这里应该有API调用来创建账号
const newId = (accounts.length + 1).toString()
setAccounts([
...accounts,
{
id: newId,
phone: newAccount.phone,
nickname: newAccount.nickname,
deviceCount: 0,
friendCount: 0,
createdAt: new Date().toISOString().split("T")[0],
status: "active",
},
])
setIsDialogOpen(false)
setNewAccount({
phone: "",
nickname: "",
password: "",
})
}
const handleDeleteAccount = (id: string) => {
setAccounts(accounts.filter((account) => account.id !== id))
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold"></h1>
<Button onClick={() => setIsDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="bg-white rounded-md shadow">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> ()</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.id}>
<TableCell>{account.phone}</TableCell>
<TableCell>{account.nickname}</TableCell>
<TableCell>{account.deviceCount}</TableCell>
<TableCell>{account.friendCount}</TableCell>
<TableCell>{account.createdAt}</TableCell>
<TableCell>
<Badge variant={account.status === "active" ? "success" : "secondary"}>
{account.status === "active" ? "活跃" : "非活跃"}
</Badge>
</TableCell>
<TableCell className="text-right space-x-2">
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteAccount(account.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
value={newAccount.phone}
onChange={(e) => setNewAccount({ ...newAccount, phone: e.target.value })}
placeholder="请输入手机号"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="nickname"> ()</Label>
<Input
id="nickname"
value={newAccount.nickname}
onChange={(e) => setNewAccount({ ...newAccount, nickname: e.target.value })}
placeholder="请输入昵称"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={newAccount.password}
onChange={(e) => setNewAccount({ ...newAccount, password: e.target.value })}
placeholder="请输入初始密码"
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleCreateAccount}></Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,88 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ChevronDown, Users, MessageSquare } from "lucide-react"
export default function AdminSidebar() {
const pathname = usePathname()
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({
账号管理: true,
})
const toggleExpand = (key: string) => {
setExpandedItems((prev) => ({
...prev,
[key]: !prev[key],
}))
}
const isActive = (path: string) => pathname === path
return (
<div className="w-64 bg-white border-r flex flex-col h-full">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold"></h2>
</div>
<nav className="flex-1 overflow-y-auto p-2">
<ul className="space-y-1">
{/* 账号管理 */}
<li>
<button
className="flex items-center justify-between w-full p-3 rounded-md hover:bg-gray-100"
onClick={() => toggleExpand("账号管理")}
>
<div className="flex items-center">
<Users className="h-5 w-5 mr-3" />
<span></span>
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${expandedItems["账号管理"] ? "transform rotate-180" : ""}`}
/>
</button>
{expandedItems["账号管理"] && (
<ul className="pl-10 space-y-1 mt-1">
<li>
<Link
href="/admin/accounts/operators"
className={`block p-2 rounded-md ${
isActive("/admin/accounts/operators") ? "bg-blue-50 text-blue-600" : "hover:bg-gray-100"
}`}
>
</Link>
</li>
<li>
<Link
href="/admin/accounts/mobile"
className={`block p-2 rounded-md ${
isActive("/admin/accounts/mobile") ? "bg-blue-50 text-blue-600" : "hover:bg-gray-100"
}`}
>
</Link>
</li>
</ul>
)}
</li>
{/* 聚合聊天 */}
<li>
<Link
href="/admin/chat"
className={`flex items-center p-3 rounded-md ${
isActive("/admin/chat") ? "bg-blue-50 text-blue-600" : "hover:bg-gray-100"
}`}
>
<MessageSquare className="h-5 w-5 mr-3" />
<span></span>
</Link>
</li>
{/* 其他菜单项可以在这里添加 */}
</ul>
</nav>
</div>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import type React from "react"
import { Bell, User } from "lucide-react"
import { Button } from "@/components/ui/button"
import AdminSidebar from "./components/AdminSidebar"
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen bg-gray-100">
<AdminSidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<header className="bg-white border-b h-16 flex items-center justify-between px-6">
<h1 className="text-xl font-semibold"></h1>
<div className="flex items-center space-x-4">
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon">
<User className="h-5 w-5" />
</Button>
</div>
</header>
<main className="flex-1 overflow-auto">{children}</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ArrowRight } from "lucide-react"
import Link from "next/link"
export default function AdminDashboard() {
const stats = [
{ title: "总账号数", value: "42" },
{ title: "手机端账号", value: "28" },
{ title: "操盘手账号", value: "14" },
{ title: "今日活跃", value: "18" },
]
return (
<div className="p-6">
<h1 className="text-2xl font-semibold mb-6"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat, index) => (
<Card key={index}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500">{stat.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stat.value}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 mb-4"></p>
<Link href="/admin/accounts/mobile">
<Button variant="outline" className="w-full">
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 mb-4"></p>
<Link href="/admin/accounts/operators">
<Button variant="outline" className="w-full">
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -17,4 +17,3 @@ export async function POST(request: Request, { params }: { params: { planId: str
return NextResponse.json({ success: false, message: "订单提交失败" }, { status: 500 })
}
}

View File

@@ -66,4 +66,3 @@ export async function publicFetch(url: string, options: RequestInit = {}) {
throw error
}
}

View File

@@ -79,4 +79,3 @@ export async function GET(request: Request) {
)
}
}

View File

@@ -0,0 +1,377 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Copy, Check, Info } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { useToast } from "@/components/ui/use-toast"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getApiGuideForScenario } from "@/docs/api-guide"
import { Badge } from "@/components/ui/badge"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
export default function ApiDocPage({ params }: { params: { channel: string; id: string } }) {
const router = useRouter()
const { toast } = useToast()
const [copiedExample, setCopiedExample] = useState<string | null>(null)
const apiGuide = getApiGuideForScenario(params.id, params.channel)
const copyToClipboard = (text: string, exampleId: string) => {
navigator.clipboard.writeText(text)
setCopiedExample(exampleId)
toast({
title: "已复制代码",
description: "代码示例已复制到剪贴板",
})
setTimeout(() => {
setCopiedExample(null)
}, 2000)
}
return (
<div className="min-h-screen bg-gray-50/30">
<header className="sticky top-0 z-10 bg-white border-b border-gray-200 shadow-sm">
<div className="flex items-center justify-between h-16 px-6">
<div className="flex items-center space-x-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-xl font-semibold text-gray-900">{apiGuide.title}</h1>
<p className="text-sm text-gray-500 mt-1">API接口文档</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => window.open(`${window.location.origin}/scenarios/${params.channel}`, "_self")}
>
</Button>
</div>
</div>
</header>
<div className="container mx-auto py-8 px-6 max-w-5xl">
{/* API密钥卡片 */}
<Card className="mb-8">
<CardHeader>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Info className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-lg">API密钥</CardTitle>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="bg-gray-50 rounded-lg p-4 border">
<div className="flex items-center justify-between">
<code className="text-sm font-mono text-gray-800">api_1_xqbint74</code>
<Button variant="outline" size="sm" onClick={() => copyToClipboard("api_1_xqbint74", "api-key")}>
{copiedExample === "api-key" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Info className="h-5 w-5 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-amber-800 mb-1"></p>
<p className="text-sm text-amber-700">
API密钥使
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 接口地址卡片 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-4 border">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">POST </span>
<Button
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(
"https://kzminfd0rplrm7owmj4b.lite.vusercontent.net/api/scenarios/post",
"api-url",
)
}
>
{copiedExample === "api-url" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<code className="text-xs font-mono text-gray-800 break-all">
https://kzminfd0rplrm7owmj4b.lite.vusercontent.net/api/scenarios/post
</code>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-900"></h4>
<div className="space-y-1">
<Badge variant="outline" className="text-blue-600 border-blue-200">
name ()
</Badge>
<Badge variant="outline" className="text-blue-600 border-blue-200">
phone ()
</Badge>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-900"></h4>
<div className="space-y-1">
<Badge variant="outline" className="text-gray-600 border-gray-200">
source ()
</Badge>
<Badge variant="outline" className="text-gray-600 border-gray-200">
remark ()
</Badge>
<Badge variant="outline" className="text-gray-600 border-gray-200">
tags ()
</Badge>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 接口文档卡片 */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>API规范和集成指南</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row gap-4">
<Button variant="outline" className="flex-1 h-12 bg-transparent">
<div className="text-center">
<div className="font-medium"></div>
<div className="text-xs text-gray-500"></div>
</div>
</Button>
<Button variant="outline" className="flex-1 h-12 bg-transparent">
<div className="text-center">
<div className="font-medium"></div>
<div className="text-xs text-gray-500"></div>
</div>
</Button>
</div>
</CardContent>
</Card>
{/* 快速测试卡片 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>使URL可以快速测试接口是否正常工作</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-gray-50 rounded-lg p-4 border">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">URL</span>
<Button
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(
"https://kzminfd0rplrm7owmj4b.lite.vusercontent.net/api/scenarios/poster/1/webhook?name=测试客户&phone=13800138000",
"test-url",
)
}
>
{copiedExample === "test-url" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<code className="text-xs font-mono text-gray-800 break-all">
https://kzminfd0rplrm7owmj4b.lite.vusercontent.net/api/scenarios/poster/1/webhook?name=测试客户&phone=13800138000
</code>
</div>
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<p className="text-sm text-green-700">💡 访</p>
</div>
</CardContent>
</Card>
{/* 详细文档手风琴 */}
<div className="mt-8">
<Accordion type="single" collapsible className="space-y-4">
{apiGuide.endpoints.map((endpoint, index) => (
<AccordionItem key={index} value={`endpoint-${index}`} className="border rounded-lg">
<AccordionTrigger className="px-6 py-4 hover:bg-gray-50">
<div className="flex items-center space-x-3">
<Badge className="bg-green-100 text-green-800 border-green-200">{endpoint.method}</Badge>
<span className="font-mono text-sm text-gray-700">{endpoint.url}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6">
<div className="space-y-6">
<p className="text-sm text-gray-700">{endpoint.description}</p>
<div>
<h4 className="text-sm font-medium mb-3 text-gray-900"></h4>
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
{endpoint.headers.map((header, i) => (
<div key={i} className="flex items-start space-x-3">
<Badge variant="outline" className="font-mono text-xs">
{header.required ? "*" : ""}
{header.name}
</Badge>
<div className="flex-1">
<p className="text-sm font-medium">{header.value}</p>
<p className="text-xs text-gray-500 mt-1">{header.description}</p>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-3 text-gray-900"></h4>
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
{endpoint.parameters.map((param, i) => (
<div key={i} className="flex items-start space-x-3">
<Badge variant="outline" className="font-mono text-xs">
{param.required ? "*" : ""}
{param.name}
</Badge>
<div className="flex-1">
<p className="text-sm">
<span className="text-gray-500 font-mono text-xs">{param.type}</span>
</p>
<p className="text-xs text-gray-500 mt-1">{param.description}</p>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-3 text-gray-900"></h4>
<pre className="bg-gray-50 rounded-lg p-4 text-xs overflow-auto border">
{JSON.stringify(endpoint.response, null, 2)}
</pre>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
{/* 代码示例 */}
<Card className="mt-8" id="examples">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue={apiGuide.examples[0].language}>
<TabsList className="mb-6">
{apiGuide.examples.map((example) => (
<TabsTrigger key={example.language} value={example.language}>
{example.title}
</TabsTrigger>
))}
</TabsList>
{apiGuide.examples.map((example) => (
<TabsContent key={example.language} value={example.language}>
<div className="relative">
<pre className="bg-gray-50 p-6 rounded-lg overflow-auto text-sm border">{example.code}</pre>
<Button
variant="outline"
size="sm"
className="absolute top-3 right-3 bg-transparent"
onClick={() => copyToClipboard(example.code, example.language)}
>
{copiedExample === example.language ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
{/* 集成指南 */}
<div className="mt-8 space-y-6" id="integration">
<h3 className="text-xl font-semibold text-gray-900"></h3>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-3 text-sm">
<li></li>
<li>"应用集成" &gt; "外部接口"</li>
<li>"添加新接口"</li>
<li>"X-API-KEY"API密钥</li>
<li>
URL为
<code className="bg-gray-100 px-2 py-1 rounded ml-2 text-xs">{apiGuide.endpoints[0].url}</code>
</li>
<li>name, phone等</li>
<li></li>
</ol>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-2 text-gray-900"></h4>
<p className="text-sm text-gray-700">
X-API-KEY正确无误
</p>
</div>
<div>
<h4 className="text-sm font-medium mb-2 text-gray-900"></h4>
<p className="text-sm text-gray-700">
</p>
</div>
<div>
<h4 className="text-sm font-medium mb-2 text-gray-900"></h4>
<p className="text-sm text-gray-700">
API密钥每分钟最多可发送30个请求使
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -71,4 +71,3 @@ export async function GET(request: Request) {
)
}
}

View File

@@ -270,4 +270,3 @@ export async function GET(request: Request) {
},
})
}

View File

@@ -1,34 +1,13 @@
"use client"
import "./globals.css"
import BottomNav from "./components/BottomNav"
import "regenerator-runtime/runtime"
import type React from "react"
import ErrorBoundary from "./components/ErrorBoundary"
import { VideoTutorialButton } from "@/components/VideoTutorialButton"
import { AuthProvider } from "@/app/components/AuthProvider"
import { usePathname } from "next/navigation"
import BottomNav from "./components/BottomNav"
// 创建一个包装组件来使用 usePathname hook
function LayoutContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
// 只在主页路径显示底部导航栏
const showBottomNav =
pathname === "/" || pathname === "/devices" || pathname === "/content" || pathname === "/profile"
return (
<div className="mx-auto w-full">
<main className="w-full mx-auto bg-white min-h-screen flex flex-col relative lg:max-w-7xl xl:max-w-[1200px]">
{children}
{showBottomNav && <BottomNav />}
{showBottomNav && <VideoTutorialButton />}
</main>
</div>
)
}
export default function RootLayout({
export default function ClientLayout({
children,
}: {
children: React.ReactNode
@@ -38,11 +17,16 @@ export default function RootLayout({
<body className="bg-gray-100">
<AuthProvider>
<ErrorBoundary>
<LayoutContent>{children}</LayoutContent>
<main className="mx-auto bg-white min-h-screen flex flex-col relative pb-16">
{children}
{/* 移除条件渲染,确保底部导航始终显示 */}
<div className="fixed bottom-0 left-0 right-0 z-50">
<BottomNav />
</div>
</main>
</ErrorBoundary>
</AuthProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,34 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card"
export default function ComponentsDemoLoading() {
return (
<div className="container mx-auto py-8 space-y-8">
<div className="space-y-4">
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3" />
</div>
<div className="space-y-6">
<div className="flex space-x-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-200 rounded animate-pulse flex-1" />
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-6 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4" />
</CardHeader>
<CardContent>
<div className="h-32 bg-gray-200 rounded animate-pulse" />
</CardContent>
</Card>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,493 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Switch } from "@/components/ui/switch"
import { Checkbox } from "@/components/ui/checkbox"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Progress } from "@/components/ui/progress"
import { Slider } from "@/components/ui/slider"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CalendarIcon, Code, Copy, Check, Smartphone, Users, TrendingUp, Activity } from "lucide-react"
/**
* 组件库展示页面
* 展示所有可用的UI组件和自定义组件
*/
export default function ComponentsDemo() {
return (
<div className="container mx-auto py-8 space-y-8">
<div className="space-y-4">
<h1 className="text-3xl font-bold"></h1>
<p className="text-gray-600">UI组件和自定义组件使</p>
</div>
<Tabs defaultValue="basic" className="space-y-6">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="forms"></TabsTrigger>
<TabsTrigger value="data"></TabsTrigger>
<TabsTrigger value="feedback"></TabsTrigger>
<TabsTrigger value="navigation"></TabsTrigger>
<TabsTrigger value="custom"></TabsTrigger>
</TabsList>
{/* 基础组件 */}
<TabsContent value="basic" className="space-y-6">
<ComponentSection title="按钮组件" description="各种样式和状态的按钮">
<div className="flex flex-wrap gap-4">
<Button></Button>
<Button variant="secondary"></Button>
<Button variant="outline"></Button>
<Button variant="ghost"></Button>
<Button variant="destructive"></Button>
<Button disabled></Button>
<Button size="sm"></Button>
<Button size="lg"></Button>
</div>
</ComponentSection>
<ComponentSection title="徽章组件" description="用于标记和分类的徽章">
<div className="flex flex-wrap gap-4">
<Badge></Badge>
<Badge variant="secondary"></Badge>
<Badge variant="outline"></Badge>
<Badge variant="destructive"></Badge>
<Badge className="bg-green-500"></Badge>
<Badge className="bg-yellow-500"></Badge>
</div>
</ComponentSection>
<ComponentSection title="头像组件" description="用户头像展示">
<div className="flex items-center gap-4">
<Avatar>
<AvatarImage src="/placeholder.svg?height=40&width=40" alt="用户头像" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<Avatar className="h-12 w-12">
<AvatarFallback>AB</AvatarFallback>
</Avatar>
<Avatar className="h-16 w-16">
<AvatarFallback>XY</AvatarFallback>
</Avatar>
</div>
</ComponentSection>
<ComponentSection title="分隔符" description="内容分隔线">
<div className="space-y-4">
<div></div>
<Separator />
<div></div>
<div className="flex items-center space-x-4">
<span></span>
<Separator orientation="vertical" className="h-4" />
<span></span>
</div>
</div>
</ComponentSection>
</TabsContent>
{/* 表单组件 */}
<TabsContent value="forms" className="space-y-6">
<ComponentSection title="输入框" description="各种类型的输入框">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="text"></Label>
<Input id="text" placeholder="请输入文本" />
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" type="email" placeholder="请输入邮箱" />
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" type="password" placeholder="请输入密码" />
</div>
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
<Input id="disabled" placeholder="禁用状态" disabled />
</div>
</div>
</ComponentSection>
<ComponentSection title="文本域" description="多行文本输入">
<div className="space-y-2">
<Label htmlFor="textarea"></Label>
<Textarea id="textarea" placeholder="请输入详细描述..." rows={4} />
</div>
</ComponentSection>
<ComponentSection title="选择器" description="下拉选择组件">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="选择设备类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="android">Android</SelectItem>
<SelectItem value="ios">iOS</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="online">线</SelectItem>
<SelectItem value="offline">线</SelectItem>
<SelectItem value="busy"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</ComponentSection>
<ComponentSection title="开关和复选框" description="布尔值输入组件">
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch id="notifications" />
<Label htmlFor="notifications"></Label>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox id="terms" />
<Label htmlFor="terms"></Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="marketing" />
<Label htmlFor="marketing"></Label>
</div>
</div>
</div>
</ComponentSection>
<ComponentSection title="单选按钮" description="单选组件">
<RadioGroup defaultValue="option1">
<div className="flex items-center space-x-2">
<RadioGroupItem value="option1" id="option1" />
<Label htmlFor="option1"> 1</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option2" id="option2" />
<Label htmlFor="option2"> 2</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option3" id="option3" />
<Label htmlFor="option3"> 3</Label>
</div>
</RadioGroup>
</ComponentSection>
<ComponentSection title="滑块" description="数值范围选择">
<div className="space-y-4">
<div className="space-y-2">
<Label>音量: 50%</Label>
<Slider defaultValue={[50]} max={100} step={1} />
</div>
<div className="space-y-2">
<Label>: ¥100 - ¥500</Label>
<Slider defaultValue={[100, 500]} max={1000} step={10} />
</div>
</div>
</ComponentSection>
</TabsContent>
{/* 数据展示 */}
<TabsContent value="data" className="space-y-6">
<ComponentSection title="统计卡片" description="数据统计展示">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCardDemo
title="总设备数"
value="1,234"
icon={<Smartphone className="h-6 w-6" />}
trend={{ value: 12, isPositive: true }}
/>
<StatCardDemo
title="在线用户"
value="856"
icon={<Users className="h-6 w-6" />}
trend={{ value: 8, isPositive: true }}
/>
<StatCardDemo
title="今日获客"
value="342"
icon={<TrendingUp className="h-6 w-6" />}
trend={{ value: 5, isPositive: false }}
/>
<StatCardDemo
title="活跃度"
value="89%"
icon={<Activity className="h-6 w-6" />}
trend={{ value: 3, isPositive: true }}
/>
</div>
</ComponentSection>
<ComponentSection title="进度条" description="进度展示组件">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">
<Label></Label>
<span className="text-sm text-gray-500">75%</span>
</div>
<Progress value={75} />
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label>使</Label>
<span className="text-sm text-gray-500">45%</span>
</div>
<Progress value={45} className="h-2" />
</div>
</div>
</ComponentSection>
<ComponentSection title="表格" description="数据表格展示">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">001</TableCell>
<TableCell>
<Badge className="bg-green-500">线</Badge>
</TableCell>
<TableCell>Android</TableCell>
<TableCell className="text-right">1,234</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">002</TableCell>
<TableCell>
<Badge variant="secondary">线</Badge>
</TableCell>
<TableCell>iOS</TableCell>
<TableCell className="text-right">856</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">003</TableCell>
<TableCell>
<Badge className="bg-yellow-500"></Badge>
</TableCell>
<TableCell>Android</TableCell>
<TableCell className="text-right">567</TableCell>
</TableRow>
</TableBody>
</Table>
</ComponentSection>
</TabsContent>
{/* 反馈组件 */}
<TabsContent value="feedback" className="space-y-6">
<ComponentSection title="对话框" description="模态对话框组件">
<Dialog>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-2">
<Button variant="outline"></Button>
<Button variant="destructive"></Button>
</div>
</DialogContent>
</Dialog>
</ComponentSection>
<ComponentSection title="弹出框" description="悬浮弹出内容">
<div className="flex gap-4">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<CalendarIcon className="mr-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar mode="single" />
</PopoverContent>
</Popover>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline"></Button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</ComponentSection>
</TabsContent>
{/* 导航组件 */}
<TabsContent value="navigation" className="space-y-6">
<ComponentSection title="手风琴" description="可折叠内容面板">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger></AccordionTrigger>
<AccordionContent></AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger></AccordionTrigger>
<AccordionContent></AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger></AccordionTrigger>
<AccordionContent></AccordionContent>
</AccordionItem>
</Accordion>
</ComponentSection>
<ComponentSection title="滚动区域" description="自定义滚动条的内容区域">
<ScrollArea className="h-72 w-full rounded-md border p-4">
<div className="space-y-4">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="p-4 border rounded">
<h4 className="font-medium"> {i + 1}</h4>
<p className="text-sm text-gray-600"> {i + 1} </p>
</div>
))}
</div>
</ScrollArea>
</ComponentSection>
</TabsContent>
{/* 自定义组件 */}
<TabsContent value="custom" className="space-y-6">
<ComponentSection title="文件上传器" description="支持拖拽的文件上传组件">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<p className="text-gray-500"></p>
<Button className="mt-4"></Button>
</div>
</ComponentSection>
</TabsContent>
</Tabs>
</div>
)
}
// 组件展示区域
function ComponentSection({
title,
description,
children,
}: {
title: string
description: string
children: React.ReactNode
}) {
const [showCode, setShowCode] = useState(false)
const [copied, setCopied] = useState(false)
const handleCopy = () => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setShowCode(!showCode)}>
<Code className="h-4 w-4 mr-2" />
{showCode ? "隐藏代码" : "查看代码"}
</Button>
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="p-6 border rounded-lg bg-gray-50">{children}</div>
{showCode && (
<div className="p-4 bg-gray-900 text-gray-100 rounded-lg text-sm overflow-x-auto">
<pre>{`// 示例代码将在这里显示\n// 具体实现请参考组件源码`}</pre>
</div>
)}
</div>
</CardContent>
</Card>
)
}
// 统计卡片演示组件
function StatCardDemo({
title,
value,
icon,
trend,
}: {
title: string
value: string
icon: React.ReactNode
trend: { value: number; isPositive: boolean }
}) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
<div className="text-gray-400">{icon}</div>
</div>
<div className="mt-4 flex items-center">
<span className={`text-sm font-medium ${trend.isPositive ? "text-green-600" : "text-red-600"}`}>
{trend.isPositive ? "+" : "-"}
{trend.value}%
</span>
<span className="text-sm text-gray-500 ml-2"></span>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card"
export default function ComponentsDocsLoading() {
return (
<div className="container mx-auto py-8 space-y-8">
<div className="space-y-4">
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3" />
</div>
<div className="space-y-6">
<div className="flex space-x-1">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-200 rounded animate-pulse flex-1" />
))}
</div>
<Card>
<CardHeader>
<div className="h-6 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4" />
</CardHeader>
<CardContent className="space-y-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded animate-pulse" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,375 @@
"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Separator } from "@/components/ui/separator"
/**
* 组件文档页面
* 提供详细的组件使用文档和API说明
*/
export default function ComponentsDocs() {
return (
<div className="container mx-auto py-8 space-y-8">
<div className="space-y-4">
<h1 className="text-3xl font-bold"></h1>
<p className="text-gray-600">使API文档和最佳实践</p>
</div>
<Tabs defaultValue="getting-started" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="getting-started"></TabsTrigger>
<TabsTrigger value="basic-components"></TabsTrigger>
<TabsTrigger value="custom-components"></TabsTrigger>
<TabsTrigger value="best-practices"></TabsTrigger>
<TabsTrigger value="changelog"></TabsTrigger>
</TabsList>
{/* 快速开始 */}
<TabsContent value="getting-started" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
<pre>{`// 导入基础UI组件
import { Button, Card, Input } from "@/components/ui"
// 导入自定义组件
import { PageHeader, StatCard } from "@/app/components/common"
// 使用组件
function MyComponent() {
return (
<Card>
<PageHeader title="标题" description="描述" />
<Button>点击我</Button>
</Card>
)
}`}</pre>
</div>
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="bg-gray-50 p-4 rounded-lg">
<pre>{`app/
├── components/
│ ├── ui/ # 基础UI组件
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ └── ...
│ └── common/ # 自定义业务组件
│ ├── PageHeader.tsx
│ ├── StatCard.tsx
│ └── ...
├── lib/
│ └── utils.ts # 工具函数
└── types/ # 类型定义`}</pre>
</div>
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-gray-600 mb-3">使 Tailwind CSS </p>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
<pre>{`// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
900: '#1e3a8a',
}
}
}
}
}`}</pre>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 基础组件文档 */}
<TabsContent value="basic-components" className="space-y-6">
<ComponentDoc
name="Button"
description="按钮组件,支持多种样式和状态"
props={[
{
name: "variant",
type: "'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'",
default: "'default'",
description: "按钮样式变体",
},
{ name: "size", type: "'sm' | 'default' | 'lg'", default: "'default'", description: "按钮大小" },
{ name: "disabled", type: "boolean", default: "false", description: "是否禁用" },
{ name: "onClick", type: "() => void", default: "-", description: "点击事件处理函数" },
]}
example={`<Button variant="primary" size="lg" onClick={() => alert('clicked')}>
点击我
</Button>`}
/>
<ComponentDoc
name="Card"
description="卡片容器组件,用于包装内容"
props={[
{ name: "className", type: "string", default: "-", description: "自定义CSS类名" },
{ name: "children", type: "ReactNode", default: "-", description: "卡片内容" },
]}
example={`<Card>
<CardHeader>
<CardTitle>标题</CardTitle>
<CardDescription>描述</CardDescription>
</CardHeader>
<CardContent>
内容区域
</CardContent>
</Card>`}
/>
<ComponentDoc
name="Input"
description="输入框组件,支持多种类型"
props={[
{
name: "type",
type: "'text' | 'email' | 'password' | 'number'",
default: "'text'",
description: "输入类型",
},
{ name: "placeholder", type: "string", default: "-", description: "占位符文本" },
{ name: "disabled", type: "boolean", default: "false", description: "是否禁用" },
{ name: "value", type: "string", default: "-", description: "输入值" },
{ name: "onChange", type: "(e: ChangeEvent) => void", default: "-", description: "值变化回调" },
]}
example={`<Input
type="email"
placeholder="请输入邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>`}
/>
</TabsContent>
{/* 自定义组件文档 */}
<TabsContent value="custom-components" className="space-y-6">
<ComponentDoc
name="PageHeader"
description="页面头部组件,提供统一的页面标题和描述布局"
props={[
{ name: "title", type: "string", default: "-", description: "页面标题" },
{ name: "description", type: "string", default: "-", description: "页面描述" },
{ name: "actions", type: "ReactNode", default: "-", description: "操作按钮区域" },
{ name: "breadcrumb", type: "ReactNode", default: "-", description: "面包屑导航" },
]}
example={`<PageHeader
title="设备管理"
description="管理所有连接的设备"
actions={<Button>添加设备</Button>}
/>`}
/>
<ComponentDoc
name="StatCard"
description="统计卡片组件,用于展示关键指标"
props={[
{ name: "title", type: "string", default: "-", description: "统计标题" },
{ name: "value", type: "string | number", default: "-", description: "统计值" },
{ name: "icon", type: "ReactNode", default: "-", description: "图标" },
{ name: "trend", type: "{ value: number; isPositive: boolean }", default: "-", description: "趋势信息" },
{ name: "description", type: "string", default: "-", description: "描述信息" },
]}
example={`<StatCard
title="总设备数"
value="1,234"
icon={<Smartphone className="h-6 w-6" />}
trend={{ value: 12, isPositive: true }}
/>`}
/>
</TabsContent>
{/* 最佳实践 */}
<TabsContent value="best-practices" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">使</h3>
<ul className="space-y-2 text-gray-600">
<li> 使</li>
<li> </li>
<li> 使TypeScript确保类型安全</li>
<li> </li>
<li> </li>
</ul>
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<ul className="space-y-2 text-gray-600">
<li> 使React.memo优化组件渲染</li>
<li> 使useCallback和useMemo</li>
<li> render中创建新对象</li>
<li> 使</li>
<li> </li>
</ul>
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3">访</h3>
<ul className="space-y-2 text-gray-600">
<li> ARIA标签</li>
<li> </li>
<li> </li>
<li> alt属性</li>
<li> 使HTML标签</li>
</ul>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 更新日志 */}
<TabsContent value="changelog" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-2">
<Badge>v1.2.0</Badge>
<span className="text-sm text-gray-500">2024-01-15</span>
</div>
<h4 className="font-medium mb-2"></h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> FileUploader </li>
<li> Wizard </li>
<li> NotificationSystem </li>
<li> </li>
</ul>
</div>
<Separator />
<div>
<div className="flex items-center gap-2 mb-2">
<Badge variant="secondary">v1.1.0</Badge>
<span className="text-sm text-gray-500">2024-01-10</span>
</div>
<h4 className="font-medium mb-2"></h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> DeviceSelector </li>
<li> DataTable </li>
<li> </li>
</ul>
</div>
<Separator />
<div>
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline">v1.0.0</Badge>
<span className="text-sm text-gray-500">2024-01-01</span>
</div>
<h4 className="font-medium mb-2"></h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> UI组件库</li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
// 组件文档展示组件
function ComponentDoc({
name,
description,
props,
example,
}: {
name: string
description: string
props: Array<{
name: string
type: string
default: string
description: string
}>
example: string
}) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{name}
<Badge variant="outline"></Badge>
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="font-medium mb-3"> (Props)</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.map((prop) => (
<TableRow key={prop.name}>
<TableCell className="font-mono text-sm">{prop.name}</TableCell>
<TableCell className="font-mono text-sm text-blue-600">{prop.type}</TableCell>
<TableCell className="font-mono text-sm">{prop.default}</TableCell>
<TableCell>{prop.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div>
<h4 className="font-medium mb-3">使</h4>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-sm">{example}</pre>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -51,4 +51,3 @@ export function AIRewriteModal({ isOpen, onClose, originalContent }: AIRewriteMo
</div>
)
}

View File

@@ -0,0 +1,22 @@
"use client"
import type React from "react"
import BottomNav from "./BottomNav"
function AdaptiveLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50">
{/* 主内容区域 */}
<main className="max-w-[390px] mx-auto">
<div className="bg-white min-h-screen pb-16">{children}</div>
</main>
{/* 始终显示底部导航 */}
<BottomNav />
</div>
)
}
// 提供默认导出和命名导出
export default AdaptiveLayout
export { AdaptiveLayout }

View File

@@ -0,0 +1,80 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { login, getUserInfo } from "@/lib/api/auth"
export function ApiTester() {
const [testAccount, setTestAccount] = useState("13800138000")
const [testPassword, setTestPassword] = useState("123456")
const [testResult, setTestResult] = useState<any>(null)
const [isLoading, setIsLoading] = useState(false)
const testLogin = async () => {
setIsLoading(true)
try {
const result = await login({
account: testAccount,
password: testPassword,
typeid: 1,
})
setTestResult(result)
} catch (error) {
setTestResult({ error: error instanceof Error ? error.message : "测试失败" })
} finally {
setIsLoading(false)
}
}
const testUserInfo = async () => {
setIsLoading(true)
try {
const result = await getUserInfo()
setTestResult(result)
} catch (error) {
setTestResult({ error: error instanceof Error ? error.message : "测试失败" })
} finally {
setIsLoading(false)
}
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
API测试工具
<Badge variant="outline"></Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Input placeholder="测试账号" value={testAccount} onChange={(e) => setTestAccount(e.target.value)} />
<Input
placeholder="测试密码"
type="password"
value={testPassword}
onChange={(e) => setTestPassword(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button onClick={testLogin} disabled={isLoading} size="sm">
</Button>
<Button onClick={testUserInfo} disabled={isLoading} size="sm" variant="outline">
</Button>
</div>
{testResult && (
<div className="mt-4 p-3 bg-gray-100 rounded-md">
<pre className="text-xs overflow-auto">{JSON.stringify(testResult, null, 2)}</pre>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,53 +1,35 @@
"use client"
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
import { useRouter } from "next/navigation"
import { validateToken } from "@/lib/api"
// 安全的localStorage访问方法
const safeLocalStorage = {
getItem: (key: string): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem(key)
}
return null
},
setItem: (key: string, value: string): void => {
if (typeof window !== 'undefined') {
localStorage.setItem(key, value)
}
},
removeItem: (key: string): void => {
if (typeof window !== 'undefined') {
localStorage.removeItem(key)
}
}
}
import { useRouter, usePathname } from "next/navigation"
import { checkLoginStatus, getCurrentUser, getToken, clearAuthData } from "@/lib/api/auth"
interface User {
id: number;
username: string;
account?: string;
avatar?: string;
id: string
username: string
phone: string
avatar?: string
role: string
nickname?: string
email?: string
}
interface AuthContextType {
isAuthenticated: boolean
token: string | null
user: User | null
login: (token: string, userData: User) => void
token: string | null
login: (token: string) => void
logout: () => void
updateToken: (newToken: string) => void
isLoading: boolean
}
// 创建默认上下文
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
token: null,
user: null,
token: null,
login: () => {},
logout: () => {},
updateToken: () => {}
isLoading: true,
})
export const useAuth = () => useContext(AuthContext)
@@ -56,139 +38,111 @@ interface AuthProviderProps {
children: ReactNode
}
// 不需要认证的页面路径
const PUBLIC_PATHS = ["/login", "/register", "/forgot-password"]
export function AuthProvider({ children }: AuthProviderProps) {
// 避免在服务端渲染时设置初始状态
const [token, setToken] = useState<string | null>(null)
const [user, setUser] = useState<User | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
// 初始页面加载时显示为false避免在服务端渲染和客户端水合时不匹配
const [isLoading, setIsLoading] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const router = useRouter()
const [isLoading, setIsLoading] = useState(true)
const router = useRouter()
const pathname = usePathname()
// 初始化认证状态
useEffect(() => {
// 仅在客户端执行初始化
setIsLoading(true)
const initAuth = async () => {
try {
const storedToken = safeLocalStorage.getItem("token")
if (storedToken) {
// 首先尝试从localStorage获取用户信息
const userDataStr = safeLocalStorage.getItem("userInfo")
if (userDataStr) {
try {
// 如果能解析用户数据,先设置登录状态
const userData = JSON.parse(userDataStr) as User
setToken(storedToken)
setUser(userData)
setIsAuthenticated(true)
// 然后在后台尝试验证token但不影响当前登录状态
validateToken().then(isValid => {
// 只有在确认token绝对无效时才登出
// 网络错误等情况默认保持登录状态
if (isValid === false) {
console.warn('验证token失败但仍允许用户保持登录状态')
}
}).catch(error => {
// 捕获所有验证过程中的错误,并记录日志
console.error('验证token过程中出错:', error)
// 网络错误等不会导致登出
})
} catch (parseError) {
// 用户数据无法解析,需要清除
console.error('解析用户数据失败:', parseError)
handleLogout()
}
} else {
// 有token但没有用户信息可能是部分数据丢失
console.warn('找到token但没有用户信息尝试保持登录状态')
// 尝试验证token并获取用户信息
try {
const isValid = await validateToken()
if (isValid) {
// 如果token有效尝试从API获取用户信息
// 这里简化处理直接使用token
setToken(storedToken)
setIsAuthenticated(true)
} else {
// token确认无效清除
handleLogout()
}
} catch (error) {
// 验证过程出错,记录日志但不登出
console.error('验证token过程中出错:', error)
// 保留token允许用户继续使用
setToken(storedToken)
setIsAuthenticated(true)
}
}
// 初始化认证状态
const initAuth = () => {
console.log("初始化认证状态...")
const isLoggedIn = checkLoginStatus()
const currentUser = getCurrentUser()
const currentToken = getToken()
console.log("认证检查结果:", { isLoggedIn, hasUser: !!currentUser, hasToken: !!currentToken })
if (isLoggedIn && currentUser && currentToken) {
setToken(currentToken)
setUser(currentUser)
setIsAuthenticated(true)
console.log("用户已登录:", currentUser)
} else {
setToken(null)
setUser(null)
setIsAuthenticated(false)
console.log("用户未登录")
// 如果当前页面需要认证且用户未登录,跳转到登录页
if (!PUBLIC_PATHS.includes(pathname)) {
console.log("重定向到登录页")
router.push("/login")
}
} catch (error) {
console.error("初始化认证状态时出错:", error)
// 非401错误不应强制登出
if (error instanceof Error &&
(error.message.includes('401') ||
error.message.includes('未授权') ||
error.message.includes('token'))) {
handleLogout()
}
} finally {
setIsLoading(false)
setIsInitialized(true)
}
setIsLoading(false)
}
initAuth()
}, []) // 空依赖数组,仅在组件挂载时执行一次
}, [pathname, router])
const handleLogout = () => {
// 先清除所有认证相关的状态
safeLocalStorage.removeItem("token")
safeLocalStorage.removeItem("token_expired")
safeLocalStorage.removeItem("s2_accountId")
safeLocalStorage.removeItem("userInfo")
safeLocalStorage.removeItem("user")
setToken(null)
setUser(null)
setIsAuthenticated(false)
// 使用 window.location 而不是 router.push避免状态更新和路由跳转的竞态条件
if (typeof window !== 'undefined') {
window.location.href = '/login'
// 监听认证错误事件
useEffect(() => {
const handleAuthError = (event: CustomEvent) => {
if (event.detail === "UNAUTHORIZED") {
console.log("收到未授权事件,清除认证状态")
logout()
}
}
}
const login = (newToken: string, userData: User) => {
safeLocalStorage.setItem("token", newToken)
safeLocalStorage.setItem("userInfo", JSON.stringify(userData))
window.addEventListener("auth-error", handleAuthError as EventListener)
return () => {
window.removeEventListener("auth-error", handleAuthError as EventListener)
}
}, [])
const login = (newToken: string) => {
const currentUser = getCurrentUser()
console.log("更新认证状态:", { token: newToken.substring(0, 10) + "...", user: currentUser })
setToken(newToken)
setUser(userData)
setUser(currentUser)
setIsAuthenticated(true)
}
const logout = () => {
handleLogout()
console.log("执行登出操作")
clearAuthData()
setToken(null)
setUser(null)
setIsAuthenticated(false)
router.push("/login")
}
// 用于刷新 token 的方法
const updateToken = (newToken: string) => {
safeLocalStorage.setItem("token", newToken)
setToken(newToken)
// 如果正在加载,显示加载状态
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
)
}
return (
<AuthContext.Provider value={{ isAuthenticated, token, user, login, logout, updateToken }}>
{isLoading && isInitialized ? (
<div className="flex h-screen w-screen items-center justify-center">...</div>
) : (
children
)}
<AuthContext.Provider
value={{
isAuthenticated,
user,
token,
login,
logout,
isLoading,
}}
>
{children}
</AuthContext.Provider>
)
}

View File

@@ -29,4 +29,3 @@ export function BindDouyinQRCode() {
</>
)
}

View File

@@ -1,36 +1,55 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, Users, User, Briefcase } from "lucide-react"
const navItems = [
{ href: "/", icon: Home, label: "首页" },
{ href: "/scenarios", icon: Users, label: "场景获客" },
{ href: "/workspace", icon: Briefcase, label: "工作台" },
{ href: "/profile", icon: User, label: "我的" },
]
import { usePathname, useRouter } from "next/navigation"
import { Home, Users, LayoutGrid, User } from "lucide-react"
export default function BottomNav() {
const pathname = usePathname()
const router = useRouter()
const navItems = [
{
name: "首页",
href: "/",
icon: Home,
active: pathname === "/",
},
{
name: "场景获客",
href: "/scenarios",
icon: Users,
active: pathname.startsWith("/scenarios"),
},
{
name: "工作台",
href: "/workspace",
icon: LayoutGrid,
active: pathname.startsWith("/workspace"),
},
{
name: "我的",
href: "/profile",
icon: User,
active: pathname.startsWith("/profile"),
},
]
return (
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200">
<div className="w-full mx-auto flex justify-around">
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-area-pb">
<div className="flex justify-around items-center h-16 max-w-md mx-auto">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center py-2 px-3 ${
pathname === item.href ? "text-blue-500" : "text-gray-500"
<button
key={item.name}
onClick={() => router.push(item.href)}
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
item.active ? "text-blue-500" : "text-gray-500 hover:text-gray-900"
}`}
>
<item.icon className="w-6 h-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
<item.icon className="w-5 h-5" />
<span className="text-xs mt-1">{item.name}</span>
</button>
))}
</div>
</nav>
</div>
)
}

View File

@@ -82,4 +82,3 @@ export function TrendChart({ data, dataKey = "value", height = 300 }) {
</div>
)
}

View File

@@ -37,4 +37,3 @@ export function ChatMessage({ content, isUser, timestamp, avatar }: ChatMessageP
</div>
)
}

View File

@@ -103,4 +103,3 @@ export function ContentSelector({ onPrev, onFinish }) {
</Card>
)
}

View File

@@ -120,4 +120,3 @@ export function DeviceSelector({ onNext, onPrev }) {
</Card>
)
}

View File

@@ -98,4 +98,3 @@ export function TaskSetup({ onNext, onPrev, step }: TaskSetupProps) {
</Card>
)
}

View File

@@ -18,7 +18,6 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import { ImeiDisplay } from "@/components/ImeiDisplay"
interface WechatAccount {
wechatId: string
@@ -170,10 +169,7 @@ export function DeviceSelector({
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className="text-sm text-gray-500 flex items-center">
<span className="mr-1">IMEI:</span>
<ImeiDisplay imei={device.imei} containerWidth={160} />
</div>
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
<div className="mt-2 space-y-2">
{device.wechatAccounts.map((account) => (
<div key={account.wechatId} className="bg-gray-50 rounded-lg p-2">
@@ -255,3 +251,7 @@ export function DeviceSelector({
)
}
// 添加 DeviceSelection 命名导出
export function DeviceSelection({ onSelect, initialSelectedDevices = [] }: DeviceSelectorProps) {
return <DeviceSelector onSelect={onSelect} initialSelectedDevices={initialSelectedDevices} />
}

View File

@@ -44,4 +44,3 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
}
export default ErrorBoundary

View File

@@ -147,4 +147,3 @@ export function FileUploader({
</div>
)
}

View File

@@ -1,58 +1,40 @@
"use client"
import { usePathname } from "next/navigation"
import BottomNav from "./BottomNav"
import { VideoTutorialButton } from "@/components/VideoTutorialButton"
import type React from "react"
import { createContext, useContext, useState, useEffect } from "react"
import { createContext, useContext, useState } from "react"
import AdaptiveLayout from "./AdaptiveLayout"
// 创建视图模式上下文
const ViewModeContext = createContext<{ viewMode: "desktop" | "mobile" }>({ viewMode: "desktop" })
const ViewModeContext = createContext<{
viewMode: "mobile" | "desktop"
setViewMode: (mode: "mobile" | "desktop") => void
}>({
viewMode: "mobile",
setViewMode: () => {},
})
// 创建视图模式钩子函数
// 导出 useViewMode hook
export function useViewMode() {
const context = useContext(ViewModeContext)
if (!context) {
throw new Error("useViewMode must be used within a LayoutWrapper")
throw new Error("useViewMode must be used within ViewModeProvider")
}
return context
}
export default function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const [viewMode, setViewMode] = useState<"desktop" | "mobile">("desktop")
interface LayoutWrapperProps {
children: React.ReactNode
}
// 检测视图模式
useEffect(() => {
const checkViewMode = () => {
setViewMode(window.innerWidth < 768 ? "mobile" : "desktop")
}
// 初始检测
checkViewMode()
// 监听窗口大小变化
window.addEventListener("resize", checkViewMode)
return () => {
window.removeEventListener("resize", checkViewMode)
}
}, [])
// 只在四个主页显示底部导航栏:首页、场景获客、工作台和我的
const mainPages = ["/", "/scenarios", "/workspace", "/profile"]
const showBottomNav = mainPages.includes(pathname)
function LayoutWrapper({ children }: LayoutWrapperProps) {
const [viewMode, setViewMode] = useState<"mobile" | "desktop">("mobile")
return (
<ViewModeContext.Provider value={{ viewMode }}>
<div className="mx-auto w-full">
<main className="w-full mx-auto bg-white min-h-screen flex flex-col relative lg:max-w-7xl xl:max-w-[1200px]">
{children}
{showBottomNav && <BottomNav />}
{showBottomNav && <VideoTutorialButton />}
</main>
</div>
<ViewModeContext.Provider value={{ viewMode, setViewMode }}>
<AdaptiveLayout>{children}</AdaptiveLayout>
</ViewModeContext.Provider>
)
}
export default LayoutWrapper
export { LayoutWrapper }

View File

@@ -0,0 +1,68 @@
"use client"
import { useEffect } from "react"
import { useToast } from "@/components/ui/use-toast"
import { useRouter } from "next/navigation"
interface LoginErrorHandlerProps {
onRetry?: () => void
}
export function LoginErrorHandler({ onRetry }: LoginErrorHandlerProps) {
const { toast } = useToast()
const router = useRouter()
// 监听网络状态
useEffect(() => {
const handleOnline = () => {
toast({
title: "网络已恢复",
description: "您可以继续登录",
})
}
const handleOffline = () => {
toast({
variant: "destructive",
title: "网络连接已断开",
description: "请检查您的网络连接后重试",
})
}
window.addEventListener("online", handleOnline)
window.addEventListener("offline", handleOffline)
return () => {
window.removeEventListener("online", handleOnline)
window.removeEventListener("offline", handleOffline)
}
}, [toast])
// 处理401错误未授权
useEffect(() => {
const handleUnauthorized = (event: CustomEvent) => {
if (event.detail === "UNAUTHORIZED") {
toast({
variant: "destructive",
title: "登录已过期",
description: "请重新登录",
})
// 清除本地存储的登录信息
localStorage.removeItem("token")
localStorage.removeItem("user")
// 重定向到登录页
router.push("/login")
}
}
window.addEventListener("auth-error", handleUnauthorized as EventListener)
return () => {
window.removeEventListener("auth-error", handleUnauthorized as EventListener)
}
}, [toast, router])
return null
}

View File

@@ -0,0 +1,6 @@
"use client"
// 由于我们不再使用PC自适应这个组件可以简化为一个空组件
export default function SideNav({ className }: { className?: string }) {
return null
}

View File

@@ -69,4 +69,3 @@ export function SpeechToTextProcessor({
return null // 这是一个功能性组件不渲染任何UI
}

View File

@@ -174,4 +174,3 @@ export function TrafficTeamSettings({ formData = {}, onChange }: TrafficTeamSett
</Card>
)
}

View File

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

View File

@@ -60,4 +60,3 @@ export function VoiceRecognition({ onResult, onStop }: VoiceRecognitionProps) {
</div>
)
}

View File

@@ -96,4 +96,3 @@ export function AcquisitionPlanChart({ data }: AcquisitionPlanChartProps) {
</div>
)
}

View File

@@ -52,4 +52,3 @@ export function DailyAcquisitionChart({ data, height = 200 }: DailyAcquisitionCh
</div>
)
}

View File

@@ -72,4 +72,3 @@ export function DeviceTreeChart() {
</Card>
)
}

View File

@@ -35,6 +35,12 @@ interface ExpandableAcquisitionCardProps {
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
}
// 计算通过率的工具函数
function calculatePassRate(acquired: number, added: number): number {
if (acquired === 0) return 0
return Math.round((added / acquired) * 100)
}
export function ExpandableAcquisitionCard({
task,
channel,
@@ -196,10 +202,3 @@ export function ExpandableAcquisitionCard({
</div>
)
}
// 计算通过率
function calculatePassRate(acquired: number, added: number) {
if (acquired === 0) return 0
return Math.round((added / acquired) * 100)
}

View File

@@ -94,4 +94,3 @@ export function NewAcquisitionPlanForm({ onSubmit, onCancel }: NewAcquisitionPla
</form>
)
}

View File

@@ -4,7 +4,7 @@ import type React from "react"
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from "lucide-react"
import { MoreHorizontal, Copy, Pencil, Trash2, Link, Play, Pause } from "lucide-react"
interface Task {
id: string
@@ -19,10 +19,6 @@ interface Task {
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
reqConf?: {
device?: string[]
selectedDevices?: string[]
}
}
interface ScenarioAcquisitionCardProps {
@@ -35,6 +31,12 @@ interface ScenarioAcquisitionCardProps {
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
}
// 计算通过率的工具函数
function calculatePassRate(acquired: number, added: number): number {
if (acquired === 0) return 0
return Math.round((added / acquired) * 100)
}
export function ScenarioAcquisitionCard({
task,
channel,
@@ -44,21 +46,11 @@ export function ScenarioAcquisitionCard({
onOpenSettings,
onStatusChange,
}: ScenarioAcquisitionCardProps) {
// 兼容后端真实数据结构
const deviceCount = Array.isArray(task.reqConf?.device)
? task.reqConf.device.length
: Array.isArray(task.reqConf?.selectedDevices)
? task.reqConf.selectedDevices.length
: 0
// 获客数和已添加数可根据 msgConf 或其它字段自定义
const acquiredCount = task.acquiredCount ?? 0
const addedCount = task.addedCount ?? 0
const passRate = task.passRate ?? 0
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
const passRate = calculatePassRate(acquiredCount, addedCount)
const [menuOpen, setMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const isActive = task.status === 1;
const handleStatusChange = (e: React.MouseEvent) => {
e.stopPropagation()
if (onStatusChange) {
@@ -112,16 +104,30 @@ export function ScenarioAcquisitionCard({
}, [])
return (
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80 backdrop-blur-sm">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Badge
variant={isActive ? "success" : "secondary"}
className="cursor-pointer hover:opacity-80"
variant={task.status === "running" ? "default" : "secondary"}
className={`cursor-pointer hover:opacity-80 ${
task.status === "running"
? "bg-green-500 hover:bg-green-600 text-white"
: "bg-gray-500 hover:bg-gray-600 text-white"
}`}
onClick={handleStatusChange}
>
{isActive ? "进行中" : "已暂停"}
{task.status === "running" ? (
<>
<Play className="h-3 w-3 mr-1" />
</>
) : (
<>
<Pause className="h-3 w-3 mr-1" />
</>
)}
</Badge>
</div>
<div className="relative z-20" ref={menuRef}>
@@ -166,7 +172,7 @@ export function ScenarioAcquisitionCard({
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
<div className="grid grid-cols-4 gap-2 mb-4">
<a href={`/scenarios/${channel}/devices`} className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
@@ -194,19 +200,10 @@ export function ScenarioAcquisitionCard({
</Card>
</div>
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.lastUpdated}</span>
</div>
<div className="flex items-center justify-between text-sm text-gray-500 border-t pt-4">
<div>{task.lastUpdated}</div>
<div>{task.nextExecutionTime}</div>
</div>
</Card>
)
}
// 计算通过率
function calculatePassRate(acquired: number, added: number) {
if (acquired === 0) return 0
return Math.round((added / acquired) * 100)
}

View File

@@ -0,0 +1,242 @@
"use client"
import { useState } from "react"
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 { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { QrCode, Upload } from "lucide-react"
import { DeviceType, DeviceCategory } from "@/types/device"
import { toast } from "@/components/ui/use-toast"
interface AddDeviceDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onDeviceAdded?: (device: any) => void
}
export function AddDeviceDialog({ open, onOpenChange, onDeviceAdded }: AddDeviceDialogProps) {
const [activeTab, setActiveTab] = useState("qr")
const [formData, setFormData] = useState({
name: "",
imei: "",
type: DeviceType.ANDROID,
category: DeviceCategory.ACQUISITION,
model: "",
remark: "",
tags: [] as string[],
location: "",
operator: "",
})
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
if (!formData.name || !formData.imei) {
toast({
title: "请填写必填信息",
description: "设备名称和IMEI是必填项",
variant: "destructive",
})
return
}
setLoading(true)
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
const newDevice = {
id: `device-${Date.now()}`,
...formData,
status: "offline",
battery: 100,
friendCount: 0,
todayAdded: 0,
lastActive: new Date().toLocaleString(),
addFriendStatus: "normal",
totalTasks: 0,
completedTasks: 0,
activePlans: [],
planNames: [],
}
onDeviceAdded?.(newDevice)
toast({
title: "设备添加成功",
description: `设备 ${formData.name} 已成功添加`,
})
// 重置表单
setFormData({
name: "",
imei: "",
type: DeviceType.ANDROID,
category: DeviceCategory.ACQUISITION,
model: "",
remark: "",
tags: [],
location: "",
operator: "",
})
onOpenChange(false)
} catch (error) {
toast({
title: "添加失败",
description: "设备添加失败,请重试",
variant: "destructive",
})
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="qr"></TabsTrigger>
<TabsTrigger value="manual"></TabsTrigger>
<TabsTrigger value="batch"></TabsTrigger>
</TabsList>
<TabsContent value="qr" className="space-y-4">
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="w-48 h-48 bg-gray-100 rounded-lg flex items-center justify-center">
<QrCode className="w-16 h-16 text-gray-400" />
</div>
<p className="text-sm text-gray-500 text-center">使</p>
<Input placeholder="或输入设备ID" className="max-w-[200px]" />
</div>
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="输入设备名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor="imei">IMEI *</Label>
<Input
id="imei"
value={formData.imei}
onChange={(e) => setFormData({ ...formData, imei: e.target.value })}
placeholder="输入IMEI"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Select
value={formData.type}
onValueChange={(value) => setFormData({ ...formData, type: value as DeviceType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DeviceType.ANDROID}>Android</SelectItem>
<SelectItem value={DeviceType.IOS}>iOS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={formData.category}
onValueChange={(value) => setFormData({ ...formData, category: value as DeviceCategory })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DeviceCategory.ACQUISITION}></SelectItem>
<SelectItem value={DeviceCategory.MAINTENANCE}></SelectItem>
<SelectItem value={DeviceCategory.TESTING}></SelectItem>
<SelectItem value={DeviceCategory.BACKUP}></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="model"></Label>
<Input
id="model"
value={formData.model}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
placeholder="输入设备型号"
/>
</div>
<div className="space-y-2">
<Label htmlFor="location"></Label>
<Input
id="location"
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
placeholder="输入设备位置"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="remark"></Label>
<Textarea
id="remark"
value={formData.remark}
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
placeholder="输入备注信息"
rows={3}
/>
</div>
</TabsContent>
<TabsContent value="batch" className="space-y-4">
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="w-48 h-32 bg-gray-100 rounded-lg flex items-center justify-center border-2 border-dashed border-gray-300">
<Upload className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 text-center">
Excel文件到此处或点击上传
<br />
<a href="#" className="text-blue-500 hover:underline">
</a>
</p>
<Button variant="outline">
<Upload className="w-4 h-4 mr-2" />
</Button>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit} disabled={loading || activeTab !== "manual"}>
{loading ? "添加中..." : "确认添加"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,155 @@
"use client"
import type React from "react"
import type { ReactNode } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/app/components/ui/card"
import { Badge } from "@/app/components/ui/badge"
import { Button } from "@/app/components/ui/button"
import { Skeleton } from "@/app/components/ui/skeleton"
interface CardAction {
label: string
onClick: (e?: React.MouseEvent) => void
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
icon?: ReactNode
}
interface CardItem {
id: string
title: string
description?: string
image?: string
tags?: string[]
status?: {
label: string
variant: "default" | "secondary" | "destructive" | "outline" | "success"
}
metadata?: Array<{
label: string
value: string | number
icon?: ReactNode
}>
onClick?: () => void
actions?: CardAction[]
}
interface CardGridProps {
items: CardItem[]
loading?: boolean
columns?: 1 | 2 | 3 | 4
emptyText?: string
emptyAction?: {
label: string
onClick: () => void
}
}
export function CardGrid({ items, loading = false, columns = 3, emptyText = "暂无数据", emptyAction }: CardGridProps) {
const gridCols = {
1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
}
if (loading) {
return (
<div className={`grid ${gridCols[columns]} gap-6`}>
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} className="overflow-hidden">
<CardHeader className="pb-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</CardHeader>
<CardContent>
<Skeleton className="h-32 w-full mb-4" />
<div className="space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-2/3" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
if (items.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground mb-4">{emptyText}</p>
{emptyAction && <Button onClick={emptyAction.onClick}>{emptyAction.label}</Button>}
</div>
)
}
return (
<div className={`grid ${gridCols[columns]} gap-6`}>
{items.map((item) => (
<Card
key={item.id}
className={`overflow-hidden transition-all hover:shadow-md ${item.onClick ? "cursor-pointer" : ""}`}
onClick={item.onClick}
>
{item.image && (
<div className="aspect-video overflow-hidden">
<img src={item.image || "/placeholder.svg"} alt={item.title} className="w-full h-full object-cover" />
</div>
)}
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<CardTitle className="text-lg">{item.title}</CardTitle>
{item.description && <CardDescription>{item.description}</CardDescription>}
</div>
{item.status && <Badge variant={item.status.variant as any}>{item.status.label}</Badge>}
</div>
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-2">
{item.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</CardHeader>
<CardContent className="pt-0">
{item.metadata && item.metadata.length > 0 && (
<div className="grid grid-cols-2 gap-3 mb-4">
{item.metadata.map((meta, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
{meta.icon}
<span className="text-muted-foreground">{meta.label}:</span>
<span className="font-medium">{meta.value}</span>
</div>
))}
</div>
)}
{item.actions && item.actions.length > 0 && (
<div className="flex gap-2 pt-2">
{item.actions.map((action, index) => (
<Button
key={index}
variant={action.variant || "outline"}
size="sm"
onClick={action.onClick}
className="flex items-center gap-1"
>
{action.icon}
{action.label}
</Button>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,242 @@
"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/app/components/ui/card"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/app/components/ui/chart"
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
Legend,
} from "recharts"
export interface ChartData {
[key: string]: any
}
export interface ChartConfig {
[key: string]: {
label: string
color: string
}
}
export interface BaseChartProps {
title?: string
description?: string
data: ChartData[]
config: ChartConfig
className?: string
height?: number
}
// 折线图组件
export function LineChartComponent({ title, description, data, config, className, height = 300 }: BaseChartProps) {
return (
<Card className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
<ChartContainer config={config} className={`h-[${height}px]`}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
{Object.entries(config).map(([key, { color }]) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={color}
strokeWidth={2}
dot={{ fill: color, strokeWidth: 2, r: 4 }}
activeDot={{ r: 6 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
)
}
// 面积图组件
export function AreaChartComponent({ title, description, data, config, className, height = 300 }: BaseChartProps) {
return (
<Card className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
<ChartContainer config={config} className={`h-[${height}px]`}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
{Object.entries(config).map(([key, { color }]) => (
<Area
key={key}
type="monotone"
dataKey={key}
stackId="1"
stroke={color}
fill={color}
fillOpacity={0.6}
/>
))}
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
)
}
// 柱状图组件
export function BarChartComponent({ title, description, data, config, className, height = 300 }: BaseChartProps) {
return (
<Card className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
<ChartContainer config={config} className={`h-[${height}px]`}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
{Object.entries(config).map(([key, { color }]) => (
<Bar key={key} dataKey={key} fill={color} radius={[4, 4, 0, 0]} />
))}
</BarChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
)
}
// 饼图组件
export function PieChartComponent({
title,
description,
data,
config,
className,
height = 300,
}: BaseChartProps & { dataKey?: string; nameKey?: string }) {
const COLORS = Object.values(config).map((item) => item.color)
return (
<Card className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
<ChartContainer config={config} className={`h-[${height}px]`}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
</PieChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
)
}
// 组合图表组件
export function ComboChartComponent({
title,
description,
data,
config,
className,
height = 300,
lineKeys = [],
barKeys = [],
}: BaseChartProps & { lineKeys?: string[]; barKeys?: string[] }) {
return (
<Card className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
<ChartContainer config={config} className={`h-[${height}px]`}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
{barKeys.map((key) => (
<Bar key={key} dataKey={key} fill={config[key]?.color} radius={[4, 4, 0, 0]} />
))}
{lineKeys.map((key) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={config[key]?.color}
strokeWidth={2}
dot={{ fill: config[key]?.color, strokeWidth: 2, r: 4 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,287 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Input } from "@/components/ui/input"
import { Search, Plus, Trash2 } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
export interface ContentTarget {
id: string
avatar: string
name?: string
}
export interface ContentLibrary {
id: string
name: string
type?: string
count?: number
targets?: ContentTarget[]
}
export interface ContentSelectorProps {
/** 已选择的内容库 */
selectedLibraries: ContentLibrary[]
/** 内容库变更回调 */
onLibrariesChange: (libraries: ContentLibrary[]) => void
/** 上一步回调 */
onPrevious?: () => void
/** 下一步回调 */
onNext?: () => void
/** 保存回调 */
onSave?: () => void
/** 取消回调 */
onCancel?: () => void
/** 自定义内容库列表,不传则使用模拟数据 */
contentLibraries?: ContentLibrary[]
/** 自定义类名 */
className?: string
/** 是否使用卡片包装 */
withCard?: boolean
}
/**
* 统一的内容选择器组件
*/
export function ContentSelector({
selectedLibraries = [],
onLibrariesChange,
onPrevious,
onNext,
onSave,
onCancel,
contentLibraries: propContentLibraries,
className,
withCard = true,
}: ContentSelectorProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState("")
// 模拟内容库数据
const defaultContentLibraries: ContentLibrary[] = [
{
id: "1",
name: "卡若朋友圈",
type: "朋友圈",
count: 307,
targets: [{ id: "t1", avatar: "/placeholder.svg?height=40&width=40&query=avatar1" }],
},
{
id: "2",
name: "业务推广内容",
type: "朋友圈",
count: 156,
targets: [
{ id: "t2", avatar: "/placeholder.svg?height=40&width=40&query=avatar2" },
{ id: "t3", avatar: "/placeholder.svg?height=40&width=40&query=avatar3" },
],
},
{
id: "3",
name: "产品介绍",
type: "群发",
count: 42,
targets: [
{ id: "t4", avatar: "/placeholder.svg?height=40&width=40&query=avatar4" },
{ id: "t5", avatar: "/placeholder.svg?height=40&width=40&query=avatar5" },
],
},
]
const contentLibraries = propContentLibraries || defaultContentLibraries
const handleAddLibrary = (library: ContentLibrary) => {
if (!selectedLibraries.some((l) => l.id === library.id)) {
onLibrariesChange([...selectedLibraries, library])
}
setIsDialogOpen(false)
}
const handleRemoveLibrary = (libraryId: string) => {
onLibrariesChange(selectedLibraries.filter((library) => library.id !== libraryId))
}
const filteredLibraries = contentLibraries.filter((library) =>
library.name.toLowerCase().includes(searchTerm.toLowerCase()),
)
const ContentSelectorContent = () => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-red-500 mr-1">*</span>
<span className="font-medium">:</span>
</div>
<Button variant="default" size="sm" onClick={() => setIsDialogOpen(true)} className="flex items-center">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="overflow-x-auto">
{selectedLibraries.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
{contentLibraries[0]?.type && <TableHead></TableHead>}
{contentLibraries[0]?.count && <TableHead></TableHead>}
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedLibraries.map((library, index) => (
<TableRow key={library.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{library.name}</TableCell>
{contentLibraries[0]?.type && <TableCell>{library.type}</TableCell>}
{contentLibraries[0]?.count && <TableCell>{library.count}</TableCell>}
<TableCell>
<div className="flex -space-x-2 flex-wrap">
{library.targets?.map((target) => (
<div key={target.id} className="w-10 h-10 rounded-md overflow-hidden border-2 border-white">
<img
src={target.avatar || "/placeholder.svg"}
alt={target.name || "Target"}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveLibrary(library.id)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-5 w-5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="border rounded-md p-8 text-center text-gray-500">"选择内容库"</div>
)}
</div>
{(onPrevious || onNext || onSave || onCancel) && (
<div className="flex space-x-2 justify-end mt-4">
{onPrevious && (
<Button type="button" variant="outline" onClick={onPrevious}>
</Button>
)}
{onNext && (
<Button type="button" onClick={onNext} disabled={selectedLibraries.length === 0}>
</Button>
)}
{onSave && (
<Button type="button" variant="outline" onClick={onSave}>
</Button>
)}
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
)}
</div>
)}
</div>
)
return (
<>
{withCard ? (
<Card className={className}>
<CardContent className="p-4 sm:p-6">
<ContentSelectorContent />
</CardContent>
</Card>
) : (
<div className={className}>
<ContentSelectorContent />
</div>
)}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="搜索内容库名称"
className="pl-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[400px]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
{contentLibraries[0]?.type && <TableHead></TableHead>}
{contentLibraries[0]?.count && <TableHead></TableHead>}
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLibraries.map((library, index) => (
<TableRow key={library.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{library.name}</TableCell>
{contentLibraries[0]?.type && <TableCell>{library.type}</TableCell>}
{contentLibraries[0]?.count && <TableCell>{library.count}</TableCell>}
<TableCell>
<div className="flex -space-x-2 flex-wrap">
{library.targets?.map((target) => (
<div key={target.id} className="w-10 h-10 rounded-md overflow-hidden border-2 border-white">
<img
src={target.avatar || "/placeholder.svg"}
alt={target.name || "Target"}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleAddLibrary(library)}
disabled={selectedLibraries.some((l) => l.id === library.id)}
className="whitespace-nowrap"
>
{selectedLibraries.some((l) => l.id === library.id) ? "已选择" : "选择"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,363 @@
"use client"
import React from "react"
import type { ReactNode } from "react"
import { useState, useEffect, useMemo, useCallback } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Pagination } from "@/components/ui/pagination"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Search, RefreshCw, ChevronDown, ChevronUp, MoreHorizontal } from "lucide-react"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
// 列定义接口
export interface Column<T> {
id: string
header: string | ReactNode
accessorKey?: keyof T
cell?: (item: T) => ReactNode
sortable?: boolean
className?: string
}
// DataTable 属性接口
export interface DataTableProps<T> {
data: T[]
columns: Column<T>[]
pageSize?: number
loading?: boolean
title?: string
description?: string
emptyMessage?: string
withCard?: boolean
showSearch?: boolean
showRefresh?: boolean
showSelection?: boolean
onRowClick?: (item: T) => void
onSelectionChange?: (selectedItems: T[]) => void
onRefresh?: () => void
onSearch?: (query: string) => void
onSort?: (columnId: string, direction: "asc" | "desc") => void
rowActions?: {
label: string
icon?: ReactNode
onClick: (item: T) => void
className?: string
}[]
batchActions?: {
label: string
icon?: ReactNode
onClick: (selectedItems: T[]) => void
className?: string
}[]
className?: string
}
/**
* 经过性能优化的数据表格组件
* - 使用 React.memo 避免不必要的重渲染
* - 使用 useMemo 缓存计算结果
* - 使用 useCallback 稳定化事件处理器
*/
function DataTableComponent<T extends { id: string | number }>({
data,
columns,
pageSize = 10,
loading = false,
title,
description,
emptyMessage = "暂无数据",
withCard = true,
showSearch = true,
showRefresh = true,
showSelection = false,
onRowClick,
onSelectionChange,
onRefresh,
onSearch,
onSort,
rowActions,
batchActions,
className,
}: DataTableProps<T>) {
const [searchQuery, setSearchQuery] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [selectedItems, setSelectedItems] = useState<T[]>([])
const [sortConfig, setSortConfig] = useState<{ columnId: string; direction: "asc" | "desc" } | null>(null)
// 当外部数据变化时,重置分页和选择
useEffect(() => {
setCurrentPage(1)
setSelectedItems([])
}, [data])
// 使用 useMemo 缓存过滤和排序后的数据
const filteredData = useMemo(() => {
let filtered = [...data]
if (searchQuery && !onSearch) {
filtered = data.filter((item) =>
columns.some((col) => {
if (!col.accessorKey) return false
const value = item[col.accessorKey]
return String(value).toLowerCase().includes(searchQuery.toLowerCase())
}),
)
}
if (sortConfig && !onSort) {
const { columnId, direction } = sortConfig
const column = columns.find((c) => c.id === columnId)
if (column?.accessorKey) {
filtered.sort((a, b) => {
const valA = a[column.accessorKey!]
const valB = b[column.accessorKey!]
if (valA < valB) return direction === "asc" ? -1 : 1
if (valA > valB) return direction === "asc" ? 1 : -1
return 0
})
}
}
return filtered
}, [data, searchQuery, sortConfig, columns, onSearch, onSort])
// 使用 useMemo 缓存当前页的数据
const currentPageItems = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize
return filteredData.slice(startIndex, startIndex + pageSize)
}, [filteredData, currentPage, pageSize])
const totalPages = Math.ceil(filteredData.length / pageSize)
const isAllCurrentPageSelected =
currentPageItems.length > 0 && currentPageItems.every((item) => selectedItems.some((s) => s.id === item.id))
// 搜索处理
const handleSearch = useCallback(
(query: string) => {
setSearchQuery(query)
setCurrentPage(1)
if (onSearch) {
onSearch(query)
}
},
[onSearch],
)
// 排序处理
const handleSort = useCallback(
(columnId: string) => {
const newDirection = sortConfig?.columnId === columnId && sortConfig.direction === "asc" ? "desc" : "asc"
setSortConfig({ columnId, direction: newDirection })
if (onSort) {
onSort(columnId, newDirection)
}
},
[sortConfig, onSort],
)
// 刷新处理
const handleRefresh = useCallback(() => {
setSearchQuery("")
setCurrentPage(1)
setSortConfig(null)
setSelectedItems([])
onRefresh?.()
}, [onRefresh])
// 全选/取消全选
const handleSelectAll = useCallback(
(checked: boolean) => {
const newSelectedItems = checked ? currentPageItems : []
setSelectedItems(newSelectedItems)
onSelectionChange?.(newSelectedItems)
},
[currentPageItems, onSelectionChange],
)
// 单行选择
const handleSelectItem = useCallback(
(item: T, checked: boolean) => {
const newSelectedItems = checked
? [...selectedItems, item]
: selectedItems.filter((selected) => selected.id !== item.id)
setSelectedItems(newSelectedItems)
onSelectionChange?.(newSelectedItems)
},
[selectedItems, onSelectionChange],
)
const TableContent = (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex flex-col sm:flex-row justify-between gap-2">
<div className="flex flex-1 gap-2">
{showSearch && (
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="搜索..."
className="pl-8"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
)}
{showRefresh && (
<Button variant="outline" size="icon" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
)}
</div>
{batchActions && selectedItems.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500"> {selectedItems.length} </span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{batchActions.map((action, i) => (
<DropdownMenuItem key={i} onClick={() => action.onClick(selectedItems)} className={action.className}>
{action.icon} {action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{/* 表格 */}
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
{showSelection && (
<TableHead className="w-[40px]">
<Checkbox checked={isAllCurrentPageSelected} onCheckedChange={handleSelectAll} />
</TableHead>
)}
{columns.map((column) => (
<TableHead
key={column.id}
className={cn(column.sortable && "cursor-pointer select-none", column.className)}
onClick={() => column.sortable && handleSort(column.id)}
>
<div className="flex items-center gap-1">
{column.header}
{column.sortable &&
sortConfig?.columnId === column.id &&
(sortConfig.direction === "asc" ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
))}
</div>
</TableHead>
))}
{rowActions && <TableHead className="w-[80px] text-right"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: pageSize }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={columns.length + (showSelection ? 1 : 0) + (rowActions ? 1 : 0)}>
<Skeleton className="h-5 w-full" />
</TableCell>
</TableRow>
))
) : currentPageItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length + (showSelection ? 1 : 0) + (rowActions ? 1 : 0)}
className="h-24 text-center"
>
{emptyMessage}
</TableCell>
</TableRow>
) : (
currentPageItems.map((item) => (
<TableRow
key={item.id}
className={cn(onRowClick && "cursor-pointer")}
onClick={() => onRowClick?.(item)}
>
{showSelection && (
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.some((s) => s.id === item.id)}
onCheckedChange={(checked) => handleSelectItem(item, !!checked)}
/>
</TableCell>
)}
{columns.map((column) => (
<TableCell key={column.id} className={column.className}>
{column.cell
? column.cell(item)
: column.accessorKey
? String(item[column.accessorKey] ?? "")
: null}
</TableCell>
))}
{rowActions && (
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{rowActions.map((action, i) => (
<DropdownMenuItem key={i} onClick={() => action.onClick(item)} className={action.className}>
{action.icon} {action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex justify-between items-center">
<div className="text-sm text-gray-500"> {filteredData.length} </div>
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
</div>
)}
</div>
)
if (withCard) {
return (
<Card className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
<TableContent />
</CardContent>
</Card>
)
}
return <div className={className}>{TableContent}</div>
}
export const DataTable = React.memo(DataTableComponent) as typeof DataTableComponent

View File

@@ -0,0 +1,320 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { ChevronDown, Filter, X, Search } from "lucide-react"
import { DeviceStatus, DeviceType, DeviceCategory, type DeviceFilterParams } from "@/types/device"
interface DeviceFilterProps {
filters: DeviceFilterParams
onFiltersChange: (filters: DeviceFilterParams) => void
availableModels?: string[]
availableTags?: string[]
compact?: boolean
}
export function DeviceFilter({
filters,
onFiltersChange,
availableModels = [],
availableTags = [],
compact = false,
}: DeviceFilterProps) {
const [isOpen, setIsOpen] = useState(!compact)
const updateFilter = (key: keyof DeviceFilterParams, value: any) => {
onFiltersChange({ ...filters, [key]: value })
}
const clearFilters = () => {
onFiltersChange({})
}
const getActiveFilterCount = () => {
let count = 0
if (filters.keyword) count++
if (filters.status?.length) count++
if (filters.type?.length) count++
if (filters.category?.length) count++
if (filters.tags?.length) count++
if (filters.models?.length) count++
if (filters.hasActivePlans !== undefined) count++
return count
}
const FilterContent = () => (
<div className="space-y-4">
{/* 关键词搜索 */}
<div className="space-y-2">
<Label></Label>
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索设备名称、IMEI、微信号..."
value={filters.keyword || ""}
onChange={(e) => updateFilter("keyword", e.target.value)}
className="pl-9"
/>
</div>
</div>
{/* 设备状态 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{Object.values(DeviceStatus).map((status) => (
<div key={status} className="flex items-center space-x-2">
<Checkbox
id={`status-${status}`}
checked={filters.status?.includes(status) || false}
onCheckedChange={(checked) => {
const currentStatus = filters.status || []
if (checked) {
updateFilter("status", [...currentStatus, status])
} else {
updateFilter(
"status",
currentStatus.filter((s) => s !== status),
)
}
}}
/>
<Label htmlFor={`status-${status}`} className="text-sm">
{status === DeviceStatus.ONLINE
? "在线"
: status === DeviceStatus.OFFLINE
? "离线"
: status === DeviceStatus.BUSY
? "忙碌"
: "错误"}
</Label>
</div>
))}
</div>
</div>
{/* 设备类型 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{Object.values(DeviceType).map((type) => (
<div key={type} className="flex items-center space-x-2">
<Checkbox
id={`type-${type}`}
checked={filters.type?.includes(type) || false}
onCheckedChange={(checked) => {
const currentType = filters.type || []
if (checked) {
updateFilter("type", [...currentType, type])
} else {
updateFilter(
"type",
currentType.filter((t) => t !== type),
)
}
}}
/>
<Label htmlFor={`type-${type}`} className="text-sm">
{type === DeviceType.ANDROID ? "Android" : "iOS"}
</Label>
</div>
))}
</div>
</div>
{/* 设备分类 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{Object.values(DeviceCategory).map((category) => (
<div key={category} className="flex items-center space-x-2">
<Checkbox
id={`category-${category}`}
checked={filters.category?.includes(category) || false}
onCheckedChange={(checked) => {
const currentCategory = filters.category || []
if (checked) {
updateFilter("category", [...currentCategory, category])
} else {
updateFilter(
"category",
currentCategory.filter((c) => c !== category),
)
}
}}
/>
<Label htmlFor={`category-${category}`} className="text-sm">
{category === DeviceCategory.ACQUISITION
? "获客设备"
: category === DeviceCategory.MAINTENANCE
? "维护设备"
: category === DeviceCategory.TESTING
? "测试设备"
: "备用设备"}
</Label>
</div>
))}
</div>
</div>
{/* 设备型号 */}
{availableModels.length > 0 && (
<div className="space-y-2">
<Label></Label>
<Select
value={filters.models?.[0] || ""}
onValueChange={(value) => updateFilter("models", value ? [value] : [])}
>
<SelectTrigger>
<SelectValue placeholder="选择设备型号" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 电量范围 */}
<div className="space-y-2">
<Label>
: {filters.batteryRange?.[0] || 0}% - {filters.batteryRange?.[1] || 100}%
</Label>
<Slider
value={filters.batteryRange || [0, 100]}
onValueChange={(value) => updateFilter("batteryRange", value as [number, number])}
max={100}
min={0}
step={5}
className="w-full"
/>
</div>
{/* 好友数量范围 */}
<div className="space-y-2">
<Label>
: {filters.friendCountRange?.[0] || 0} - {filters.friendCountRange?.[1] || 5000}
</Label>
<Slider
value={filters.friendCountRange || [0, 5000]}
onValueChange={(value) => updateFilter("friendCountRange", value as [number, number])}
max={5000}
min={0}
step={50}
className="w-full"
/>
</div>
{/* 设备标签 */}
{availableTags.length > 0 && (
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{availableTags.map((tag) => (
<div key={tag} className="flex items-center space-x-2">
<Checkbox
id={`tag-${tag}`}
checked={filters.tags?.includes(tag) || false}
onCheckedChange={(checked) => {
const currentTags = filters.tags || []
if (checked) {
updateFilter("tags", [...currentTags, tag])
} else {
updateFilter(
"tags",
currentTags.filter((t) => t !== tag),
)
}
}}
/>
<Label htmlFor={`tag-${tag}`} className="text-sm">
{tag}
</Label>
</div>
))}
</div>
</div>
)}
{/* 是否有活跃计划 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center space-x-2">
<Checkbox
id="hasActivePlans"
checked={filters.hasActivePlans || false}
onCheckedChange={(checked) => updateFilter("hasActivePlans", checked || undefined)}
/>
<Label htmlFor="hasActivePlans" className="text-sm">
</Label>
</div>
</div>
{/* 清除过滤器 */}
<div className="flex justify-between items-center pt-2">
<Button variant="outline" size="sm" onClick={clearFilters}>
<X className="h-4 w-4 mr-1" />
</Button>
<div className="text-sm text-gray-500">{getActiveFilterCount()} </div>
</div>
</div>
)
if (compact) {
return (
<Card className="p-4">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<div className="flex items-center">
<Filter className="h-4 w-4 mr-2" />
{getActiveFilterCount() > 0 && (
<Badge variant="secondary" className="ml-2">
{getActiveFilterCount()}
</Badge>
)}
</div>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
<FilterContent />
</CollapsibleContent>
</Collapsible>
</Card>
)
}
return (
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<Filter className="h-5 w-5 mr-2" />
<h3 className="font-medium"></h3>
{getActiveFilterCount() > 0 && (
<Badge variant="secondary" className="ml-2">
{getActiveFilterCount()}
</Badge>
)}
</div>
</div>
<FilterContent />
</Card>
)
}

View File

@@ -0,0 +1,537 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Smartphone, CheckCircle2, Loader2, Plus, Battery, Users, MapPin, Activity } from "lucide-react"
import { cn } from "@/lib/utils"
import { DeviceFilter } from "./DeviceFilter"
import { AddDeviceDialog } from "./AddDeviceDialog"
import type { Device, DeviceFilterParams } from "@/types/device"
export interface DeviceSelectorProps {
/** 是否使用对话框模式 */
dialogMode?: boolean
/** 对话框是否打开 */
open?: boolean
/** 对话框打开状态变更回调 */
onOpenChange?: (open: boolean) => void
/** 是否支持多选 */
multiple?: boolean
/** 已选择的设备ID */
selectedDevices?: string[]
/** 设备选择变更回调 */
onDevicesChange: (deviceIds: string[]) => void
/** 是<><E698AF><EFBFBD>排除已用于其他计划的设备 */
devices?: Device[]
/** 是否显示下一步按钮 */
showNextButton?: boolean
/** 下一步按钮点击回调 */
onNext?: () => void
/** 上一步按钮点击回调 */
onPrevious?: () => void
/** 自定义类名 */
className?: string
/** 页面标题 */
title?: string
/** 最大选择数量 */
maxSelection?: number
}
/**
* 统一的设备选择器组件
* 支持对话框模式和内嵌模式,支持单选和多选,样式与设备管理页面一致
*/
export function DeviceSelector({
dialogMode = false,
open = false,
onOpenChange,
multiple = true,
selectedDevices = [],
onDevicesChange,
devices: propDevices,
showNextButton = false,
onNext,
onPrevious,
className,
title = "选择设备",
maxSelection = 10,
}: DeviceSelectorProps) {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<string[]>(selectedDevices)
const [filters, setFilters] = useState<DeviceFilterParams>({})
const [showAddDialog, setShowAddDialog] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const devicesPerPage = 10
// 如果外部selectedDevices变化同步更新内部状态
useEffect(() => {
setSelected(selectedDevices)
}, [selectedDevices])
// 加载设备数据
useEffect(() => {
const fetchDevices = async () => {
setLoading(true)
try {
if (propDevices) {
setDevices(propDevices)
} else {
// 模拟设备数据
await new Promise((resolve) => setTimeout(resolve, 800))
const mockDevices: Device[] = Array.from({ length: 25 }, (_, i) => ({
id: `device-${i + 1}`,
name: `设备 ${i + 1}`,
imei: `IMEI-${Math.random().toString(36).substr(2, 8)}`,
type: i % 2 === 0 ? "android" : "ios",
status: i < 20 ? "online" : i < 23 ? "offline" : "busy",
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
friendCount: Math.floor(Math.random() * 1000) + 100,
battery: Math.floor(Math.random() * 100) + 1,
lastActive: i < 5 ? "刚刚" : i < 10 ? "5分钟前" : i < 15 ? "1小时前" : "2小时前",
addFriendStatus: Math.random() > 0.2 ? "normal" : "abnormal",
remark: `${title}设备 ${i + 1}`,
model: i % 3 === 0 ? "iPhone 14" : i % 3 === 1 ? "Samsung S23" : "Xiaomi 13",
category: i % 4 === 0 ? "acquisition" : i % 4 === 1 ? "maintenance" : i % 4 === 2 ? "testing" : "backup",
todayAdded: Math.floor(Math.random() * 50),
totalTasks: Math.floor(Math.random() * 100) + 10,
completedTasks: Math.floor(Math.random() * 80) + 5,
activePlans: i < 15 ? [`plan-${i + 1}`, `plan-${i + 2}`] : [],
planNames: i < 15 ? [`计划 ${i + 1}`, `计划 ${i + 2}`] : [],
tags: i % 2 === 0 ? ["高效", "稳定"] : ["测试", "备用"],
location: i % 3 === 0 ? "北京" : i % 3 === 1 ? "上海" : "深圳",
operator: `操作员${(i % 5) + 1}`,
}))
setDevices(mockDevices)
}
} catch (error) {
console.error("获取设备失败:", error)
} finally {
setLoading(false)
}
}
if (!dialogMode || open) {
fetchDevices()
}
}, [dialogMode, open, propDevices, title])
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
let newSelected: string[]
if (multiple) {
if (selected.includes(deviceId)) {
newSelected = selected.filter((id) => id !== deviceId)
} else {
if (selected.length >= maxSelection) {
return // 达到最大选择数量
}
newSelected = [...selected, deviceId]
}
} else {
newSelected = [deviceId]
}
setSelected(newSelected)
onDevicesChange(newSelected)
}
// 处理全选/取消全选
const handleSelectAll = () => {
if (selected.length === Math.min(filteredDevices.length, maxSelection)) {
setSelected([])
onDevicesChange([])
} else {
const newSelected = filteredDevices.slice(0, maxSelection).map((device) => device.id)
setSelected(newSelected)
onDevicesChange(newSelected)
}
}
// 处理对话框确认
const handleConfirm = () => {
onDevicesChange(selected)
if (onOpenChange) {
onOpenChange(false)
}
}
// 处理设备添加
const handleDeviceAdded = (newDevice: Device) => {
setDevices([newDevice, ...devices])
}
// 过滤设备
const filteredDevices = devices.filter((device) => {
// 关键词搜索
if (filters.keyword) {
const keyword = filters.keyword.toLowerCase()
const matchesKeyword =
device.name.toLowerCase().includes(keyword) ||
device.imei.toLowerCase().includes(keyword) ||
device.wechatId.toLowerCase().includes(keyword) ||
(device.remark && device.remark.toLowerCase().includes(keyword)) ||
(device.model && device.model.toLowerCase().includes(keyword))
if (!matchesKeyword) return false
}
// 状态过滤
if (filters.status?.length && !filters.status.includes(device.status)) {
return false
}
// 类型过滤
if (filters.type?.length && !filters.type.includes(device.type)) {
return false
}
// 分类过滤
if (filters.category?.length && device.category && !filters.category.includes(device.category)) {
return false
}
// 型号过滤
if (filters.models?.length && device.model && !filters.models.includes(device.model)) {
return false
}
// 电量范围过滤
if (filters.batteryRange) {
const [min, max] = filters.batteryRange
if (device.battery < min || device.battery > max) {
return false
}
}
// 好友数量范围过滤
if (filters.friendCountRange) {
const [min, max] = filters.friendCountRange
if (device.friendCount < min || device.friendCount > max) {
return false
}
}
// 标签过滤
if (filters.tags?.length && device.tags) {
const hasMatchingTag = filters.tags.some((tag) => device.tags?.includes(tag))
if (!hasMatchingTag) return false
}
// 活跃计划过滤
if (filters.hasActivePlans !== undefined) {
const hasActivePlans = device.activePlans && device.activePlans.length > 0
if (filters.hasActivePlans !== hasActivePlans) {
return false
}
}
return true
})
// 分页数据
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
// 获取可用的型号和标签
const availableModels = [...new Set(devices.map((d) => d.model).filter(Boolean))]
const availableTags = [...new Set(devices.flatMap((d) => d.tags || []))]
// 设备卡片组件
const DeviceCard = ({ device }: { device: Device }) => {
const isSelected = selected.includes(device.id)
const canSelect = !isSelected && (selected.length < maxSelection || !multiple)
return (
<Card
className={cn(
"p-4 cursor-pointer transition-all duration-200 hover:shadow-md",
isSelected ? "ring-2 ring-blue-500 bg-blue-50" : "hover:bg-gray-50",
!canSelect && !isSelected && "opacity-50 cursor-not-allowed",
)}
onClick={() => (canSelect || isSelected ? handleDeviceToggle(device.id) : undefined)}
>
<div className="flex items-start space-x-3">
<div className="mt-1">
{multiple ? (
<Checkbox
checked={isSelected}
className="data-[state=checked]:bg-blue-500"
onClick={(e) => e.stopPropagation()}
/>
) : isSelected ? (
<CheckCircle2 className="h-5 w-5 text-blue-500" />
) : (
<div className="h-5 w-5 rounded-full border-2 border-gray-300" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<h3 className="font-medium truncate">{device.name}</h3>
<Badge
variant="outline"
className={cn(
device.status === "online"
? "bg-green-500/10 text-green-600 border-green-200"
: device.status === "busy"
? "bg-yellow-500/10 text-yellow-600 border-yellow-200"
: "bg-gray-500/10 text-gray-600 border-gray-200",
)}
>
{device.status === "online" ? "在线" : device.status === "busy" ? "忙碌" : "离线"}
</Badge>
</div>
<div className="flex items-center space-x-1">
<Smartphone className={cn("h-4 w-4", device.type === "android" ? "text-green-500" : "text-gray-500")} />
<span className="text-xs text-gray-500">{device.type === "android" ? "Android" : "iOS"}</span>
</div>
</div>
<div className="space-y-1 text-sm text-gray-600">
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
{device.model && <div>: {device.model}</div>}
{device.remark && <div>: {device.remark}</div>}
</div>
<div className="flex items-center justify-between mt-3 text-sm">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1">
<Battery
className={cn(
"h-4 w-4",
device.battery > 50 ? "text-green-500" : device.battery > 20 ? "text-yellow-500" : "text-red-500",
)}
/>
<span>{device.battery}%</span>
</div>
<div className="flex items-center space-x-1">
<Users className="h-4 w-4 text-blue-500" />
<span>{device.friendCount}</span>
</div>
{device.todayAdded !== undefined && <div className="text-green-600">+{device.todayAdded}</div>}
</div>
<div className="text-xs text-gray-500">{device.lastActive}</div>
</div>
{/* 计划和任务信息 */}
{device.activePlans && device.activePlans.length > 0 && (
<div className="mt-2 space-y-1">
<div className="flex items-center space-x-1 text-xs text-blue-600">
<Activity className="h-3 w-3" />
<span>: {device.activePlans.length}</span>
</div>
{device.planNames && (
<div className="text-xs text-gray-500 truncate">{device.planNames.join(", ")}</div>
)}
</div>
)}
{/* 任务完成情况 */}
{device.totalTasks !== undefined && device.completedTasks !== undefined && (
<div className="mt-2 text-xs text-gray-500">
: {device.completedTasks}/{device.totalTasks}(
{Math.round((device.completedTasks / device.totalTasks) * 100)}%)
</div>
)}
{/* 标签 */}
{device.tags && device.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{device.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* 位置和操作员 */}
{(device.location || device.operator) && (
<div className="flex items-center justify-between mt-2 text-xs text-gray-500">
{device.location && (
<div className="flex items-center space-x-1">
<MapPin className="h-3 w-3" />
<span>{device.location}</span>
</div>
)}
{device.operator && <span>: {device.operator}</span>}
</div>
)}
</div>
</div>
</Card>
)
}
// 设备列表内容
const DeviceListContent = () => (
<div className="space-y-4">
<Tabs defaultValue="list" className="w-full">
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="list"></TabsTrigger>
<TabsTrigger value="filter"></TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={() => setShowAddDialog(true)}
className="flex items-center space-x-1"
>
<Plus className="h-4 w-4" />
<span></span>
</Button>
</div>
<TabsContent value="list" className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500">
{selected.length} / {Math.min(filteredDevices.length, maxSelection)}
{multiple && maxSelection < filteredDevices.length && (
<span className="text-orange-500 ml-2">( {maxSelection} )</span>
)}
</div>
{multiple && (
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={filteredDevices.length === 0}>
{selected.length === Math.min(filteredDevices.length, maxSelection) && filteredDevices.length > 0
? "取消全选"
: "全选"}
</Button>
)}
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-blue-500 mr-2" />
<span>...</span>
</div>
) : filteredDevices.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<Smartphone className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p></p>
<Button variant="outline" className="mt-4" onClick={() => setFilters({})}>
</Button>
</div>
) : (
<>
<div className="grid grid-cols-1 gap-3">
{paginatedDevices.map((device) => (
<DeviceCard key={device.id} device={device} />
))}
</div>
{/* 分页 */}
{filteredDevices.length > devicesPerPage && (
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-500">
{currentPage} / {Math.ceil(filteredDevices.length / devicesPerPage)}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
}
disabled={currentPage >= Math.ceil(filteredDevices.length / devicesPerPage)}
>
</Button>
</div>
)}
</>
)}
</TabsContent>
<TabsContent value="filter">
<DeviceFilter
filters={filters}
onFiltersChange={setFilters}
availableModels={availableModels}
availableTags={availableTags}
/>
</TabsContent>
</Tabs>
</div>
)
// 对话框模式
if (dialogMode) {
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto py-4">
<DeviceListContent />
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange && onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm} disabled={selected.length === 0}>
({selected.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AddDeviceDialog open={showAddDialog} onOpenChange={setShowAddDialog} onDeviceAdded={handleDeviceAdded} />
</>
)
}
// 内嵌模式
return (
<>
<Card className={cn("p-6", className)}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{title}</h2>
<div className="text-sm text-gray-500">{filteredDevices.length} </div>
</div>
<DeviceListContent />
{showNextButton && (
<div className="flex justify-between mt-6 pt-6 border-t">
{onPrevious && (
<Button variant="outline" onClick={onPrevious}>
</Button>
)}
<div className="flex-1" />
{onNext && (
<Button onClick={onNext} disabled={selected.length === 0} className="bg-blue-500 hover:bg-blue-600">
({selected.length})
</Button>
)}
</div>
)}
</div>
</Card>
<AddDeviceDialog open={showAddDialog} onOpenChange={setShowAddDialog} onDeviceAdded={handleDeviceAdded} />
</>
)
}

View File

@@ -0,0 +1,373 @@
"use client"
import type React from "react"
import { useState, useRef, useCallback } from "react"
import { Button } from "@/app/components/ui/button"
import { Card, CardContent } from "@/app/components/ui/card"
import { Progress } from "@/app/components/ui/progress"
import { Badge } from "@/app/components/ui/badge"
import { Upload, X, File, ImageIcon, Video, FileText, Download, Eye } from "lucide-react"
import { cn } from "@/app/lib/utils"
export interface UploadedFile {
id: string
name: string
size: number
type: string
url?: string
progress?: number
status: "uploading" | "success" | "error"
error?: string
}
export interface FileUploaderProps {
/** 允许的文件类型 */
accept?: string
/** 是否支持多文件上传 */
multiple?: boolean
/** 最大文件大小(字节) */
maxSize?: number
/** 最大文件数量 */
maxFiles?: number
/** 已上传的文件列表 */
files?: UploadedFile[]
/** 文件变更回调 */
onFilesChange?: (files: UploadedFile[]) => void
/** 文件上传处理函数 */
onUpload?: (file: File) => Promise<{ url: string; id: string }>
/** 文件删除回调 */
onDelete?: (fileId: string) => void
/** 是否禁用 */
disabled?: boolean
/** 自定义类名 */
className?: string
/** 上传区域提示文本 */
placeholder?: string
/** 是否显示预览 */
showPreview?: boolean
}
/**
* 统一的文件上传组件
* 支持拖拽上传、多文件上传、进度显示、预览等功能
*/
export function FileUploader({
accept,
multiple = false,
maxSize = 10 * 1024 * 1024, // 10MB
maxFiles = 10,
files = [],
onFilesChange,
onUpload,
onDelete,
disabled = false,
className,
placeholder = "点击或拖拽文件到此处上传",
showPreview = true,
}: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false)
const [uploadingFiles, setUploadingFiles] = useState<UploadedFile[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// 获取文件图标
const getFileIcon = (type: string) => {
if (type.startsWith("image/")) return <ImageIcon className="h-8 w-8 text-blue-500" />
if (type.startsWith("video/")) return <Video className="h-8 w-8 text-purple-500" />
if (type.includes("pdf") || type.includes("document")) return <FileText className="h-8 w-8 text-red-500" />
return <File className="h-8 w-8 text-gray-500" />
}
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
}
// 验证文件
const validateFile = (file: File): string | null => {
if (maxSize && file.size > maxSize) {
return `文件大小不能超过 ${formatFileSize(maxSize)}`
}
if (accept) {
const acceptedTypes = accept.split(",").map((type) => type.trim())
const isAccepted = acceptedTypes.some((type) => {
if (type.startsWith(".")) {
return file.name.toLowerCase().endsWith(type.toLowerCase())
}
return file.type.match(type.replace("*", ".*"))
})
if (!isAccepted) {
return `不支持的文件类型: ${file.type}`
}
}
if (files.length + uploadingFiles.length >= maxFiles) {
return `最多只能上传 ${maxFiles} 个文件`
}
return null
}
// 处理文件上传
const handleFileUpload = useCallback(
async (fileList: FileList) => {
if (disabled || !onUpload) return
const filesToUpload = Array.from(fileList)
const newUploadingFiles: UploadedFile[] = []
for (const file of filesToUpload) {
const error = validateFile(file)
if (error) {
// 显示错误通知
console.error(error)
continue
}
const uploadFile: UploadedFile = {
id: Math.random().toString(36).substr(2, 9),
name: file.name,
size: file.size,
type: file.type,
progress: 0,
status: "uploading",
}
newUploadingFiles.push(uploadFile)
}
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
// 逐个上传文件
for (let i = 0; i < newUploadingFiles.length; i++) {
const uploadFile = newUploadingFiles[i]
const file = filesToUpload[i]
try {
// 模拟上传进度
const progressInterval = setInterval(() => {
setUploadingFiles((prev) =>
prev.map((f) => (f.id === uploadFile.id ? { ...f, progress: Math.min((f.progress || 0) + 10, 90) } : f)),
)
}, 200)
const result = await onUpload(file)
clearInterval(progressInterval)
// 上传成功
const successFile: UploadedFile = {
...uploadFile,
url: result.url,
id: result.id,
progress: 100,
status: "success",
}
setUploadingFiles((prev) => prev.filter((f) => f.id !== uploadFile.id))
if (onFilesChange) {
onFilesChange([...files, successFile])
}
} catch (error) {
// 上传失败
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === uploadFile.id
? { ...f, status: "error", error: error instanceof Error ? error.message : "上传失败" }
: f,
),
)
}
}
},
[disabled, onUpload, files, onFilesChange, maxSize, maxFiles, accept, uploadingFiles.length],
)
// 处理拖拽
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
if (!disabled) {
setIsDragging(true)
}
},
[disabled],
)
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (disabled) return
const droppedFiles = e.dataTransfer.files
if (droppedFiles.length > 0) {
handleFileUpload(droppedFiles)
}
},
[disabled, handleFileUpload],
)
// 处理文件选择
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files
if (selectedFiles && selectedFiles.length > 0) {
handleFileUpload(selectedFiles)
}
// 清空input值允许重复选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
},
[handleFileUpload],
)
// 删除文件
const handleDeleteFile = (fileId: string) => {
if (onDelete) {
onDelete(fileId)
}
if (onFilesChange) {
onFilesChange(files.filter((file) => file.id !== fileId))
}
}
// 删除上传中的文件
const handleDeleteUploadingFile = (fileId: string) => {
setUploadingFiles((prev) => prev.filter((file) => file.id !== fileId))
}
// 重试上传
const handleRetryUpload = (fileId: string) => {
const failedFile = uploadingFiles.find((f) => f.id === fileId)
if (failedFile) {
// 这里需要重新获取原始文件,实际实现中可能需要保存原始文件引用
console.log("重试上传:", failedFile.name)
}
}
const allFiles = [...files, ...uploadingFiles]
return (
<div className={cn("space-y-4", className)}>
{/* 上传区域 */}
<Card
className={cn(
"border-2 border-dashed transition-colors cursor-pointer",
isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300",
disabled && "opacity-50 cursor-not-allowed",
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !disabled && fileInputRef.current?.click()}
>
<CardContent className="p-8 text-center">
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium mb-2">{placeholder}</p>
<p className="text-sm text-gray-500 mb-4">
{accept && `支持格式: ${accept}`}
{maxSize && ` • 最大 ${formatFileSize(maxSize)}`}
{multiple && ` • 最多 ${maxFiles} 个文件`}
</p>
<Button variant="outline" disabled={disabled}>
</Button>
</CardContent>
</Card>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
/>
{/* 文件列表 */}
{allFiles.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium"> ({allFiles.length})</h4>
<div className="space-y-2">
{allFiles.map((file) => (
<Card key={file.id} className="p-3">
<div className="flex items-center space-x-3">
{showPreview && getFileIcon(file.type)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="font-medium truncate">{file.name}</p>
<div className="flex items-center space-x-2">
<Badge
variant={
file.status === "success"
? "success"
: file.status === "error"
? "destructive"
: "secondary"
}
>
{file.status === "success" ? "已上传" : file.status === "error" ? "失败" : "上传中"}
</Badge>
<span className="text-sm text-gray-500">{formatFileSize(file.size)}</span>
</div>
</div>
{file.status === "uploading" && <Progress value={file.progress || 0} className="mt-2" />}
{file.status === "error" && file.error && <p className="text-sm text-red-500 mt-1">{file.error}</p>}
</div>
<div className="flex items-center space-x-1">
{file.status === "success" && file.url && (
<>
<Button variant="ghost" size="icon" onClick={() => window.open(file.url, "_blank")}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => window.open(file.url, "_blank")}>
<Download className="h-4 w-4" />
</Button>
</>
)}
{file.status === "error" && (
<Button variant="ghost" size="sm" onClick={() => handleRetryUpload(file.id)}>
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() =>
file.status === "success" ? handleDeleteFile(file.id) : handleDeleteUploadingFile(file.id)
}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,207 @@
"use client"
import type { ReactNode } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export interface FormSection {
title?: string
description?: string
children: ReactNode
}
export interface FormLayoutProps {
/** 表单标题 */
title?: string
/** 表单描述 */
description?: string
/** 表单部分 */
sections?: FormSection[]
/** 表单内容 */
children?: ReactNode
/** 提交按钮文本 */
submitText?: string
/** 取消按钮文本 */
cancelText?: string
/** 是否显示取消按钮 */
showCancel?: boolean
/** 是否显示重置按钮 */
showReset?: boolean
/** 重置按钮文本 */
resetText?: string
/** 提交处理函数 */
onSubmit?: () => void
/** 取消处理函数 */
onCancel?: () => void
/** 重置处理函数 */
onReset?: () => void
/** 是否禁用提交按钮 */
submitDisabled?: boolean
/** 是否显示加载状态 */
loading?: boolean
/** 自定义底部内容 */
footer?: ReactNode
/** 自定义类名 */
className?: string
/** 是否使用卡片包装 */
withCard?: boolean
/** 表单布局方向 */
direction?: "vertical" | "horizontal"
/** 表单标签宽度 (仅在水平布局时有效) */
labelWidth?: string
}
/**
* 统一的表单布局组件
*/
export function FormLayout({
title,
description,
sections = [],
children,
submitText = "提交",
cancelText = "取消",
showCancel = true,
showReset = false,
resetText = "重置",
onSubmit,
onCancel,
onReset,
submitDisabled = false,
loading = false,
footer,
className,
withCard = true,
direction = "vertical",
labelWidth = "120px",
}: FormLayoutProps) {
const FormContent = () => (
<>
{/* 表单内容 */}
<div className={cn("space-y-6", direction === "horizontal" && "form-horizontal")}>
{/* 如果有sections渲染sections */}
{sections.length > 0
? sections.map((section, index) => (
<div key={index} className="space-y-4">
{(section.title || section.description) && (
<div className="mb-4">
{section.title && <h3 className="text-lg font-medium">{section.title}</h3>}
{section.description && <p className="text-sm text-gray-500">{section.description}</p>}
</div>
)}
<div>{section.children}</div>
</div>
))
: // 否则直接渲染children
children}
</div>
{/* 表单底部 */}
{(onSubmit || onCancel || onReset || footer) && (
<div className="flex justify-end space-x-2 pt-6">
{footer || (
<>
{showReset && onReset && (
<Button type="button" variant="outline" onClick={onReset}>
{resetText}
</Button>
)}
{showCancel && onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
{cancelText}
</Button>
)}
{onSubmit && (
<Button
type="submit"
disabled={submitDisabled || loading}
onClick={onSubmit}
className={loading ? "opacity-70" : ""}
>
{loading ? "处理中..." : submitText}
</Button>
)}
</>
)}
</div>
)}
</>
)
// 添加水平布局的样式
if (direction === "horizontal") {
const style = document.createElement("style")
style.textContent = `
.form-horizontal .form-item {
display: flex;
align-items: flex-start;
margin-bottom: 1rem;
}
.form-horizontal .form-label {
width: ${labelWidth};
flex-shrink: 0;
padding-top: 0.5rem;
}
.form-horizontal .form-field {
flex: 1;
}
@media (max-width: 640px) {
.form-horizontal .form-item {
flex-direction: column;
align-items: stretch;
}
.form-horizontal .form-label {
width: 100%;
margin-bottom: 0.5rem;
padding-top: 0;
}
}
`
document.head.appendChild(style)
}
// 根据是否需要卡片包装返回不同的渲染结果
if (withCard) {
return (
<Card className={className}>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
<FormContent />
</CardContent>
</Card>
)
}
return (
<div className={className}>
{(title || description) && (
<div className="mb-6">
{title && <h2 className="text-xl font-semibold">{title}</h2>}
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
</div>
)}
<FormContent />
</div>
)
}
/**
* 表单项组件 - 用于水平布局
*/
export function FormItem({ label, required, children }: { label: string; required?: boolean; children: ReactNode }) {
return (
<div className="form-item">
<div className="form-label">
{required && <span className="text-red-500 mr-1">*</span>}
<span>{label}:</span>
</div>
<div className="form-field">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
"use client"
import { useState, useEffect, useRef, type ReactNode } from "react"
import { cn } from "@/lib/utils"
export interface LazyLoadProps {
/** 子组件 */
children: ReactNode
/** 占位符 */
placeholder?: ReactNode
/** 根边距 */
rootMargin?: string
/** 阈值 */
threshold?: number
/** 是否只加载一次 */
once?: boolean
/** 自定义类名 */
className?: string
/** 加载完成回调 */
onLoad?: () => void
}
/**
* 懒加载组件
* 当元素进入视口时才渲染内容
*/
export function LazyLoad({
children,
placeholder,
rootMargin = "50px",
threshold = 0.1,
once = true,
className,
onLoad,
}: LazyLoadProps) {
const [isVisible, setIsVisible] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
const elementRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const element = elementRef.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
if (once) {
setHasLoaded(true)
observer.unobserve(element)
}
if (onLoad) {
onLoad()
}
} else if (!once) {
setIsVisible(false)
}
},
{
rootMargin,
threshold,
},
)
observer.observe(element)
return () => {
observer.unobserve(element)
}
}, [rootMargin, threshold, once, onLoad])
const shouldRender = isVisible || hasLoaded
return (
<div ref={elementRef} className={cn(className)}>
{shouldRender ? children : placeholder}
</div>
)
}
// 懒加载图片组件
export interface LazyImageProps {
src: string
alt: string
width?: number
height?: number
className?: string
placeholder?: ReactNode
onLoad?: () => void
onError?: () => void
}
export function LazyImage({ src, alt, width, height, className, placeholder, onLoad, onError }: LazyImageProps) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
const handleLoad = () => {
setLoaded(true)
if (onLoad) onLoad()
}
const handleError = () => {
setError(true)
if (onError) onError()
}
const defaultPlaceholder = (
<div
className={cn("bg-gray-200 animate-pulse flex items-center justify-center", className)}
style={{ width, height }}
>
<span className="text-gray-400 text-sm">...</span>
</div>
)
if (error) {
return (
<div className={cn("bg-gray-100 flex items-center justify-center", className)} style={{ width, height }}>
<span className="text-gray-400 text-sm"></span>
</div>
)
}
return (
<LazyLoad placeholder={placeholder || defaultPlaceholder}>
<img
src={src || "/placeholder.svg"}
alt={alt}
width={width}
height={height}
className={cn("transition-opacity duration-300", loaded ? "opacity-100" : "opacity-0", className)}
onLoad={handleLoad}
onError={handleError}
/>
</LazyLoad>
)
}

View File

@@ -0,0 +1,182 @@
"use client"
import { useState, createContext, useContext, type ReactNode } from "react"
import { Card, CardContent } from "@/app/components/ui/card"
import { Button } from "@/app/components/ui/button"
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react"
import { cn } from "@/app/lib/utils"
export type NotificationType = "success" | "error" | "warning" | "info"
export interface Notification {
id: string
type: NotificationType
title: string
message?: string
duration?: number
persistent?: boolean
actions?: Array<{
label: string
onClick: () => void
variant?: "default" | "outline"
}>
}
interface NotificationContextType {
notifications: Notification[]
addNotification: (notification: Omit<Notification, "id">) => void
removeNotification: (id: string) => void
clearAll: () => void
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined)
export function useNotifications() {
const context = useContext(NotificationContext)
if (!context) {
throw new Error("useNotifications must be used within a NotificationProvider")
}
return context
}
export function NotificationProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([])
const addNotification = (notification: Omit<Notification, "id">) => {
const id = Math.random().toString(36).substr(2, 9)
const newNotification: Notification = {
...notification,
id,
duration: notification.duration ?? 5000,
}
setNotifications((prev) => [newNotification, ...prev])
// 自动移除非持久化通知
if (!notification.persistent && newNotification.duration > 0) {
setTimeout(() => {
removeNotification(id)
}, newNotification.duration)
}
}
const removeNotification = (id: string) => {
setNotifications((prev) => prev.filter((notification) => notification.id !== id))
}
const clearAll = () => {
setNotifications([])
}
return (
<NotificationContext.Provider value={{ notifications, addNotification, removeNotification, clearAll }}>
{children}
<NotificationContainer />
</NotificationContext.Provider>
)
}
function NotificationContainer() {
const { notifications, removeNotification } = useNotifications()
if (notifications.length === 0) return null
return (
<div className="fixed top-4 right-4 z-50 space-y-2 max-w-sm w-full">
{notifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} onRemove={removeNotification} />
))}
</div>
)
}
function NotificationItem({
notification,
onRemove,
}: {
notification: Notification
onRemove: (id: string) => void
}) {
const getIcon = () => {
switch (notification.type) {
case "success":
return <CheckCircle className="h-5 w-5 text-green-500" />
case "error":
return <AlertCircle className="h-5 w-5 text-red-500" />
case "warning":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
case "info":
return <Info className="h-5 w-5 text-blue-500" />
}
}
const getBorderColor = () => {
switch (notification.type) {
case "success":
return "border-l-green-500"
case "error":
return "border-l-red-500"
case "warning":
return "border-l-yellow-500"
case "info":
return "border-l-blue-500"
}
}
return (
<Card className={cn("border-l-4 shadow-lg animate-in slide-in-from-right", getBorderColor())}>
<CardContent className="p-4">
<div className="flex items-start space-x-3">
{getIcon()}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">{notification.title}</h4>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-gray-600"
onClick={() => onRemove(notification.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
{notification.message && <p className="text-sm text-gray-600 mt-1">{notification.message}</p>}
{notification.actions && notification.actions.length > 0 && (
<div className="flex space-x-2 mt-3">
{notification.actions.map((action, index) => (
<Button
key={index}
variant={action.variant || "outline"}
size="sm"
onClick={() => {
action.onClick()
onRemove(notification.id)
}}
>
{action.label}
</Button>
))}
</div>
)}
</div>
</div>
</CardContent>
</Card>
)
}
// 便捷的通知钩子
export function useNotify() {
const { addNotification } = useNotifications()
return {
success: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "success", title, message, ...options }),
error: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "error", title, message, ...options }),
warning: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "warning", title, message, ...options }),
info: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "info", title, message, ...options }),
}
}

View File

@@ -0,0 +1,59 @@
"use client"
import { Button } from "@/app/components/ui/button"
import type { ReactNode } from "react"
interface PageHeaderProps {
title: string
description?: string
primaryAction?: {
label: string
icon?: ReactNode
onClick: () => void
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
}
secondaryActions?: Array<{
label: string
icon?: ReactNode
onClick: () => void
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
}>
}
export function PageHeader({ title, description, primaryAction, secondaryActions = [] }: PageHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-6 border-b">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description && <p className="text-muted-foreground">{description}</p>}
</div>
{(primaryAction || secondaryActions.length > 0) && (
<div className="flex items-center gap-2">
{secondaryActions.map((action, index) => (
<Button
key={index}
variant={action.variant || "outline"}
onClick={action.onClick}
className="flex items-center"
>
{action.icon}
{action.label}
</Button>
))}
{primaryAction && (
<Button
variant={primaryAction.variant || "default"}
onClick={primaryAction.onClick}
className="flex items-center"
>
{primaryAction.icon}
{primaryAction.label}
</Button>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,169 @@
"use client"
import { useState } from "react"
import { Input } from "@/app/components/ui/input"
import { Button } from "@/app/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/components/ui/select"
import { Calendar } from "@/app/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/app/components/ui/popover"
import { Badge } from "@/app/components/ui/badge"
import { Search, Filter, X, CalendarIcon } from "lucide-react"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
interface FilterField {
id: string
label: string
type: "text" | "select" | "dateRange" | "multiSelect"
options?: Array<{ label: string; value: string }>
placeholder?: string
}
interface SearchFilterProps {
fields: FilterField[]
onFilterChange: (filters: Record<string, any>) => void
placeholder?: string
}
export function SearchFilter({ fields, onFilterChange, placeholder = "搜索..." }: SearchFilterProps) {
const [searchValue, setSearchValue] = useState("")
const [filters, setFilters] = useState<Record<string, any>>({})
const [showFilters, setShowFilters] = useState(false)
const handleSearchChange = (value: string) => {
setSearchValue(value)
const newFilters = { ...filters, search: value }
setFilters(newFilters)
onFilterChange(newFilters)
}
const handleFilterChange = (fieldId: string, value: any) => {
const newFilters = { ...filters, [fieldId]: value }
setFilters(newFilters)
onFilterChange(newFilters)
}
const clearFilter = (fieldId: string) => {
const newFilters = { ...filters }
delete newFilters[fieldId]
setFilters(newFilters)
onFilterChange(newFilters)
}
const clearAllFilters = () => {
setSearchValue("")
setFilters({})
onFilterChange({})
}
const activeFiltersCount = Object.keys(filters).filter(
(key) => key !== "search" && filters[key] !== undefined && filters[key] !== "",
).length
return (
<div className="space-y-4">
{/* 搜索栏 */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={placeholder}
value={searchValue}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" onClick={() => setShowFilters(!showFilters)} className="flex items-center gap-2">
<Filter className="h-4 w-4" />
{activeFiltersCount > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFiltersCount}
</Badge>
)}
</Button>
</div>
{/* 筛选器 */}
{showFilters && (
<div className="p-4 border rounded-lg bg-muted/50 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{fields.map((field) => (
<div key={field.id} className="space-y-2">
<label className="text-sm font-medium">{field.label}</label>
{field.type === "select" && (
<Select
value={filters[field.id] || ""}
onValueChange={(value) => handleFilterChange(field.id, value)}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder || `选择${field.label}`} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.type === "text" && (
<Input
placeholder={field.placeholder || `输入${field.label}`}
value={filters[field.id] || ""}
onChange={(e) => handleFilterChange(field.id, e.target.value)}
/>
)}
{field.type === "dateRange" && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{filters[field.id]
? `${format(filters[field.id].from, "yyyy-MM-dd", { locale: zhCN })} - ${format(filters[field.id].to, "yyyy-MM-dd", { locale: zhCN })}`
: field.placeholder || "选择日期范围"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
selected={filters[field.id]}
onSelect={(range) => handleFilterChange(field.id, range)}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
)}
</div>
))}
</div>
{/* 活跃筛选器显示 */}
{activeFiltersCount > 0 && (
<div className="flex flex-wrap gap-2 pt-2 border-t">
<span className="text-sm text-muted-foreground">:</span>
{Object.entries(filters).map(([key, value]) => {
if (key === "search" || !value) return null
const field = fields.find((f) => f.id === key)
if (!field) return null
return (
<Badge key={key} variant="secondary" className="flex items-center gap-1">
{field.label}: {typeof value === "object" ? "已选择" : value}
<X className="h-3 w-3 cursor-pointer" onClick={() => clearFilter(key)} />
</Badge>
)
})}
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="h-6 px-2 text-xs">
</Button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import type { ReactNode } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui/card"
import { TrendingUp, TrendingDown, Minus } from "lucide-react"
interface StatCardProps {
title: string
value: string | number
icon?: ReactNode
change?: {
value: string | number
type: "increase" | "decrease" | "neutral"
label?: string
}
description?: string
}
export function StatCard({ title, value, icon, change, description }: StatCardProps) {
const getChangeIcon = () => {
switch (change?.type) {
case "increase":
return <TrendingUp className="h-3 w-3" />
case "decrease":
return <TrendingDown className="h-3 w-3" />
default:
return <Minus className="h-3 w-3" />
}
}
const getChangeColor = () => {
switch (change?.type) {
case "increase":
return "text-green-600"
case "decrease":
return "text-red-600"
default:
return "text-muted-foreground"
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon && <div className="text-muted-foreground">{icon}</div>}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{change && (
<div className={`flex items-center text-xs ${getChangeColor()}`}>
{getChangeIcon()}
<span className="ml-1">
{change.value} {change.label}
</span>
</div>
)}
{description && <p className="text-xs text-muted-foreground mt-1">{description}</p>}
</CardContent>
</Card>
)
}
interface StatCardGroupProps {
cards: StatCardProps[]
}
export function StatCardGroup({ cards }: StatCardGroupProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card, index) => (
<StatCard key={index} {...card} />
))}
</div>
)
}

View File

@@ -0,0 +1,193 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Plus, X } from "lucide-react"
import { cn } from "@/lib/utils"
export interface Tag {
id: string
name: string
color?: string
}
export interface TagManagerProps {
/** 已选择的标签 */
selectedTags: Tag[]
/** 标签变更回调 */
onTagsChange: (tags: Tag[]) => void
/** 预设标签列表 */
presetTags?: Tag[]
/** 是否允许创建自定义标签 */
allowCustomTags?: boolean
/** 标签最大数量0表示不限制 */
maxTags?: number
/** 自定义类名 */
className?: string
/** 是否使用卡片包装 */
withCard?: boolean
/** 标题 */
title?: string
}
/**
* 统一的标签管理器组件
*/
export function TagManager({
selectedTags = [],
onTagsChange,
presetTags = [],
allowCustomTags = true,
maxTags = 0,
className,
withCard = true,
title = "标签管理",
}: TagManagerProps) {
const [newTagName, setNewTagName] = useState("")
// 默认预设标签
const defaultPresetTags: Tag[] = [
{ id: "tag1", name: "重要客户", color: "bg-red-100 text-red-800 hover:bg-red-200" },
{ id: "tag2", name: "潜在客户", color: "bg-blue-100 text-blue-800 hover:bg-blue-200" },
{ id: "tag3", name: "已成交", color: "bg-green-100 text-green-800 hover:bg-green-200" },
{ id: "tag4", name: "待跟进", color: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200" },
{ id: "tag5", name: "高意向", color: "bg-purple-100 text-purple-800 hover:bg-purple-200" },
{ id: "tag6", name: "低意向", color: "bg-gray-100 text-gray-800 hover:bg-gray-200" },
{ id: "tag7", name: "已流失", color: "bg-pink-100 text-pink-800 hover:bg-pink-200" },
{ id: "tag8", name: "新客户", color: "bg-indigo-100 text-indigo-800 hover:bg-indigo-200" },
]
const availablePresetTags = presetTags.length > 0 ? presetTags : defaultPresetTags
// 添加标签
const handleAddTag = (tag: Tag) => {
if (maxTags > 0 && selectedTags.length >= maxTags) {
return
}
if (!selectedTags.some((t) => t.id === tag.id)) {
onTagsChange([...selectedTags, tag])
}
}
// 移除标签
const handleRemoveTag = (tagId: string) => {
onTagsChange(selectedTags.filter((tag) => tag.id !== tagId))
}
// 添加自定义标签
const handleAddCustomTag = () => {
if (!newTagName.trim() || (maxTags > 0 && selectedTags.length >= maxTags)) {
return
}
// 生成随机颜色
const colors = [
"bg-red-100 text-red-800 hover:bg-red-200",
"bg-blue-100 text-blue-800 hover:bg-blue-200",
"bg-green-100 text-green-800 hover:bg-green-200",
"bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
"bg-purple-100 text-purple-800 hover:bg-purple-200",
"bg-gray-100 text-gray-800 hover:bg-gray-200",
"bg-pink-100 text-pink-800 hover:bg-pink-200",
"bg-indigo-100 text-indigo-800 hover:bg-indigo-200",
]
const randomColor = colors[Math.floor(Math.random() * colors.length)]
const newTag: Tag = {
id: `custom-${Date.now()}`,
name: newTagName.trim(),
color: randomColor,
}
onTagsChange([...selectedTags, newTag])
setNewTagName("")
}
const TagManagerContent = () => (
<div className="space-y-4">
{title && <h3 className="text-lg font-medium">{title}</h3>}
{/* 已选标签 */}
<div>
<Label className="mb-2 block"></Label>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 border rounded-md">
{selectedTags.length > 0 ? (
selectedTags.map((tag) => (
<Badge key={tag.id} className={cn("flex items-center gap-1 px-3 py-1", tag.color)}>
{tag.name}
<X className="h-3 w-3 cursor-pointer" onClick={() => handleRemoveTag(tag.id)} />
</Badge>
))
) : (
<div className="text-gray-400 text-sm"></div>
)}
</div>
</div>
{/* 预设标签 */}
<div>
<Label className="mb-2 block"></Label>
<div className="flex flex-wrap gap-2">
{availablePresetTags.map((tag) => (
<Badge
key={tag.id}
className={cn(
"cursor-pointer px-3 py-1",
tag.color,
selectedTags.some((t) => t.id === tag.id) ? "opacity-50" : "",
)}
onClick={() => handleAddTag(tag)}
>
{tag.name}
</Badge>
))}
</div>
</div>
{/* 自定义标签 */}
{allowCustomTags && (
<div>
<Label className="mb-2 block"></Label>
<div className="flex gap-2">
<Input
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
placeholder="输入标签名称"
className="flex-1"
maxLength={10}
/>
<Button
onClick={handleAddCustomTag}
disabled={!newTagName.trim() || (maxTags > 0 && selectedTags.length >= maxTags)}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{maxTags > 0 && (
<div className="text-sm text-gray-500 mt-1">
{selectedTags.length}/{maxTags}
</div>
)}
</div>
)}
</div>
)
return withCard ? (
<Card className={className}>
<CardContent className="p-4 sm:p-6">
<TagManagerContent />
</CardContent>
</Card>
) : (
<div className={className}>
<TagManagerContent />
</div>
)
}

View File

@@ -0,0 +1,110 @@
"use client"
import type React from "react"
import { useState, useRef, useMemo, type ReactNode } from "react"
import { cn } from "@/lib/utils"
export interface VirtualizedListProps<T> {
/** 数据列表 */
items: T[]
/** 每项的高度 */
itemHeight: number
/** 容器高度 */
height: number
/** 渲染函数 */
renderItem: (item: T, index: number) => ReactNode
/** 缓冲区大小(额外渲染的项目数) */
overscan?: number
/** 自定义类名 */
className?: string
/** 加载更多回调 */
onLoadMore?: () => void
/** 是否正在加载 */
loading?: boolean
/** 空状态渲染 */
emptyState?: ReactNode
}
/**
* 虚拟化列表组件
* 用于高性能渲染大量数据
*/
export function VirtualizedList<T>({
items,
itemHeight,
height,
renderItem,
overscan = 5,
className,
onLoadMore,
loading = false,
emptyState,
}: VirtualizedListProps<T>) {
const [scrollTop, setScrollTop] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
// 计算可见范围
const visibleRange = useMemo(() => {
const containerHeight = height
const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight), items.length - 1)
return {
start: Math.max(0, startIndex - overscan),
end: Math.min(items.length - 1, endIndex + overscan),
}
}, [scrollTop, itemHeight, height, items.length, overscan])
// 可见项目
const visibleItems = useMemo(() => {
return items.slice(visibleRange.start, visibleRange.end + 1)
}, [items, visibleRange])
// 处理滚动
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const scrollTop = e.currentTarget.scrollTop
setScrollTop(scrollTop)
// 检查是否需要加载更多
if (onLoadMore && !loading) {
const { scrollHeight, clientHeight } = e.currentTarget
if (scrollTop + clientHeight >= scrollHeight - 100) {
onLoadMore()
}
}
}
// 总高度
const totalHeight = items.length * itemHeight
// 偏移量
const offsetY = visibleRange.start * itemHeight
if (items.length === 0) {
return (
<div className={cn("flex items-center justify-center", className)} style={{ height }}>
{emptyState || <div className="text-gray-500"></div>}
</div>
)
}
return (
<div ref={containerRef} className={cn("overflow-auto", className)} style={{ height }} onScroll={handleScroll}>
<div style={{ height: totalHeight, position: "relative" }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item, index) => (
<div key={visibleRange.start + index} style={{ height: itemHeight }} className="flex items-center">
{renderItem(item, visibleRange.start + index)}
</div>
))}
</div>
</div>
{loading && (
<div className="flex items-center justify-center p-4">
<div className="text-sm text-gray-500">...</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,243 @@
"use client"
import { useState, type ReactNode, createContext, useContext } from "react"
import { Button } from "@/app/components/ui/button"
import { Card, CardContent } from "@/app/components/ui/card"
import { Progress } from "@/app/components/ui/progress"
import { CheckCircle, Circle, ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/app/lib/utils"
export interface WizardStep {
id: string
title: string
description?: string
content: ReactNode
optional?: boolean
validation?: () => boolean | Promise<boolean>
}
interface WizardContextType {
currentStep: number
steps: WizardStep[]
goToStep: (step: number) => void
nextStep: () => Promise<void>
previousStep: () => void
isFirstStep: boolean
isLastStep: boolean
canGoNext: boolean
canGoPrevious: boolean
}
const WizardContext = createContext<WizardContextType | undefined>(undefined)
export function useWizard() {
const context = useContext(WizardContext)
if (!context) {
throw new Error("useWizard must be used within a Wizard component")
}
return context
}
export interface WizardProps {
steps: WizardStep[]
onComplete?: () => void
onCancel?: () => void
className?: string
showProgress?: boolean
showStepNumbers?: boolean
allowStepNavigation?: boolean
children?: ReactNode
}
/**
* 统一的向导组件
* 支持步骤导航、验证、进度显示等功能
*/
export function Wizard({
steps,
onComplete,
onCancel,
className,
showProgress = true,
showStepNumbers = true,
allowStepNavigation = false,
children,
}: WizardProps) {
const [currentStep, setCurrentStep] = useState(0)
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set())
const isFirstStep = currentStep === 0
const isLastStep = currentStep === steps.length - 1
const canGoPrevious = !isFirstStep
const canGoNext = currentStep < steps.length - 1
const goToStep = (step: number) => {
if (step >= 0 && step < steps.length) {
if (allowStepNavigation || step <= Math.max(...Array.from(completedSteps)) + 1) {
setCurrentStep(step)
}
}
}
const nextStep = async () => {
const step = steps[currentStep]
// 验证当前步骤
if (step.validation) {
const isValid = await step.validation()
if (!isValid) {
return
}
}
// 标记当前步骤为已完成
setCompletedSteps((prev) => new Set([...prev, currentStep]))
if (isLastStep) {
// 完成向导
if (onComplete) {
onComplete()
}
} else {
// 进入下一步
setCurrentStep((prev) => prev + 1)
}
}
const previousStep = () => {
if (canGoPrevious) {
setCurrentStep((prev) => prev - 1)
}
}
const contextValue: WizardContextType = {
currentStep,
steps,
goToStep,
nextStep,
previousStep,
isFirstStep,
isLastStep,
canGoNext,
canGoPrevious,
}
const progressPercentage = ((currentStep + 1) / steps.length) * 100
return (
<WizardContext.Provider value={contextValue}>
<div className={cn("space-y-6", className)}>
{/* 进度条 */}
{showProgress && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span>
{currentStep + 1} / {steps.length}
</span>
<span>{Math.round(progressPercentage)}% </span>
</div>
<Progress value={progressPercentage} className="h-2" />
</div>
)}
{/* 步骤指示器 */}
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<div
className={cn(
"flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors",
index === currentStep
? "border-blue-500 bg-blue-500 text-white"
: completedSteps.has(index)
? "border-green-500 bg-green-500 text-white"
: "border-gray-300 bg-white text-gray-500",
allowStepNavigation && "cursor-pointer hover:border-blue-400",
)}
onClick={() => allowStepNavigation && goToStep(index)}
>
{completedSteps.has(index) ? (
<CheckCircle className="h-6 w-6" />
) : showStepNumbers ? (
index + 1
) : (
<Circle className="h-6 w-6" />
)}
</div>
{index < steps.length - 1 && (
<div
className={cn(
"flex-1 h-0.5 mx-4 transition-colors",
completedSteps.has(index) ? "bg-green-500" : "bg-gray-200",
)}
/>
)}
</div>
))}
</div>
{/* 当前步骤标题 */}
<div className="text-center">
<h2 className="text-2xl font-bold">{steps[currentStep].title}</h2>
{steps[currentStep].description && <p className="text-gray-600 mt-2">{steps[currentStep].description}</p>}
{steps[currentStep].optional && <span className="text-sm text-gray-500 mt-1 block">()</span>}
</div>
{/* 步骤内容 */}
<Card>
<CardContent className="p-6">{steps[currentStep].content}</CardContent>
</Card>
{/* 导航按钮 */}
<div className="flex justify-between">
<div className="flex space-x-2">
{onCancel && (
<Button variant="outline" onClick={onCancel}>
</Button>
)}
{canGoPrevious && (
<Button variant="outline" onClick={previousStep}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
)}
</div>
<Button onClick={nextStep} disabled={!canGoNext && !isLastStep}>
{isLastStep ? "完成" : "下一步"}
{!isLastStep && <ArrowRight className="h-4 w-4 ml-2" />}
</Button>
</div>
{/* 自定义内容 */}
{children}
</div>
</WizardContext.Provider>
)
}
// 向导步骤组件
export function WizardStep({ children }: { children: ReactNode }) {
return <div>{children}</div>
}
// 向导导航组件
export function WizardNavigation() {
const { currentStep, steps, goToStep, canGoPrevious, canGoNext, nextStep, previousStep } = useWizard()
return (
<div className="flex justify-between">
<Button variant="outline" onClick={previousStep} disabled={!canGoPrevious}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Button onClick={nextStep} disabled={!canGoNext}>
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</div>
)
}

View File

@@ -1,15 +1,11 @@
"use client"
import { useState, useEffect } from "react"
import { useState } from "react"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Battery, Smartphone, MessageCircle, Users, Clock, Search, Power, RefreshCcw, Settings, AlertTriangle } from "lucide-react"
import { ImeiDisplay } from "@/components/ImeiDisplay"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Battery, Smartphone, MessageCircle, Users, Clock } from "lucide-react"
export interface Device {
id: string
@@ -41,55 +37,32 @@ export function DeviceGrid({
itemsPerRow = 2,
}: DeviceGridProps) {
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
const [searchTerm, setSearchTerm] = useState("")
const [filteredDevices, setFilteredDevices] = useState(devices)
useEffect(() => {
const filtered = devices.filter(
(device) =>
device.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
device.imei.includes(searchTerm) ||
device.wechatId.toLowerCase().includes(searchTerm.toLowerCase())
)
setFilteredDevices(filtered)
}, [searchTerm, devices])
const handleSelectAll = () => {
if (selectedDevices.length === filteredDevices.length) {
if (selectedDevices.length === devices.length) {
onSelect?.([])
} else {
onSelect?.(filteredDevices.map((d) => d.id))
onSelect?.(devices.map((d) => d.id))
}
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="relative w-full sm:w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="搜索设备..."
className="pl-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{selectable && (
<div className="flex items-center justify-between w-full sm:w-auto">
<div className="flex items-center space-x-2">
<Checkbox
checked={selectedDevices.length === filteredDevices.length && filteredDevices.length > 0}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm"></span>
</div>
<span className="text-sm text-gray-500 ml-4"> {selectedDevices.length} </span>
{selectable && (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
checked={selectedDevices.length === devices.length && devices.length > 0}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm"></span>
</div>
)}
</div>
<span className="text-sm text-gray-500"> {selectedDevices.length} </span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredDevices.map((device) => (
<div className={`grid grid-cols-${itemsPerRow} gap-4`}>
{devices.map((device) => (
<Card
key={device.id}
className={`p-4 hover:shadow-md transition-all cursor-pointer ${
@@ -115,77 +88,40 @@ export function DeviceGrid({
/>
)}
<div className="flex-1 space-y-2">
<div className="relative">
<div className="flex items-center justify-between">
<div className="font-medium flex items-center">
<span>{device.name}</span>
{device.addFriendStatus === "abnormal" && (
<Badge variant="destructive" className="ml-2 text-xs">
</Badge>
)}
</div>
<div className="absolute top-0 right-0">
<Badge
variant={device.status === "online" ? "default" : "secondary"}
className={`${
device.status === "online"
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
device.status === "online" ? "bg-green-500" : "bg-gray-500"
}`} />
<span>{device.status === "online" ? "在线" : "离线"}</span>
</div>
</Badge>
</div>
<div className="flex items-center justify-between">
<div className="font-medium">{device.name}</div>
<Badge variant={device.status === "online" ? "success" : "secondary"}>
{device.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
<span>{device.battery}%</span>
</div>
<div className="flex items-center space-x-1">
<Users className="w-4 h-4" />
<span>{device.friendCount}</span>
</div>
<div className="flex items-center space-x-1">
<MessageCircle className="w-4 h-4" />
<span>{device.messageCount}</span>
</div>
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>+{device.todayAdded}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
<Battery className={`w-4 h-4 ${
device.battery < 20
? "text-red-500"
: device.battery < 50
? "text-yellow-500"
: "text-green-500"
}`} />
<span className={`${
device.battery < 20
? "text-red-700"
: device.battery < 50
? "text-yellow-700"
: "text-green-700"
}`}>{device.battery}%</span>
</div>
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
<Users className="w-4 h-4 text-blue-500" />
<span className="text-blue-700">{device.friendCount}</span>
</div>
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
<MessageCircle className="w-4 h-4 text-purple-500" />
<span className="text-purple-700">{device.messageCount}</span>
</div>
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
<Clock className="w-4 h-4 text-indigo-500" />
<span className="text-indigo-700">+{device.todayAdded}</span>
</div>
<div className="text-sm text-gray-500">
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
<div className="text-sm space-y-1.5 mt-3">
<div className="flex items-center text-gray-600">
<span className="w-16">IMEI:</span>
<ImeiDisplay imei={device.imei} containerWidth={120} />
</div>
<div className="flex items-center text-gray-600">
<span className="w-16">:</span>
<span className="font-mono">{device.wechatId}</span>
</div>
</div>
<Badge variant={device.addFriendStatus === "normal" ? "outline" : "destructive"} className="mt-2">
{device.addFriendStatus === "normal" ? "加友正常" : "加友异常"}
</Badge>
</div>
</div>
</Card>
@@ -193,144 +129,74 @@ export function DeviceGrid({
</div>
<Dialog open={!!selectedDevice} onOpenChange={() => setSelectedDevice(null)}>
<DialogContent className="max-w-3xl">
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedDevice && (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`p-3 rounded-lg ${
selectedDevice.status === "online"
? "bg-green-100"
: "bg-gray-100"
}`}>
<Smartphone className={`w-6 h-6 ${
selectedDevice.status === "online"
? "text-green-700"
: "text-gray-700"
}`} />
<div className="p-3 bg-gray-100 rounded-lg">
<Smartphone className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium flex items-center space-x-2">
<span>{selectedDevice.name}</span>
<Badge
variant={selectedDevice.status === "online" ? "default" : "secondary"}
className={`${
selectedDevice.status === "online"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}
>
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
selectedDevice.status === "online" ? "bg-green-500" : "bg-gray-500"
}`} />
<span>{selectedDevice.status === "online" ? "在线" : "离线"}</span>
</div>
</Badge>
</h3>
<div className="text-sm text-gray-500 mt-1 space-x-4">
<span>IMEI: <ImeiDisplay imei={selectedDevice.imei} containerWidth={160} /></span>
<span>: {selectedDevice.wechatId}</span>
</div>
<h3 className="font-medium">{selectedDevice.name}</h3>
<p className="text-sm text-gray-500">IMEI: {selectedDevice.imei}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" className="space-x-1">
<RefreshCcw className="w-4 h-4" />
<span></span>
</Button>
<Button variant="outline" size="sm" className="space-x-1">
<Power className="w-4 h-4" />
<span></span>
</Button>
<Button variant="outline" size="sm">
<Settings className="w-4 h-4" />
</Button>
<Badge variant={selectedDevice.status === "online" ? "success" : "secondary"}>
{selectedDevice.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Battery className={`w-5 h-5 ${selectedDevice.battery < 20 ? "text-red-500" : "text-green-500"}`} />
<span className="font-medium">{selectedDevice.battery}%</span>
</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Users className="w-5 h-5 text-blue-500" />
<span className="font-medium">{selectedDevice.friendCount}</span>
</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Users className="w-5 h-5 text-green-500" />
<span className="font-medium">+{selectedDevice.todayAdded}</span>
</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<MessageCircle className="w-5 h-5 text-purple-500" />
<span className="font-medium">{selectedDevice.messageCount}</span>
</div>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="status"></TabsTrigger>
<TabsTrigger value="stats"></TabsTrigger>
<TabsTrigger value="tasks"></TabsTrigger>
<TabsTrigger value="logs"></TabsTrigger>
</TabsList>
<TabsContent value="status" className="space-y-4">
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Battery className={`w-5 h-5 ${
selectedDevice.battery < 20
? "text-red-500"
: selectedDevice.battery < 50
? "text-yellow-500"
: "text-green-500"
}`} />
<span className={`font-medium ${
selectedDevice.battery < 20
? "text-red-700"
: selectedDevice.battery < 50
? "text-yellow-700"
: "text-green-700"
}`}>{selectedDevice.battery}%</span>
</div>
</div>
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Users className="w-5 h-5 text-blue-500" />
<span className="font-medium text-blue-700">{selectedDevice.friendCount}</span>
</div>
</div>
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Users className="w-5 h-5 text-green-500" />
<span className="font-medium text-green-700">+{selectedDevice.todayAdded}</span>
</div>
</div>
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<MessageCircle className="w-5 h-5 text-purple-500" />
<span className="font-medium text-purple-700">{selectedDevice.messageCount}</span>
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{selectedDevice.wechatId}</div>
</div>
{selectedDevice.addFriendStatus === "abnormal" && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
<div className="flex items-center space-x-2 text-red-800">
<AlertTriangle className="w-5 h-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-red-600 mt-1">
</p>
</div>
)}
</TabsContent>
<TabsContent value="stats">
<div className="text-center text-gray-500 py-8">
...
</div>
</TabsContent>
<TabsContent value="tasks">
<div className="text-center text-gray-500 py-8">
...
</div>
</TabsContent>
<TabsContent value="logs">
<div className="text-center text-gray-500 py-8">
...
</div>
</TabsContent>
</Tabs>
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{selectedDevice.lastActive}</div>
</div>
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<Badge variant={selectedDevice.addFriendStatus === "normal" ? "outline" : "destructive"}>
{selectedDevice.addFriendStatus === "normal" ? "加友正常" : "加友异常"}
</Badge>
</div>
</div>
)}
</DialogContent>
@@ -338,4 +204,3 @@ export function DeviceGrid({
</div>
)
}

View File

@@ -1,206 +1,154 @@
"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, RefreshCw, Filter } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Checkbox } from "@/components/ui/checkbox"
import { api } from "@/lib/api"
import type React from "react"
interface Device {
id: string
name: string
imei: string
status: "online" | "offline"
wechatAccounts: {
wechatId: string
nickname: string
remainingAdds: number
maxDailyAdds: number
}[]
}
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Search, Smartphone } from "lucide-react"
import { Badge } from "@/components/ui/badge"
// 模拟设备数据
const mockDevices = [
{ id: "dev1", name: "iPhone 13", status: "online", lastActive: "2023-05-20T10:30:00Z" },
{ id: "dev2", name: "Xiaomi 12", status: "online", lastActive: "2023-05-20T09:15:00Z" },
{ id: "dev3", name: "Huawei P40", status: "offline", lastActive: "2023-05-19T18:45:00Z" },
{ id: "dev4", name: "OPPO Find X3", status: "online", lastActive: "2023-05-20T11:20:00Z" },
{ id: "dev5", name: "Samsung S21", status: "offline", lastActive: "2023-05-19T14:10:00Z" },
{ id: "dev6", name: "iPhone 12", status: "online", lastActive: "2023-05-20T08:30:00Z" },
{ id: "dev7", name: "Xiaomi 11", status: "online", lastActive: "2023-05-20T10:45:00Z" },
{ id: "dev8", name: "Huawei Mate 40", status: "offline", lastActive: "2023-05-18T16:20:00Z" },
]
interface DeviceSelectionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedDevices: string[]
onSelect: (deviceIds: string[]) => void
onSelect: (selectedDevices: string[]) => void
}
export function DeviceSelectionDialog({ open, onOpenChange, selectedDevices, onSelect }: DeviceSelectionDialogProps) {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([])
const [devices, setDevices] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<string[]>(selectedDevices)
useEffect(() => {
if (open) setSelectedDeviceIds(selectedDevices)
}, [open, selectedDevices])
useEffect(() => {
if (!open) return
const fetchDevices = async () => {
setLoading(true)
// 模拟API请求
const fetchData = async () => {
try {
const params = []
if (searchQuery) params.push(`keyword=${encodeURIComponent(searchQuery)}`)
if (statusFilter !== "all") params.push(`status=${statusFilter}`)
params.push("page=1", "limit=100")
const url = `/v1/devices?${params.join("&")}`
const response = await api.get<any>(url)
const list = response.data?.list || response.data?.items || []
const devices = list.map((device: any) => ({
id: device.id?.toString() || device.id,
imei: device.imei || "",
name: device.memo || device.name || `设备_${device.id}`,
status: device.alive === 1 || device.status === "online" ? "online" : "offline",
wechatAccounts: [
{
wechatId: device.wechatId || device.wxid || "",
nickname: device.nickname || "",
remainingAdds: device.remainingAdds || 0,
maxDailyAdds: device.maxDailyAdds || 0,
},
],
}))
setDevices(devices)
} catch {
setDevices([])
} finally {
// 实际项目中应从API获取数据
// const response = await fetch('/api/devices')
// const data = await response.json()
// setDevices(data)
// 使用模拟数据
setTimeout(() => {
setDevices(mockDevices)
setLoading(false)
}, 500)
} catch (error) {
console.error("获取设备失败:", error)
setLoading(false)
}
}
fetchDevices()
}, [open, searchQuery, statusFilter])
const handleSelectDevice = (deviceId: string) => {
setSelectedDeviceIds((prev) =>
prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId]
)
if (open) {
fetchData()
setSelected(selectedDevices)
}
}, [open, selectedDevices])
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value)
}
const handleToggleSelect = (id: string) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]))
}
const handleSelectAll = () => {
if (selectedDeviceIds.length === filteredDevices.length) {
setSelectedDeviceIds([])
if (selected.length === filteredDevices.length) {
setSelected([])
} else {
setSelectedDeviceIds(filteredDevices.map((device) => device.id))
setSelected(filteredDevices.map((device) => device.id))
}
}
const handleConfirm = () => {
onSelect(selectedDeviceIds)
onSelect(selected)
onOpenChange(false)
}
const handleCancel = () => {
setSelectedDeviceIds(selectedDevices)
onOpenChange(false)
}
const filteredDevices = devices.filter((device) => {
const searchLower = searchQuery.toLowerCase()
const matchesSearch =
(device.name || '').toLowerCase().includes(searchLower) ||
(device.imei || '').toLowerCase().includes(searchLower) ||
(device.wechatAccounts[0]?.wechatId || '').toLowerCase().includes(searchLower)
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline")
return matchesSearch && matchesStatus
})
const filteredDevices = devices.filter((device) => device.name.toLowerCase().includes(searchQuery.toLowerCase()))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl w-full p-0 rounded-2xl shadow-2xl max-h-[80vh]">
<DialogContent className="sm:max-w-md md:max-w-lg lg:max-w-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold text-center py-3 border-b"></DialogTitle>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="p-6 pt-4">
{/* 搜索和筛选 */}
<div className="flex items-center gap-2 mb-4">
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="flex-1 rounded-lg border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
/>
<select
className="border rounded-lg px-3 py-2 text-sm bg-gray-50 focus:border-blue-500"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
</div>
{/* 设备列表 */}
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-2">
{loading ? (
<div className="text-center text-gray-400 py-8">...</div>
) : filteredDevices.length === 0 ? (
<div className="text-center text-gray-400 py-8"></div>
) : (
filteredDevices.map(device => {
const checked = selectedDeviceIds.includes(device.id)
const wx = device.wechatAccounts[0] || {}
return (
<label
key={device.id}
className={`
flex items-center gap-3 p-4 rounded-xl border
${checked ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"}
hover:border-blue-400 transition-colors cursor-pointer
`}
>
<input
type="checkbox"
className="accent-blue-500 scale-110"
checked={checked}
onChange={() => {
setSelectedDeviceIds(prev =>
prev.includes(device.id)
? prev.filter(id => id !== device.id)
: [...prev, device.id]
)
}}
/>
<div className="flex-1">
<div className="font-semibold text-base">{device.name}</div>
<div className="text-xs text-gray-500">IMEI: {device.imei}</div>
<div className="text-xs text-gray-400">: {wx.wechatId || '--'}{wx.nickname || '--'}</div>
</div>
<span className="flex items-center gap-1 text-xs font-medium">
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-green-500' : 'bg-gray-300'}`}></span>
<span className={device.status === 'online' ? 'text-green-600' : 'text-gray-400'}>
{device.status === 'online' ? '在线' : '离线'}
</span>
</span>
</label>
)
})
)}
</div>
{/* 确认按钮 */}
<div className="flex justify-center mt-8">
<Button
className="w-4/5 py-3 rounded-full text-base font-bold shadow-md"
onClick={() => {
onSelect(selectedDeviceIds)
onOpenChange(false)
}}
>
</Button>
</div>
<div className="relative mb-4">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input placeholder="搜索设备名称" className="pl-9" value={searchQuery} onChange={handleSearch} />
</div>
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-500">
{selected.length} / {filteredDevices.length}
</div>
<Button variant="ghost" size="sm" onClick={handleSelectAll}>
{selected.length === filteredDevices.length ? "取消全选" : "全选"}
</Button>
</div>
<ScrollArea className="h-[300px] rounded-md border p-2">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
) : filteredDevices.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500"></div>
) : (
<div className="space-y-2">
{filteredDevices.map((device) => (
<div key={device.id} className="flex items-center space-x-3 p-2 rounded-md hover:bg-gray-100">
<Checkbox
id={`device-${device.id}`}
checked={selected.includes(device.id)}
onCheckedChange={() => handleToggleSelect(device.id)}
/>
<div className="flex-1">
<label htmlFor={`device-${device.id}`} className="flex items-center justify-between cursor-pointer">
<div className="flex items-center">
<Smartphone className="h-4 w-4 mr-2 text-gray-500" />
<div>
<div className="font-medium">{device.name}</div>
<div className="text-xs text-gray-500">ID: {device.id}</div>
</div>
</div>
<Badge variant={device.status === "online" ? "success" : "secondary"}>
{device.status === "online" ? "在线" : "离线"}
</Badge>
</label>
</div>
</div>
))}
</div>
)}
</ScrollArea>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -2,7 +2,7 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Check } from "lucide-react"
import { Check, Plus } from "lucide-react"
interface PosterTemplate {
id: string
@@ -57,7 +57,7 @@ export function PosterSelector({ open, onOpenChange, onSelect }: PosterSelectorP
<img
src={template.imageUrl || "/placeholder.svg"}
alt={template.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
className="w-full h-full object-cover"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 bg-black/50 group-hover:opacity-100 transition-opacity">
@@ -69,6 +69,15 @@ export function PosterSelector({ open, onOpenChange, onSelect }: PosterSelectorP
</div>
</div>
))}
{/* 添加自定义海报按钮 */}
<div className="group relative cursor-pointer">
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
<div className="flex flex-col items-center justify-center text-gray-400">
<Plus className="w-12 h-12" />
<p className="mt-2 text-sm"></p>
</div>
</div>
</div>
</div>
</div>
@@ -82,4 +91,3 @@ export function PosterSelector({ open, onOpenChange, onSelect }: PosterSelectorP
</Dialog>
)
}

View File

@@ -1,324 +1,99 @@
"use client"
import { useState, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Search, Filter } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Search } from "lucide-react"
interface UserTag {
id: string
name: string
color: string
}
interface TrafficUser {
id: string
avatar: string
nickname: string
wechatId: string
phone: string
region: string
note: string
status: "pending" | "added" | "failed"
addTime: string
source: string
assignedTo: string
category: "potential" | "customer" | "lost"
tags: UserTag[]
}
// 模拟流量池数据
const mockTrafficPools = [
{ id: "1", name: "抖音流量池", source: "抖音", count: 1200 },
{ id: "2", name: "微信流量池", source: "微信", count: 850 },
{ id: "3", name: "小红书流量池", source: "小红书", count: 650 },
{ id: "4", name: "知乎流量池", source: "知乎", count: 320 },
{ id: "5", name: "百度流量池", source: "百度", count: 480 },
]
interface TrafficPoolSelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedUsers: TrafficUser[]
onSelect: (users: TrafficUser[]) => void
onSelect: (poolId: string, poolName: string) => void
selectedPoolId: string | null
selectedPoolName: string
}
export function TrafficPoolSelector({ open, onOpenChange, selectedUsers, onSelect }: TrafficPoolSelectorProps) {
const [users, setUsers] = useState<TrafficUser[]>([])
const [loading, setLoading] = useState(false)
export function TrafficPoolSelector({ onSelect, selectedPoolId, selectedPoolName }: TrafficPoolSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [activeCategory, setActiveCategory] = useState("all")
const [sourceFilter, setSourceFilter] = useState("all")
const [tagFilter, setTagFilter] = useState("all")
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([])
const [filteredPools, setFilteredPools] = useState(mockTrafficPools)
// 初始化已选用户
// 搜索过滤
useEffect(() => {
if (open) {
setSelectedUserIds(selectedUsers.map((user) => user.id))
}
}, [open, selectedUsers])
// 模拟获取用户数据
useEffect(() => {
if (!open) return
const fetchUsers = async () => {
setLoading(true)
try {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 800))
// 生成模拟数据
const mockUsers: TrafficUser[] = Array.from({ length: 30 }, (_, i) => {
// 随机标签
const tagPool = [
{ id: "tag1", name: "潜在客户", color: "bg-blue-100 text-blue-800" },
{ id: "tag2", name: "高意向", color: "bg-green-100 text-green-800" },
{ id: "tag3", name: "已成交", color: "bg-purple-100 text-purple-800" },
{ id: "tag4", name: "需跟进", color: "bg-yellow-100 text-yellow-800" },
{ id: "tag5", name: "活跃用户", color: "bg-indigo-100 text-indigo-800" },
{ id: "tag6", name: "沉默用户", color: "bg-gray-100 text-gray-800" },
{ id: "tag7", name: "企业客户", color: "bg-red-100 text-red-800" },
{ id: "tag8", name: "个人用户", color: "bg-pink-100 text-pink-800" },
]
const randomTags = Array.from(
{ length: Math.floor(Math.random() * 3) + 1 },
() => tagPool[Math.floor(Math.random() * tagPool.length)],
)
// 确保标签唯一
const uniqueTags = randomTags.filter((tag, index, self) => index === self.findIndex((t) => t.id === tag.id))
const sources = ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号"]
const statuses = ["pending", "added", "failed"] as const
return {
id: `user-${i + 1}`,
avatar: `/placeholder.svg?height=40&width=40`,
nickname: `用户${i + 1}`,
wechatId: `wxid_${Math.random().toString(36).substring(2, 10)}`,
phone: `1${Math.floor(Math.random() * 9 + 1)}${Array(9)
.fill(0)
.map(() => Math.floor(Math.random() * 10))
.join("")}`,
region: ["北京", "上海", "广州", "深圳", "杭州"][Math.floor(Math.random() * 5)],
note: Math.random() > 0.7 ? `这是用户${i + 1}的备注信息` : "",
status: statuses[Math.floor(Math.random() * 3)],
addTime: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toISOString(),
source: sources[Math.floor(Math.random() * sources.length)],
assignedTo: Math.random() > 0.5 ? `销售${Math.floor(Math.random() * 5) + 1}` : "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as
| "potential"
| "customer"
| "lost",
tags: uniqueTags,
}
})
setUsers(mockUsers)
} catch (error) {
console.error("Failed to fetch users:", error)
} finally {
setLoading(false)
}
}
fetchUsers()
}, [open])
// 过滤用户
const filteredUsers = users.filter((user) => {
const matchesSearch =
searchQuery === "" ||
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.phone.includes(searchQuery)
const matchesCategory = activeCategory === "all" || user.category === activeCategory
const matchesSource = sourceFilter === "all" || user.source === sourceFilter
const matchesTag = tagFilter === "all" || user.tags.some((tag) => tag.id === tagFilter)
return matchesSearch && matchesCategory && matchesSource && matchesTag
})
// 处理选择用户
const handleSelectUser = (userId: string) => {
setSelectedUserIds((prev) => (prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]))
}
// 处理全选
const handleSelectAll = () => {
if (selectedUserIds.length === filteredUsers.length) {
setSelectedUserIds([])
if (searchQuery.trim() === "") {
setFilteredPools(mockTrafficPools)
} else {
setSelectedUserIds(filteredUsers.map((user) => user.id))
const query = searchQuery.toLowerCase()
const filtered = mockTrafficPools.filter(
(pool) => pool.name.toLowerCase().includes(query) || pool.source.toLowerCase().includes(query),
)
setFilteredPools(filtered)
}
}, [searchQuery])
// 处理选择
const handleSelect = (poolId: string) => {
const pool = mockTrafficPools.find((p) => p.id === poolId)
if (pool) {
onSelect(pool.id, pool.name)
setIsOpen(false)
}
}
// 处理确认选择
const handleConfirm = () => {
const selectedUsersList = users.filter((user) => selectedUserIds.includes(user.id))
onSelect(selectedUsersList)
onOpenChange(false)
}
// 获取所有标签选项
const allTags = Array.from(new Set(users.flatMap((user) => user.tags).map((tag) => JSON.stringify(tag)))).map(
(tag) => JSON.parse(tag) as UserTag,
)
// 获取所有来源选项
const allSources = Array.from(new Set(users.map((user) => user.source)))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
{selectedPoolId ? selectedPoolName : "选择流量池"}
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{/* 搜索和筛选区域 */}
<div className="space-y-4 mb-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索用户名/微信号/手机号"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</div>
{/* 分类标签页 */}
<Tabs defaultValue="all" value={activeCategory} onValueChange={setActiveCategory}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="potential"></TabsTrigger>
<TabsTrigger value="customer"></TabsTrigger>
<TabsTrigger value="lost"></TabsTrigger>
</TabsList>
</Tabs>
{/* 筛选器 */}
<div className="flex space-x-2">
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{allSources.map((source) => (
<SelectItem key={source} value={source}>
{source}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tagFilter} onValueChange={setTagFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="标签" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{allTags.map((tag) => (
<SelectItem key={tag.id} value={tag.id}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" className="ml-auto" onClick={handleSelectAll}>
{selectedUserIds.length === filteredUsers.length && filteredUsers.length > 0 ? "取消全选" : "全选"}
</Button>
</div>
<div className="relative mb-4">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="搜索流量池..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* 用户列表 */}
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchQuery || activeCategory !== "all" || sourceFilter !== "all" || tagFilter !== "all"
? "没有符合条件的用户"
: "暂无用户数据"}
</div>
) : (
<div className="space-y-2 pr-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className={`p-3 hover:shadow-md transition-shadow ${
selectedUserIds.includes(user.id) ? "border-primary" : ""
}`}
>
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedUserIds.includes(user.id)}
onCheckedChange={() => handleSelectUser(user.id)}
id={`user-${user.id}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<label htmlFor={`user-${user.id}`} className="font-medium truncate cursor-pointer">
{user.nickname}
</label>
<div
className={`px-2 py-1 rounded-full text-xs ${
user.status === "added"
? "bg-green-100 text-green-800"
: user.status === "pending"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{user.status === "added" ? "已添加" : user.status === "pending" ? "待处理" : "已失败"}
</div>
</div>
<div className="text-sm text-gray-500">: {user.wechatId}</div>
<div className="text-sm text-gray-500">: {user.source}</div>
{/* 标签展示 */}
<div className="flex flex-wrap gap-1 mt-2">
{user.tags.map((tag) => (
<span key={tag.id} className={`text-xs px-2 py-0.5 rounded-full ${tag.color}`}>
{tag.name}
</span>
))}
</div>
</div>
<RadioGroup value={selectedPoolId || ""} onValueChange={handleSelect}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredPools.map((pool) => (
<Card
key={pool.id}
className={`cursor-pointer transition-all ${selectedPoolId === pool.id ? "ring-2 ring-primary" : ""}`}
>
<CardContent className="p-4">
<RadioGroupItem value={pool.id} id={`pool-${pool.id}`} className="absolute right-4 top-4" />
<div className="space-y-2" onClick={() => handleSelect(pool.id)}>
<div className="font-medium">{pool.name}</div>
<div className="text-sm text-gray-500">: {pool.source}</div>
<div className="text-sm">: {pool.count.toLocaleString()}</div>
</div>
</Card>
))}
</div>
)}
</ScrollArea>
</div>
<DialogFooter className="flex items-center justify-between pt-4 border-t">
<div className="text-sm">
<span className="font-medium text-primary">{selectedUserIds.length}</span>
</div>
<div className="space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm}></Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
))}
</div>
</RadioGroup>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -54,4 +54,3 @@ const AccordionContent = React.forwardRef<
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -38,4 +38,3 @@ const AvatarFallback = React.forwardRef<
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -12,6 +12,7 @@ const badgeVariants = cva(
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",
success: "border-transparent bg-green-100 text-green-700 hover:bg-green-200",
},
},
defaultVariants: {
@@ -27,4 +28,3 @@ function Badge({ className, variant, ...props }: BadgeProps) {
}
export { Badge, badgeVariants }

View File

@@ -57,4 +57,3 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -52,4 +52,3 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -41,4 +41,3 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,69 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface ChartContainerProps extends React.HTMLAttributes<HTMLDivElement> {
config: Record<string, { label: string; color: string }>
}
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
({ className, config, children, ...props }, ref) => {
const colorVars = Object.entries(config).reduce((acc, [key, value], index) => {
acc[`--color-${key}`] = value.color
return acc
}, {})
return (
<div ref={ref} className={cn("relative", className)} style={colorVars} {...props}>
{children}
</div>
)
},
)
ChartContainer.displayName = "ChartContainer"
interface ChartTooltipProps {
children?: React.ReactNode
}
const ChartTooltip = React.forwardRef<HTMLDivElement, ChartTooltipProps>(({ className, children, ...props }, ref) => {
return <div ref={ref} className={cn("rounded-md border bg-card p-2 shadow-md", className)} {...props} />
})
ChartTooltip.displayName = "ChartTooltip"
interface ChartTooltipContentProps extends React.HTMLAttributes<HTMLDivElement> {
active?: boolean
payload?: any[]
label?: string
labelFormatter?: (value: any) => string
hideLabel?: boolean
}
const ChartTooltipContent = React.forwardRef<HTMLDivElement, ChartTooltipContentProps>(
({ active, payload, label, labelFormatter, hideLabel, className, ...props }, ref) => {
if (!active || !payload) {
return null
}
return (
<div ref={ref} className={cn("rounded-lg border bg-background p-2 shadow-sm", className)} {...props}>
{!hideLabel && label && (
<div className="mb-1 text-xs font-medium">{labelFormatter ? labelFormatter(label) : label}</div>
)}
<div className="flex flex-col gap-1">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2 text-xs">
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="font-medium">{entry.name}:</span>
<span>{entry.value}</span>
</div>
))}
</div>
</div>
)
},
)
ChartTooltipContent.displayName = "ChartTooltipContent"
export { ChartContainer, ChartTooltip, ChartTooltipContent }

View File

@@ -26,4 +26,3 @@ const Checkbox = React.forwardRef<
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -9,4 +9,3 @@ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -95,4 +95,3 @@ export {
DialogTitle,
DialogDescription,
}

View File

@@ -179,4 +179,3 @@ export {
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -20,4 +20,3 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
Input.displayName = "Input"
export { Input }

View File

@@ -17,4 +17,3 @@ const Label = React.forwardRef<
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -79,4 +79,3 @@ export {
PaginationNext,
PaginationPrevious,
}

View File

@@ -4,4 +4,3 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
const Popover = PopoverPrimitive.Root
const PopoverTrigger

View File

@@ -32,4 +32,3 @@ export function PreviewDialog({ children, title = "预览效果" }: PreviewDialo
</>
)
}

View File

@@ -23,4 +23,3 @@ const Progress = React.forwardRef<
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -36,4 +36,3 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -38,4 +38,3 @@ const ScrollBar = React.forwardRef<
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -101,4 +101,3 @@ const SelectSeparator = React.forwardRef<
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator }

View File

@@ -6,4 +6,3 @@ function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>)
}
export { Skeleton }

View File

@@ -0,0 +1,157 @@
"use client"
import { cn } from "@/lib/utils"
import { Check } from "lucide-react"
export interface Step {
id: number
title: string
subtitle?: string
description?: string
}
export interface StepIndicatorProps {
steps: Step[]
currentStep: number
variant?: "default" | "circle" | "numbered" | "minimal"
orientation?: "horizontal" | "vertical"
onStepClick?: (stepId: number) => void
className?: string
showProgress?: boolean
}
/**
* 统一的步骤指示器组件
*
* @param steps 步骤数组
* @param currentStep 当前步骤
* @param variant 样式变体
* @param orientation 方向
* @param onStepClick 步骤点击回调
* @param className 自定义类名
* @param showProgress 是否显示进度条
*/
export function StepIndicator({
steps,
currentStep,
variant = "default",
orientation = "horizontal",
onStepClick,
className,
showProgress = true,
}: StepIndicatorProps) {
// 计算进度百分比
const progressPercentage = steps.length > 1 ? ((currentStep - 1) / (steps.length - 1)) * 100 : 0
// 根据变体渲染不同样式的步骤指示器
if (variant === "circle") {
return (
<div className={cn("w-full", orientation === "vertical" ? "space-y-4" : "", className)}>
<div
className={cn(
"relative",
orientation === "horizontal" ? "flex justify-between items-center" : "flex-col space-y-8",
)}
>
{steps.map((step, index) => {
const isCompleted = currentStep > step.id
const isCurrent = currentStep === step.id
const isClickable = onStepClick && (isCompleted || isCurrent)
return (
<div
key={step.id}
className={cn(
"flex items-center relative z-10",
orientation === "horizontal" ? "flex-col" : "flex-row space-x-4",
isClickable ? "cursor-pointer" : "",
)}
onClick={() => isClickable && onStepClick(step.id)}
>
<div
className={cn(
"flex items-center justify-center w-10 h-10 rounded-full transition-colors",
isCompleted
? "bg-blue-600 text-white"
: isCurrent
? "border-2 border-blue-600 text-blue-600"
: "border-2 border-gray-300 text-gray-300",
)}
>
{isCompleted ? <Check className="w-5 h-5" /> : step.id}
</div>
<div className={cn("text-center mt-2", orientation === "horizontal" ? "" : "flex-1")}>
<div
className={cn(
"font-medium",
isCurrent ? "text-blue-600" : isCompleted ? "text-gray-900" : "text-gray-400",
)}
>
{step.title}
</div>
{step.subtitle && <div className="text-xs text-gray-500">{step.subtitle}</div>}
</div>
</div>
)
})}
{/* 连接线 */}
{showProgress && orientation === "horizontal" && (
<div className="absolute top-5 left-0 w-full h-0.5 bg-gray-200 -translate-y-1/2 z-0">
<div
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
)}
</div>
</div>
)
}
// 默认样式
return (
<div className={cn("w-full", className)}>
<div className="flex items-center justify-between mb-4">
{steps.map((step, index) => {
const isActive = currentStep >= step.id
const isCurrent = currentStep === step.id
const isClickable = onStepClick && currentStep > step.id
return (
<div key={step.id} className="flex items-center flex-1">
<div
className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-full border-2",
isActive
? "bg-blue-600 border-blue-600 text-white"
: isCurrent
? "border-blue-600 text-blue-600"
: "border-gray-300 text-gray-300",
isClickable ? "cursor-pointer" : "",
)}
onClick={() => isClickable && onStepClick(step.id)}
>
{isActive && currentStep !== step.id ? <Check className="w-4 h-4" /> : step.id}
</div>
{index < steps.length - 1 && (
<div className={cn("flex-1 h-0.5", index < currentStep - 1 ? "bg-blue-600" : "bg-gray-300")} />
)}
<div
className={cn(
"absolute mt-10 text-xs text-center w-24 -ml-8",
isCurrent ? "text-blue-600 font-medium" : "text-gray-500",
)}
>
{step.title}
</div>
</div>
)
})}
</div>
</div>
)
}
export default StepIndicator

View File

@@ -65,4 +65,3 @@ export function Step({ title, description }: StepProps) {
</div>
)
}

View File

@@ -27,4 +27,3 @@ const Switch = React.forwardRef<
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -70,4 +70,3 @@ const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttribu
TableCaption.displayName = "TableCaption"
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }

View File

@@ -53,4 +53,3 @@ const TabsContent = React.forwardRef<
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -19,4 +19,3 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -109,4 +109,3 @@ export {
ToastClose,
ToastAction,
}

View File

@@ -74,7 +74,7 @@ const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
<div
ref={tooltipRef}
className={cn(
"fixed z-50 px-2 py-1 text-xs text-primary-foreground bg-primary rounded-md shadow-sm scale-90 animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"fixed z-50 px-2 py-1 text-xs text-primary-foreground bg-primary rounded-md shadow-sm scale-90 animate-in fade-in-0 zoom-in-95",
className,
)}
style={{
@@ -105,4 +105,3 @@ export const TooltipContent = React.forwardRef<HTMLDivElement, React.HTMLAttribu
TooltipContent.displayName = "TooltipContent"
export { Tooltip }

View File

@@ -184,4 +184,3 @@ function useToast() {
}
export { useToast, toast }

View File

@@ -2,162 +2,59 @@
import type React from "react"
import { useState, useRef } from "react"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronLeft, Plus, X, Image as ImageIcon, UploadCloud, Link, Video, FileText, Layers, CalendarDays, ChevronDown } from "lucide-react"
import { ChevronLeft, Plus, X } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { toast } from "@/components/ui/use-toast"
import Image from "next/image"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { api } from "@/lib/api"
import { showToast } from "@/lib/toast"
interface ApiResponse<T = any> {
code: number
msg: string
data: T
}
// 素材类型枚举
const MATERIAL_TYPES = [
{ id: 1, name: "图片", icon: ImageIcon },
{ id: 2, name: "链接", icon: Link },
{ id: 3, name: "视频", icon: Video },
{ id: 4, name: "文本", icon: FileText },
{ id: 5, name: "小程序", icon: Layers }
]
export default function NewMaterialPage({ params }: { params: { id: string } }) {
const router = useRouter()
const [content, setContent] = useState("")
const [images, setImages] = useState<string[]>([])
const [previewUrls, setPreviewUrls] = useState<string[]>([])
const [materialType, setMaterialType] = useState<number>(1)
const [url, setUrl] = useState<string>("")
const [desc, setDesc] = useState<string>("")
const [image, setImage] = useState<string>("")
const [videoUrl, setVideoUrl] = useState<string>("")
const [publishTime, setPublishTime] = useState("")
const [comment, setComment] = useState("")
const [loading, setLoading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [newTag, setNewTag] = useState("")
const [tags, setTags] = useState<string[]>([])
// 图片上传
const handleUploadImage = () => {
fileInputRef.current?.click()
}
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showToast("请选择图片文件", "error")
return
}
const loadingToast = showToast("正在上传图片...", "loading", true)
setLoading(true)
const formData = new FormData()
formData.append("file", file)
try {
const token = localStorage.getItem('token');
const headers: HeadersInit = {
// 浏览器会自动为 FormData 设置 Content-Type 为 multipart/form-data无需手动设置
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
method: 'POST',
headers: headers,
body: formData,
});
const result: ApiResponse = await response.json();
if (result.code === 200 && result.data?.url) {
setImages((prev) => [...prev, result.data.url]);
setPreviewUrls((prev) => [...prev, result.data.url]);
showToast("图片上传成功", "success");
} else {
showToast(result.msg || "图片上传失败", "error");
}
} catch (error: any) {
showToast(error?.message || "图片上传失败", "error")
} finally {
loadingToast.remove && loadingToast.remove()
setLoading(false)
// 清空文件输入框,以便再次上传同一文件
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
const handleAddTag = () => {
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag])
setNewTag("")
}
}
const handleRemoveImage = (indexToRemove: number) => {
setImages(images.filter((_, index) => index !== indexToRemove))
setPreviewUrls(previewUrls.filter((_, index) => index !== indexToRemove))
const handleRemoveTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove))
}
// 创建素材
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// 校验
if (!content) {
showToast("请输入内容", "error")
toast({
title: "错误",
description: "请输入素材内容",
variant: "destructive",
})
return
}
if (!comment) {
showToast("请输入评论内容", "error")
return
}
if (materialType === 1 && images.length === 0) {
showToast("请上传图片", "error")
return
} else if (materialType === 2 && (!url || !desc)) {
showToast("请输入描述和链接地址", "error")
return
} else if (materialType === 3 && (!url && !videoUrl)) {
showToast("请填写视频链接或上传视频", "error")
return
}
setLoading(true)
const loadingToast = showToast("正在创建素材...", "loading", true)
try {
const payload: any = {
libraryId: params.id,
type: materialType,
content: content,
comment: comment,
sendTime: publishTime,
}
if (materialType === 1) {
payload.resUrls = images
} else if (materialType === 2) {
payload.urls = [{ desc, image, url }]
} else if (materialType === 3) {
payload.urls = videoUrl ? [videoUrl] : []
}
const response = await api.post<ApiResponse>('/v1/content/library/create-item', payload)
if (response.code === 200) {
showToast("创建成功", "success")
// 模拟保存新素材
await new Promise((resolve) => setTimeout(resolve, 1000))
toast({
title: "成功",
description: "新素材已创建",
})
router.push(`/content/${params.id}/materials`)
} else {
showToast(response.msg || "创建失败", "error")
}
} catch (error: any) {
showToast(error?.message || "创建素材失败", "error")
} finally {
loadingToast.remove && loadingToast.remove()
setLoading(false)
} catch (error) {
console.error("Failed to create new material:", error)
toast({
title: "错误",
description: "创建素材失败",
variant: "destructive",
})
}
}
@@ -173,273 +70,57 @@ export default function NewMaterialPage({ params }: { params: { id: string } })
</div>
</div>
</header>
<div className="p-4">
<Card className="p-8 rounded-3xl shadow-xl bg-white max-w-lg mx-auto">
<form onSubmit={handleSubmit} className="space-y-8">
{/* 基础信息分组 */}
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
<div className="mb-4">
<Label className="font-bold flex items-center mb-2"></Label>
<div className="relative">
<Input
id="publish-time"
type="datetime-local"
step="60"
value={publishTime}
onChange={(e) => setPublishTime(e.target.value)}
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
placeholder="请选择发布时间"
style={{ width: 'auto' }}
/>
<CalendarDays className="absolute right-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
</div>
</div>
<Card className="p-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label className="font-bold flex items-center mb-2">
<span className="text-red-500 mr-1">*</span>
</Label>
<div className="relative">
<select
style={{ border: '1px solid #e0e0e0' }}
className="appearance-none w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 pr-10 text-base bg-white placeholder:text-gray-300"
value={materialType}
onChange={e => setMaterialType(Number(e.target.value))}
>
{MATERIAL_TYPES.map(type => (
<option key={type.id} value={type.id}>{type.name}</option>
))}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
</div>
</div>
</div>
<div className="border-b border-gray-100 my-4" />
{/* 内容信息分组(所有类型都展示内容和评论) */}
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
<Label htmlFor="content" className="font-bold flex items-center mb-2">
<span className="text-red-500 mr-1">*</span>
</Label>
<Label htmlFor="content"></Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="请输入内容"
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[120px] bg-gray-50 placeholder:text-gray-300"
rows={10}
placeholder="请输入素材内容"
className="mt-1"
rows={5}
/>
<div className="mt-4">
<Label htmlFor="comment" className="font-bold mb-2"></Label>
<Textarea
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="请输入评论内容"
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[80px] bg-gray-50 placeholder:text-gray-300"
rows={4}
/>
</div>
</div>
{(materialType === 2 || materialType === 3) && (
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
{materialType === 2 && (
<div className="mb-4">
<Label htmlFor="desc" className="font-bold flex items-center mb-2">
<span className="text-red-500 mr-1">*</span>
</Label>
<Input
id="desc"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="请输入描述"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"/>
{/* 封面图上传 */}
<div className="mt-4">
<Label className="font-bold mb-2"></Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
className="rounded-2xl border-dashed border-2 border-blue-300 bg-white hover:bg-blue-50 h-28 w-28 flex flex-col items-center justify-center p-0"
onClick={() => {
const mock = [
"https://cdn-icons-png.flaticon.com/512/732/732212.png",
"https://cdn-icons-png.flaticon.com/512/5968/5968764.png",
"https://cdn-icons-png.flaticon.com/512/5968/5968705.png"
];
const random = mock[Math.floor(Math.random() * mock.length)];
setImage(random);
}}
>
{image ? (
<Image src={image} alt="封面图" width={80} height={80} className="object-contain rounded-xl mx-auto my-auto" />
) : (
<>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400 mx-auto" />
<span className="text-sm text-gray-500"></span>
</>
)}
</Button>
{image && (
<Button type="button" variant="destructive" size="sm" className="h-8 px-2 rounded-lg" onClick={() => setImage("")}></Button>
)}
</div>
<div className="text-xs text-gray-400 mt-1"> 80x80 PNG/JPG</div>
</div>
</div>
)}
{materialType === 2 && (
<>
<Label htmlFor="url" className="font-bold flex items-center mb-2">
<span className="text-red-500 mr-1">*</span>
</Label>
<div>
<Label htmlFor="tags"></Label>
<div className="flex items-center mt-1">
<Input
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="输入链接地址"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
/>
</>
)}
{materialType === 3 && (
<div className="mt-4">
<Label className="font-bold mb-2"></Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
className="rounded-2xl border-dashed border-2 border-blue-300 bg-white hover:bg-blue-50 h-28 w-44 flex flex-col items-center justify-center p-0"
onClick={() => {
const mock = [
"https://www.w3schools.com/html/mov_bbb.mp4",
"https://www.w3schools.com/html/movie.mp4"
];
const random = mock[Math.floor(Math.random() * mock.length)];
setVideoUrl(random);
}}
>
{videoUrl ? (
<video src={videoUrl} controls className="object-contain rounded-xl h-24 w-40 mx-auto my-auto" />
) : (
<>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400 mx-auto" />
<span className="text-sm text-gray-500"></span>
</>
)}
id="tags"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="输入标签"
className="flex-1"
/>
<Button type="button" onClick={handleAddTag} className="ml-2">
<Plus className="h-4 w-4" />
</Button>
{videoUrl && (
<Button type="button" variant="destructive" size="sm" className="h-8 px-2 rounded-lg" onClick={() => setVideoUrl("")}></Button>
)}
</div>
<div className="text-xs text-gray-400 mt-1">MP420MB</div>
</div>
)}
</div>
)}
{/* 素材上传分组(仅图片类型和小程序类型) */}
{(materialType === 1 || materialType === 5) && (
<div className="mb-6">
<div className="text-xs text-gray-400 mb-2 tracking-widest"></div>
{materialType === 1 && (
<>
<Label className="font-bold mb-2"></Label>
<div className="border border-dashed border-gray-300 rounded-2xl p-4 text-center bg-gray-50">
<Button
type="button"
variant="outline"
onClick={handleUploadImage}
className="w-full py-8 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
disabled={loading}
>
<UploadCloud className="h-8 w-8 mb-2 text-gray-400" />
<span></span>
<span className="text-xs text-gray-500 mt-1"> JPGPNG </span>
</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept="image/*"
/>
</div>
{previewUrls.length > 0 && (
<div className="mt-2">
<Label className="font-bold mb-2"></Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-2">
{previewUrls.map((url, index) => (
<div key={index} className="relative group">
<div className="aspect-square relative rounded-2xl overflow-hidden border border-gray-200">
<Image
src={url}
alt={`图片 ${index + 1}`}
fill
className="object-cover"
/>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{tags.map((tag, index) => (
<Badge key={index} variant="secondary" className="flex items-center">
{tag}
<Button
type="button"
variant="destructive"
variant="ghost"
size="sm"
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0 rounded-full"
onClick={() => handleRemoveImage(index)}
className="h-4 w-4 ml-1 p-0"
onClick={() => handleRemoveTag(tag)}
>
<X className="h-3 w-3" />
</Button>
</div>
</Badge>
))}
</div>
</div>
)}
</>
)}
{materialType === 5 && (
<div className="space-y-6">
<div>
<Label htmlFor="appTitle" className="font-bold mb-2"></Label>
<Input
id="appTitle"
placeholder="请输入小程序名称"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
/>
</div>
<div>
<Label htmlFor="appId" className="font-bold mb-2">AppID</Label>
<Input
id="appId"
placeholder="请输入AppID"
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
/>
</div>
<div>
<Label className="font-bold mb-2"></Label>
<div className="border border-dashed border-gray-300 rounded-2xl p-4 text-center bg-gray-50">
<Button
type="button"
variant="outline"
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = handleUploadImage;
input.click();
}}
className="w-full py-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
>
<UploadCloud className="h-6 w-6 mb-2 text-gray-400" />
<span></span>
</Button>
</div>
</div>
</div>
)}
</div>
)}
<Button type="submit" className="w-full h-12 rounded-2xl bg-blue-600 hover:bg-blue-700 text-base font-bold mt-12 shadow" disabled={loading}>
{loading ? "创建中..." : "保存素材"}
<Button type="submit" className="w-full">
</Button>
</form>
</Card>
@@ -447,4 +128,3 @@ export default function NewMaterialPage({ params }: { params: { id: string } })
</div>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { useState, useEffect, useCallback, use } from "react"
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart, RefreshCw, Image as ImageIcon, Edit } from "lucide-react"
import { useState, useEffect } from "react"
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -9,327 +9,101 @@ import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { api } from "@/lib/api"
import { showToast } from "@/lib/toast"
import { Avatar } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
import { format } from "date-fns"
import Image from "next/image"
import { cn } from "@/lib/utils"
interface ApiResponse<T = any> {
code: number
msg: string
data: T
}
interface MaterialListResponse {
list: Material[]
total: number
}
interface Material {
id: number
type: string
title: string
id: string
content: string
coverImage: string | null
resUrls: string[]
urls: string[]
createTime: string
createMomentTime: number
time: string
wechatId: string
friendId: string | null
wechatChatroomId: number
senderNickname: string
senderAvatar: string // 发布朋友圈用户的头像
location: string | null
lat: string
lng: string
tags: string[]
aiAnalysis?: string
}
const isImageUrl = (url: string) => {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) || url.includes('oss-cn-shenzhen.aliyuncs.com')
}
export default function MaterialsPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params)
export default function MaterialsPage({ params }: { params: { id: string } }) {
const router = useRouter()
const [materials, setMaterials] = useState<Material[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const limit = 20
const [deleteDialogOpen, setDeleteDialogOpen] = useState<number | null>(null)
const fetchMaterials = useCallback(async () => {
useEffect(() => {
const fetchMaterials = async () => {
setIsLoading(true)
try {
const queryParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
libraryId: resolvedParams.id,
...(searchQuery ? { keyword: searchQuery } : {})
})
const response = await api.get<ApiResponse<MaterialListResponse>>(`/v1/content/library/item-list?${queryParams.toString()}`)
if (response.code === 200 && response.data) {
setMaterials(response.data.list)
setTotal(response.data.total)
} else {
showToast(response.msg || "获取素材数据失败", "error")
}
} catch (error: any) {
// 模拟从API获取素材数据
await new Promise((resolve) => setTimeout(resolve, 500))
const mockMaterials: Material[] = [
{
id: "1",
content: "今天的阳光真好,适合出去走走",
tags: ["日常", "心情"],
},
{
id: "2",
content: "新品上市,限时优惠,快来抢购!",
tags: ["营销", "促销"],
},
{
id: "3",
content: "学习新技能的第一天,感觉很充实",
tags: ["学习", "成长"],
},
]
setMaterials(mockMaterials)
} catch (error) {
console.error("Failed to fetch materials:", error)
showToast(error?.message || "请检查网络连接", "error")
toast({
title: "错误",
description: "获取素材数据失败",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}, [page, searchQuery, resolvedParams.id])
useEffect(() => {
}
fetchMaterials()
}, [fetchMaterials])
}, [])
const handleDownload = () => {
showToast("正在将素材导出为Excel格式", "loading")
// 实现下载功能
toast({
title: "下载开始",
description: "正在将素材导出为Excel格式",
})
}
const handleNewMaterial = () => {
router.push(`/content/${resolvedParams.id}/materials/new`)
// 实现新建素材功能
router.push(`/content/${params.id}/materials/new`)
}
const handleAIAnalysis = async (material: Material) => {
try {
// 模拟AI分析过程
await new Promise((resolve) => setTimeout(resolve, 1000))
const analysis = "这是一条" + material.title + "相关的内容,情感倾向积极。"
setSelectedMaterial(material)
showToast("AI分析完成", "success")
const analysis = "这是一条" + material.tags.join("、") + "相关的内容,情感倾向积极。"
setMaterials(materials.map((m) => (m.id === material.id ? { ...m, aiAnalysis: analysis } : m)))
setSelectedMaterial({ ...material, aiAnalysis: analysis })
} catch (error) {
console.error("AI analysis failed:", error)
showToast("AI分析失败", "error")
toast({
title: "错误",
description: "AI分析失败",
variant: "destructive",
})
}
}
const handleSearch = () => {
setPage(1)
fetchMaterials()
}
const filteredMaterials = materials.filter(
(material) =>
material.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
material.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase())),
)
const handleRefresh = () => {
fetchMaterials()
}
const handleDelete = async (id: number) => {
const loadingToast = showToast("正在删除...", "loading", true)
try {
const response = await api.delete<ApiResponse>(`/v1/content/library/delete-item?id=${id}`)
if (response.code === 200) {
showToast("删除成功", "success")
fetchMaterials()
} else {
showToast(response.msg || "删除失败", "error")
}
} catch (error: any) {
showToast(error?.message || "删除失败", "error")
} finally {
loadingToast.remove && loadingToast.remove()
setDeleteDialogOpen(null)
}
}
// 新增:根据类型渲染内容
const renderMaterialByType = (material: any) => {
const type = Number(material.contentType || material.type);
// 链接类型
if (type === 2 && material.urls && material.urls.length > 0) {
const first = material.urls[0];
return (
<a
href={first.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center bg-white rounded p-2 hover:bg-gray-50 transition group"
style={{ textDecoration: 'none' }}
>
{first.image && (
<div className="flex-shrink-0 w-14 h-14 rounded overflow-hidden mr-3 bg-gray-100">
<Image
src={first.image}
alt="封面图"
width={56}
height={56}
className="object-cover w-full h-full"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-base font-medium truncate group-hover:text-blue-600">{first.desc}</div>
</div>
</a>
);
}
// 视频类型
if (type === 3 && material.urls && material.urls.length > 0) {
const first = material.urls[0];
const videoUrl = typeof first === "string" ? first : (first.url || "");
return videoUrl ? (
<div className="mb-3">
<video src={videoUrl} controls className="rounded w-full max-w-md" />
</div>
) : null;
}
// 文本类型
if (type === 4 || type === 6) {
return (
<div className="mb-3">
<p className="text-gray-700 whitespace-pre-line">{material.content}</p>
</div>
);
}
// 小程序类型
if (type === 5 && material.urls && material.urls.length > 0) {
const first = material.urls[0];
return (
<div className="mb-3">
<div>{first.appTitle}</div>
<div>AppID{first.appId}</div>
{first.image && (
<div className="mb-2">
<Image src={first.image} alt="小程序封面图" width={80} height={80} className="rounded" />
</div>
)}
</div>
);
}
// 图片类型
if (type === 1) {
return (
<div className="mb-3">
{/* 内容字段(如有) */}
{material.content && (
<div className="mb-2 text-base font-medium text-gray-800 whitespace-pre-line">
{material.content}
</div>
)}
{/* 图片资源 */}
{renderImageResources(material)}
</div>
);
}
// 其它类型
return null;
}
// 处理图片资源
const renderImageResources = (material: Material) => {
const imageUrls = material.resUrls.filter(isImageUrl)
// 如果内容本身是图片,也添加到图片数组中
if (isImageUrl(material.content) && !imageUrls.includes(material.content)) {
imageUrls.unshift(material.content)
}
if (imageUrls.length === 0) return null
// 微信朋友圈风格的图片布局
if (imageUrls.length === 1) {
// 单张图片:大图显示
return (
<div className="relative rounded-md overflow-hidden">
<Image
src={imageUrls[0]}
alt="图片内容"
width={600}
height={400}
className="object-cover w-full h-auto"
/>
</div>
)
} else if (imageUrls.length === 2) {
// 两张图片:横向排列
return (
<div className="grid grid-cols-2 gap-2 mb-3">
{imageUrls.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
<Image
src={url}
alt={`图片 ${idx + 1}`}
fill
className="object-cover"
/>
</div>
))}
</div>
)
} else if (imageUrls.length === 3) {
// 三张图片使用3x3网格的前三个格子
return (
<div className="grid grid-cols-3 gap-2 mb-3">
{imageUrls.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
<Image
src={url}
alt={`图片 ${idx + 1}`}
fill
className="object-cover"
/>
</div>
))}
</div>
)
} else if (imageUrls.length === 4) {
// 四张图片2x2网格
return (
<div className="grid grid-cols-2 gap-2 mb-3">
{imageUrls.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
<Image
src={url}
alt={`图片 ${idx + 1}`}
fill
className="object-cover"
/>
</div>
))}
</div>
)
} else {
// 五张及以上3x3网格
const displayImages = imageUrls.slice(0, 9)
const hasMore = imageUrls.length > 9
return (
<div className="grid grid-cols-3 gap-2 mb-3">
{displayImages.map((url, idx) => (
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
<Image
src={url}
alt={`图片 ${idx + 1}`}
fill
className="object-cover"
/>
{idx === 8 && hasMore && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<span className="text-white text-lg font-medium">+{imageUrls.length - 9}</span>
</div>
)}
</div>
))}
</div>
)
}
}
const handleSubmit = async () => {
fetchMaterials()
if (isLoading) {
return <div className="flex justify-center items-center h-screen">...</div>
}
return (
<div className="flex-1 bg-gray-50 min-h-screen pb-20">
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
@@ -339,6 +113,10 @@ export default function MaterialsPage({ params }: { params: Promise<{ id: string
<h1 className="text-lg font-medium"></h1>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
Excel
</Button>
<Button onClick={handleNewMaterial}>
<Plus className="h-4 w-4 mr-2" />
@@ -348,117 +126,36 @@ export default function MaterialsPage({ params }: { params: Promise<{ id: string
</header>
<div className="p-4">
<Card className="p-4 mb-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Card className="p-4">
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索素材..."
className="pl-9"
placeholder="搜索素材或标签..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-9"
/>
</div>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</Card>
{isLoading ? (
// 加载状态
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<Card key={index} className="p-4">
<div className="flex items-center space-x-3 mb-3">
<div className="w-10 h-10 rounded-full bg-gray-200 animate-pulse"></div>
<div className="space-y-2">
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded"></div>
<div className="h-3 w-16 bg-gray-200 animate-pulse rounded"></div>
</div>
</div>
<div className="my-3 h-0.5 bg-gray-100"></div>
<div className="space-y-2">
<div className="h-4 w-full bg-gray-200 animate-pulse rounded"></div>
<div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded"></div>
<div className="flex space-x-2 mt-3">
<div className="h-20 w-20 bg-gray-200 animate-pulse rounded"></div>
<div className="h-20 w-20 bg-gray-200 animate-pulse rounded"></div>
</div>
</div>
</Card>
))}
</div>
) : (
// 素材列表
<div className="space-y-4">
{materials.length === 0 ? (
<Card className="p-8 text-center text-gray-500">
</Card>
) : (
materials.map(material => (
<Card key={material.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<Avatar>
<Image
src={material.senderAvatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${material.senderNickname}`}
alt={material.senderNickname}
width={40}
height={40}
className="rounded-full"
/>
</Avatar>
<div>
<div className="font-medium">{material.senderNickname}</div>
<div className="text-sm text-gray-500">
{material.time && format(new Date(material.time), 'yyyy-MM-dd HH:mm')}
</div>
</div>
</div>
<Badge variant="outline" className="bg-blue-50">
ID: {material.id}
</Badge>
</div>
<Separator className="my-3" />
{/* 类型分发内容渲染 */}
{renderMaterialByType(material)}
{/* 非图片资源标签 */}
{material.resUrls.length > 0 && !material.resUrls.some(isImageUrl) && (
<div className="flex flex-wrap gap-2 mb-3">
{material.resUrls.map((url, index) => (
{filteredMaterials.map((material) => (
<div key={material.id} className="flex items-center justify-between bg-white p-3 rounded-lg shadow">
<div className="flex-1">
<p className="text-sm text-gray-600 mb-2">{material.content}</p>
<div className="flex flex-wrap gap-2">
{material.tags.map((tag, index) => (
<Badge key={index} variant="secondary">
<Tag className="h-3 w-3 mr-1" />
{index + 1}
{tag}
</Badge>
))}
</div>
)}
<div className="flex items-center justify-between mt-4">
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
className="px-3 h-8 text-xs"
onClick={() => router.push(`/content/${resolvedParams.id}/materials/edit/${material.id}`)}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="flex items-center space-x-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="px-3 h-8 text-xs">
<Button variant="outline" size="sm" onClick={() => handleAIAnalysis(material)}>
<BarChart className="h-4 w-4 mr-1" />
AI分析
</Button>
@@ -468,62 +165,20 @@ export default function MaterialsPage({ params }: { params: Promise<{ id: string
<DialogTitle>AI </DialogTitle>
</DialogHeader>
<div className="mt-4">
<p>...</p>
</div>
</DialogContent>
</Dialog>
</div>
<Dialog open={deleteDialogOpen === material.id} onOpenChange={(open) => setDeleteDialogOpen(open ? material.id : null)}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm" className="px-3 h-8 text-xs">
<Trash2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="mt-4 mb-4 text-sm text-gray-700"></div>
<div className="flex justify-end space-x-2">
<Button variant="outline" size="sm" onClick={() => setDeleteDialogOpen(null)}></Button>
<Button variant="destructive" size="sm" onClick={() => handleDelete(material.id)}></Button>
<p>{selectedMaterial?.aiAnalysis || "正在分析中..."}</p>
</div>
</DialogContent>
</Dialog>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</Card>
))
)}
</div>
)}
{!isLoading && total > limit && (
<div className="flex justify-center mt-6">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => setPage(prev => Math.max(prev - 1, 1))}
className="mx-1"
>
</Button>
<span className="mx-4 py-2 text-sm text-gray-500">
{page} {Math.ceil(total / limit)}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= Math.ceil(total / limit)}
onClick={() => setPage(prev => prev + 1)}
className="mx-1"
>
</Button>
))}
</div>
</div>
)}
</Card>
</div>
</div>
)
}

View File

@@ -287,4 +287,3 @@ export default function ContentLibraryPage({ params }: { params: { id: string }
</div>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

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