feat: 本次提交更新内容如下
场景获客列表搞定
This commit is contained in:
@@ -51,4 +51,3 @@ export function AIRewriteModal({ isOpen, onClose, originalContent }: AIRewriteMo
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
22
Cunkebao/app/components/AdaptiveLayout.tsx
Normal file
22
Cunkebao/app/components/AdaptiveLayout.tsx
Normal 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 }
|
||||
80
Cunkebao/app/components/ApiTester.tsx
Normal file
80
Cunkebao/app/components/ApiTester.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,4 +29,3 @@ export function BindDouyinQRCode() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -82,4 +82,3 @@ export function TrendChart({ data, dataKey = "value", height = 300 }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,4 +37,3 @@ export function ChatMessage({ content, isUser, timestamp, avatar }: ChatMessageP
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -103,4 +103,3 @@ export function ContentSelector({ onPrev, onFinish }) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -120,4 +120,3 @@ export function DeviceSelector({ onNext, onPrev }) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -98,4 +98,3 @@ export function TaskSetup({ onNext, onPrev, step }: TaskSetupProps) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -44,4 +44,3 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
|
||||
|
||||
@@ -147,4 +147,3 @@ export function FileUploader({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
68
Cunkebao/app/components/LoginErrorHandler.tsx
Normal file
68
Cunkebao/app/components/LoginErrorHandler.tsx
Normal 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
|
||||
}
|
||||
6
Cunkebao/app/components/SideNav.tsx
Normal file
6
Cunkebao/app/components/SideNav.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
"use client"
|
||||
|
||||
// 由于我们不再使用PC自适应,这个组件可以简化为一个空组件
|
||||
export default function SideNav({ className }: { className?: string }) {
|
||||
return null
|
||||
}
|
||||
@@ -69,4 +69,3 @@ export function SpeechToTextProcessor({
|
||||
|
||||
return null // 这是一个功能性组件,不渲染任何UI
|
||||
}
|
||||
|
||||
|
||||
@@ -174,4 +174,3 @@ export function TrafficTeamSettings({ formData = {}, onChange }: TrafficTeamSett
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
63
Cunkebao/app/components/VideoTutorialButton.tsx
Normal file
63
Cunkebao/app/components/VideoTutorialButton.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Play, Video } from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { getPageTutorials } from "@/lib/tutorials"
|
||||
|
||||
export function VideoTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const tutorials = getPageTutorials(pathname)
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
className="fixed bottom-20 right-4 h-12 w-12 rounded-full shadow-lg bg-white hover:bg-gray-50 border z-50"
|
||||
onClick={handleOpenDialog}
|
||||
>
|
||||
<Video className="h-5 w-5 text-gray-600" />
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-[640px] p-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="p-4 border-b">视频教程</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-4">
|
||||
{tutorials.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{tutorials.map((tutorial) => (
|
||||
<div key={tutorial.id} className="flex items-center space-x-4">
|
||||
<div className="w-24 h-16 bg-gray-200 rounded-lg relative overflow-hidden">
|
||||
<img
|
||||
src={tutorial.thumbnailUrl || "/placeholder.svg"}
|
||||
alt={tutorial.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Play className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{tutorial.title}</h3>
|
||||
<p className="text-sm text-gray-500">{tutorial.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">暂无该页面的教程视频</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -60,4 +60,3 @@ export function VoiceRecognition({ onResult, onStop }: VoiceRecognitionProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,4 +96,3 @@ export function AcquisitionPlanChart({ data }: AcquisitionPlanChartProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,4 +52,3 @@ export function DailyAcquisitionChart({ data, height = 200 }: DailyAcquisitionCh
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -72,4 +72,3 @@ export function DeviceTreeChart() {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -94,4 +94,3 @@ export function NewAcquisitionPlanForm({ onSubmit, onCancel }: NewAcquisitionPla
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
242
Cunkebao/app/components/common/AddDeviceDialog.tsx
Normal file
242
Cunkebao/app/components/common/AddDeviceDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
Cunkebao/app/components/common/CardGrid.tsx
Normal file
155
Cunkebao/app/components/common/CardGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
Cunkebao/app/components/common/Charts.tsx
Normal file
242
Cunkebao/app/components/common/Charts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
287
Cunkebao/app/components/common/ContentSelector.tsx
Normal file
287
Cunkebao/app/components/common/ContentSelector.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
363
Cunkebao/app/components/common/DataTable.tsx
Normal file
363
Cunkebao/app/components/common/DataTable.tsx
Normal 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
|
||||
320
Cunkebao/app/components/common/DeviceFilter.tsx
Normal file
320
Cunkebao/app/components/common/DeviceFilter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
537
Cunkebao/app/components/common/DeviceSelector.tsx
Normal file
537
Cunkebao/app/components/common/DeviceSelector.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
373
Cunkebao/app/components/common/FileUploader.tsx
Normal file
373
Cunkebao/app/components/common/FileUploader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
Cunkebao/app/components/common/FormLayout.tsx
Normal file
207
Cunkebao/app/components/common/FormLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
Cunkebao/app/components/common/LazyLoad.tsx
Normal file
137
Cunkebao/app/components/common/LazyLoad.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
Cunkebao/app/components/common/NotificationSystem.tsx
Normal file
182
Cunkebao/app/components/common/NotificationSystem.tsx
Normal 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 }),
|
||||
}
|
||||
}
|
||||
59
Cunkebao/app/components/common/PageHeader.tsx
Normal file
59
Cunkebao/app/components/common/PageHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
Cunkebao/app/components/common/SearchFilter.tsx
Normal file
169
Cunkebao/app/components/common/SearchFilter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
Cunkebao/app/components/common/StatCard.tsx
Normal file
76
Cunkebao/app/components/common/StatCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
Cunkebao/app/components/common/TagManager.tsx
Normal file
193
Cunkebao/app/components/common/TagManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
Cunkebao/app/components/common/VirtualizedList.tsx
Normal file
110
Cunkebao/app/components/common/VirtualizedList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
Cunkebao/app/components/common/Wizard.tsx
Normal file
243
Cunkebao/app/components/common/Wizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,4 +54,3 @@ const AccordionContent = React.forwardRef<
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
|
||||
|
||||
@@ -38,4 +38,3 @@ const AvatarFallback = React.forwardRef<
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -57,4 +57,3 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
|
||||
@@ -52,4 +52,3 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
|
||||
|
||||
@@ -41,4 +41,3 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
|
||||
69
Cunkebao/app/components/ui/chart.tsx
Normal file
69
Cunkebao/app/components/ui/chart.tsx
Normal 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 }
|
||||
@@ -26,4 +26,3 @@ const Checkbox = React.forwardRef<
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
|
||||
|
||||
@@ -9,4 +9,3 @@ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
|
||||
@@ -95,4 +95,3 @@ export {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
||||
|
||||
@@ -179,4 +179,3 @@ export {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,3 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
||||
|
||||
@@ -17,4 +17,3 @@ const Label = React.forwardRef<
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
||||
|
||||
@@ -79,4 +79,3 @@ export {
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,3 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger
|
||||
|
||||
|
||||
@@ -32,4 +32,3 @@ export function PreviewDialog({ children, title = "预览效果" }: PreviewDialo
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,4 +23,3 @@ const Progress = React.forwardRef<
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
|
||||
|
||||
@@ -36,4 +36,3 @@ const RadioGroupItem = React.forwardRef<
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
|
||||
|
||||
@@ -38,4 +38,3 @@ const ScrollBar = React.forwardRef<
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
||||
|
||||
@@ -101,4 +101,3 @@ const SelectSeparator = React.forwardRef<
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator }
|
||||
|
||||
|
||||
@@ -6,4 +6,3 @@ function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
||||
|
||||
157
Cunkebao/app/components/ui/step-indicator.tsx
Normal file
157
Cunkebao/app/components/ui/step-indicator.tsx
Normal 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
|
||||
@@ -65,4 +65,3 @@ export function Step({ title, description }: StepProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,4 +27,3 @@ const Switch = React.forwardRef<
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
||||
|
||||
@@ -70,4 +70,3 @@ const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttribu
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
|
||||
|
||||
@@ -53,4 +53,3 @@ const TabsContent = React.forwardRef<
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
||||
|
||||
@@ -19,4 +19,3 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
|
||||
|
||||
@@ -109,4 +109,3 @@ export {
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -184,4 +184,3 @@ function useToast() {
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user