This commit is contained in:
wong
2025-04-25 09:29:18 +08:00
11 changed files with 1768 additions and 425 deletions

View File

@@ -21,46 +21,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
// 保留原始示例数据,作为加载失败时的备用数据
const adminsData = [
{
id: "1",
account: "admin_zhang",
username: "张管理",
role: "超级管理员",
permissions: ["项目管理", "客户池", "管理员权限"],
createdAt: "2023-05-01",
lastLogin: "2023-06-28 09:15",
},
{
id: "2",
account: "admin_li",
username: "李管理",
role: "项目管理员",
permissions: ["项目管理", "客户池"],
createdAt: "2023-05-10",
lastLogin: "2023-06-27 14:30",
},
{
id: "3",
account: "admin_wang",
username: "王管理",
role: "客户管理员",
permissions: ["客户池"],
createdAt: "2023-05-15",
lastLogin: "2023-06-28 11:45",
},
{
id: "4",
account: "admin_zhao",
username: "赵管理",
role: "项目管理员",
permissions: ["项目管理"],
createdAt: "2023-05-20",
lastLogin: "2023-06-26 16:20",
},
]
export default function AdminsPage() {
const [searchTerm, setSearchTerm] = useState("")
const [isLoading, setIsLoading] = useState(true)
@@ -95,14 +55,8 @@ export default function AdminsPage() {
description: response.msg || "请稍后重试",
variant: "destructive",
})
// 加载失败时显示示例数据
setAdministrators(adminsData.map(admin => ({
...admin,
id: Number(admin.id),
name: admin.username,
status: 1
})))
setTotalCount(adminsData.length)
setAdministrators([])
setTotalCount(0)
}
} catch (error) {
console.error("获取管理员列表出错:", error)
@@ -111,14 +65,8 @@ export default function AdminsPage() {
description: "请检查网络连接后重试",
variant: "destructive",
})
// 加载失败时显示示例数据
setAdministrators(adminsData.map(admin => ({
...admin,
id: Number(admin.id),
name: admin.username,
status: 1
})))
setTotalCount(adminsData.length)
setAdministrators([])
setTotalCount(0)
} finally {
setIsLoading(false)
}

View File

@@ -20,94 +20,6 @@ import { getTrafficPoolList } from "@/lib/traffic-pool-api"
import { Customer } from "@/lib/traffic-pool-api"
import { PaginationControls } from "@/components/ui/pagination-controls"
// Sample customer data
const customersData = [
{
id: "1",
name: "张三",
avatar: "/placeholder.svg?height=40&width=40",
wechatId: "zhangsan123",
gender: "男",
region: "北京",
source: "微信搜索",
tags: ["潜在客户", "高消费"],
projectName: "电商平台项目",
addedDate: "2023-06-10",
},
{
id: "2",
name: "李四",
avatar: "/placeholder.svg?height=40&width=40",
wechatId: "lisi456",
gender: "男",
region: "上海",
source: "朋友推荐",
tags: ["活跃用户"],
projectName: "社交媒体营销",
addedDate: "2023-06-12",
},
{
id: "3",
name: "王五",
avatar: "/placeholder.svg?height=40&width=40",
wechatId: "wangwu789",
gender: "男",
region: "广州",
source: "广告点击",
tags: ["新用户"],
projectName: "企业官网推广",
addedDate: "2023-06-15",
},
{
id: "4",
name: "赵六",
avatar: "/placeholder.svg?height=40&width=40",
wechatId: "zhaoliu321",
gender: "男",
region: "深圳",
source: "线下活动",
tags: ["高消费", "忠诚客户"],
projectName: "教育平台项目",
addedDate: "2023-06-18",
},
{
id: "5",
name: "钱七",
avatar: "/placeholder.svg?height=40&width=40",
wechatId: "qianqi654",
gender: "女",
region: "成都",
source: "微信群",
tags: ["潜在客户"],
projectName: "金融服务推广",
addedDate: "2023-06-20",
},
{
id: "6",
name: "孙八",
avatar: "/placeholder.svg?height=40&width=40",
wechatId: "sunba987",
gender: "女",
region: "武汉",
source: "微信搜索",
tags: ["活跃用户", "高消费"],
projectName: "电商平台项目",
addedDate: "2023-06-22",
},
{
id: "7",
name: "周九",
avatar: "/placeholder.svg?height=40&width=40",
wechatId: "zhoujiu135",
gender: "女",
region: "杭州",
source: "朋友推荐",
tags: ["新用户"],
projectName: "社交媒体营销",
addedDate: "2023-06-25",
},
]
export default function CustomersPage() {
const [searchTerm, setSearchTerm] = useState("")
const [selectedRegion, setSelectedRegion] = useState("")
@@ -162,25 +74,6 @@ export default function CustomersPage() {
setCurrentPage(1); // Reset to first page when page size changes
};
// Filter customers based on search and filters (兼容示例数据)
const filteredCustomers = customersData.filter((customer) => {
const matchesSearch =
customer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
customer.wechatId.toLowerCase().includes(searchTerm.toLowerCase())
const matchesRegion = selectedRegion ? customer.region === selectedRegion : true
const matchesGender = selectedGender ? customer.gender === selectedGender : true
const matchesSource = selectedSource ? customer.source === selectedSource : true
const matchesProject = selectedProject ? customer.projectName === selectedProject : true
return matchesSearch && matchesRegion && matchesGender && matchesSource && matchesProject
})
// Get unique values for filters
const regions = [...new Set(customersData.map((c) => c.region))]
const sources = [...new Set(customersData.map((c) => c.source))]
const projects = [...new Set(customersData.map((c) => c.projectName))]
return (
<div className="space-y-6">
<div className="flex justify-between">
@@ -217,11 +110,7 @@ export default function CustomersPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{regions.map((region) => (
<SelectItem key={region} value={region}>
{region}
</SelectItem>
))}
{/* 从API获取到的regions数据应在此处映射 */}
</SelectContent>
</Select>
</div>
@@ -248,28 +137,20 @@ export default function CustomersPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{sources.map((source) => (
<SelectItem key={source} value={source}>
{source}
</SelectItem>
))}
{/* 从API获取到的sources数据应在此处映射 */}
</SelectContent>
</Select>
</div>
<DropdownMenuSeparator />
<div className="p-2">
<p className="mb-2 text-sm font-medium"></p>
<p className="mb-2 text-sm font-medium"></p>
<Select value={selectedProject} onValueChange={setSelectedProject}>
<SelectTrigger>
<SelectValue placeholder="所有项目" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{projects.map((project) => (
<SelectItem key={project} value={project}>
{project}
</SelectItem>
))}
{/* 从API获取到的projects数据应在此处映射 */}
</SelectContent>
</Select>
</div>
@@ -277,16 +158,16 @@ export default function CustomersPage() {
</DropdownMenu>
</div>
<div className="rounded-md border">
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
@@ -294,45 +175,45 @@ export default function CustomersPage() {
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
...
</TableCell>
<TableCell colSpan={8} className="text-center py-4">...</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-red-600">
{error}
</TableCell>
<TableCell colSpan={8} className="text-center py-4 text-red-500">{error}</TableCell>
</TableRow>
) : customers.length > 0 ? (
) : customers.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-4"></TableCell>
</TableRow>
) : (
customers.map((customer) => (
<TableRow key={customer.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage
src={customer.avatar || "/placeholder.svg?height=40&width=40"}
alt={customer.nickname || "未知"}
onError={(e) => {
// 图片加载失败时使用默认图片
const target = e.target as HTMLImageElement;
target.src = "/placeholder.svg?height=40&width=40";
}}
/>
<AvatarFallback>{(customer.nickname || "未知").slice(0, 2)}</AvatarFallback>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={customer.avatar || "/placeholder.svg?height=40&width=40"} alt={customer.nickname} />
<AvatarFallback>{customer.nickname ? customer.nickname.substring(0, 1) : "?"}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{customer.nickname || "未知"}</div>
<div className="text-xs text-muted-foreground">{customer.gender}</div>
<div className="font-medium">{customer.nickname}</div>
<div className="text-sm text-muted-foreground">{customer.wechatId}</div>
</div>
</div>
</TableCell>
<TableCell>{customer.wechatId}</TableCell>
<TableCell>{customer.gender}</TableCell>
<TableCell>{customer.region}</TableCell>
<TableCell>{customer.source}</TableCell>
<TableCell>{customer.projectName}</TableCell>
<TableCell>{customer.addTime}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{customer.tags && customer.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</TableCell>
<TableCell>{customer.companyName}</TableCell>
<TableCell>{customer.createTime}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -342,39 +223,41 @@ export default function CustomersPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/customers/${customer.id}`}>
<Eye className="mr-2 h-4 w-4" />
<DropdownMenuItem>
<Link href={`/dashboard/customers/${customer.id}`} className="flex items-center w-full">
<Eye className="mr-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<UserPlus className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 使用新的分页组件 */}
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
totalItems={totalItems}
onPageChange={setCurrentPage} // 直接传递setCurrentPage
onPageSizeChange={handlePageSizeChange}
/>
{/* 分页控制 */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{totalItems} {currentPage} {totalPages}
</div>
<div className="flex items-center space-x-2">
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
onPageSizeChange={handlePageSizeChange}
pageSize={pageSize}
totalItems={totalItems}
/>
</div>
</div>
</div>
</div>
)

View File

@@ -1,14 +1,41 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { useState, useEffect, createContext, useContext } from "react"
import { useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Menu, X } from "lucide-react"
import { Sidebar } from "@/components/layout/sidebar"
import { Header } from "@/components/layout/header"
import { getAdminInfo } from "@/lib/utils"
// 全局标签页管理上下文
interface TabData {
id: string
label: string
path: string
closable: boolean
}
interface TabContextType {
tabs: TabData[]
activeTab: string
addTab: (tab: Omit<TabData, "id">) => string
closeTab: (tabId: string) => void
setActiveTab: (tabId: string) => void
findTabByPath: (path: string) => TabData | undefined
}
const TabContext = createContext<TabContextType | undefined>(undefined);
export function useTabContext() {
const context = useContext(TabContext);
if (!context) {
throw new Error("useTabContext must be used within a TabProvider");
}
return context;
}
export default function DashboardLayout({
children,
}: {
@@ -16,6 +43,122 @@ export default function DashboardLayout({
}) {
const [sidebarOpen, setSidebarOpen] = useState(true)
const router = useRouter()
const pathname = usePathname()
// 标签页状态管理
const [tabs, setTabs] = useState<TabData[]>([
{ id: "dashboard", label: "仪表盘", path: "/dashboard", closable: false }
])
const [activeTab, setActiveTab] = useState("dashboard")
// 添加标签页
const addTab = (tabData: Omit<TabData, "id">) => {
const id = `tab-${Date.now()}`
const newTab = { id, ...tabData }
// 检查是否已存在类似标签(基于路径)
const existingTab = findTabByPath(tabData.path)
if (existingTab) {
// 如果已存在,激活它
setActiveTab(existingTab.id)
// 确保导航到该路径(即使路径匹配也强制导航)
router.push(tabData.path)
return existingTab.id
} else {
// 如果不存在,添加新标签
setTabs(prev => [...prev, newTab])
setActiveTab(id)
// 确保导航到该路径(即使路径匹配也强制导航)
router.push(tabData.path)
return id
}
}
// 设置激活标签(并导航到对应路径)
const setActiveTabAndNavigate = (tabId: string) => {
setActiveTab(tabId)
// 找到标签对应的路径并导航
const tab = tabs.find(tab => tab.id === tabId)
if (tab) {
router.push(tab.path)
}
}
// 关闭标签页
const closeTab = (tabId: string) => {
// 找到要关闭的标签的索引
const tabIndex = tabs.findIndex(tab => tab.id === tabId)
// 如果标签不存在或者是不可关闭的标签,直接返回
if (tabIndex === -1 || !tabs[tabIndex].closable) return
// 创建新的标签数组,移除要关闭的标签
const newTabs = tabs.filter(tab => tab.id !== tabId)
setTabs(newTabs)
// 如果关闭的是当前活动标签,需要激活另一个标签
if (activeTab === tabId) {
// 优先激活关闭标签左侧的标签,如果没有则激活默认的仪表盘标签
const newActiveTab = newTabs[tabIndex - 1]?.id || "dashboard"
setActiveTab(newActiveTab)
// 路由跳转到新激活的标签对应的路径
const newActivePath = newTabs.find(tab => tab.id === newActiveTab)?.path || "/dashboard"
router.push(newActivePath)
}
}
// 根据路径查找标签
const findTabByPath = (path: string): TabData | undefined => {
return tabs.find(tab => tab.path === path)
}
// 监听路径变化,自动添加标签
useEffect(() => {
// 不触发/dashboard路径已有默认标签
if (pathname === "/dashboard") {
setActiveTab("dashboard")
return
}
// 检查当前路径是否已有对应标签
const existingTab = findTabByPath(pathname)
if (existingTab) {
// 如果存在,激活它
setActiveTab(existingTab.id)
} else {
// 如果不存在,添加新标签
// 生成标签标题
let label = "新标签"
// 根据路径生成更友好的标签名
if (pathname.includes("/projects")) {
if (pathname === "/dashboard/projects") {
label = "项目列表"
} else if (pathname.includes("/new")) {
label = "新建项目"
} else if (pathname.includes("/edit")) {
label = "编辑项目"
} else {
label = "项目详情"
}
} else if (pathname.includes("/admins")) {
label = "管理员"
} else if (pathname.includes("/customers")) {
label = "客户池"
} else if (pathname.includes("/settings")) {
label = "系统设置"
}
addTab({
label,
path: pathname,
closable: true
})
}
}, [pathname])
// 认证检查
useEffect(() => {
@@ -31,29 +174,72 @@ export default function DashboardLayout({
}, [router])
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile sidebar toggle */}
<div className="fixed top-4 left-4 z-50 md:hidden">
<Button variant="outline" size="icon" onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
</div>
<TabContext.Provider value={{
tabs,
activeTab,
addTab,
closeTab,
setActiveTab: setActiveTabAndNavigate,
findTabByPath
}}>
<div className="flex h-screen overflow-hidden bg-background">
{/* Mobile sidebar toggle */}
<div className="fixed top-4 left-4 z-50 md:hidden">
<Button variant="outline" size="icon" onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
</div>
{/* Sidebar */}
<div
className={`bg-background border-r w-64 flex-shrink-0 transition-all duration-300 ease-in-out ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
} md:translate-x-0 fixed md:relative z-40 h-full`}
>
<Sidebar />
</div>
{/* Sidebar */}
<div
className={`bg-background flex-shrink-0 transition-all duration-300 ease-in-out ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
} md:translate-x-0 fixed md:relative z-40 h-full`}
>
<Sidebar />
</div>
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
{/* 标签栏 */}
<div className="border-b border-border">
<div className="flex overflow-x-auto">
{tabs.map(tab => (
<div
key={tab.id}
className={`flex items-center px-4 py-2 border-r border-border cursor-pointer ${
activeTab === tab.id ? "bg-muted font-medium" : "hover:bg-muted/50"
}`}
onClick={() => {
setActiveTabAndNavigate(tab.id)
}}
>
<span className="truncate max-w-[200px]">{tab.label}</span>
{tab.closable && (
<button
className="ml-2 p-1 rounded-full hover:bg-muted-foreground/20"
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{/* 内容区域 */}
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
</div>
</TabContext.Provider>
)
}

View File

@@ -1,16 +1,24 @@
"use client"
import type React from "react"
import { useState, useEffect, use } from "react"
import * as React from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Plus, Trash } from "lucide-react"
import { ArrowLeft, Plus, Trash, X } from "lucide-react"
import Link from "next/link"
import { toast, Toaster } from "sonner"
import Image from "next/image"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
// 为React.use添加类型声明
declare module 'react' {
function use<T>(promise: Promise<T>): T;
function use<T>(value: T): T;
}
interface Device {
id: number
@@ -28,6 +36,10 @@ interface Project {
deviceCount: number
friendCount: number
userCount: number
username: string
status: number
s2_accountId?: number
devices?: Device[]
}
export default function EditProjectPage({ params }: { params: { id: string } }) {
@@ -37,7 +49,10 @@ export default function EditProjectPage({ params }: { params: { id: string } })
const [project, setProject] = useState<Project | null>(null)
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const { id } = use(params)
const [isModalOpen, setIsModalOpen] = useState(false)
const [qrCodeData, setQrCodeData] = useState("")
const [isAddingDevice, setIsAddingDevice] = useState(false)
const { id } = React.use(params)
useEffect(() => {
const fetchProject = async () => {
@@ -82,8 +97,8 @@ export default function EditProjectPage({ params }: { params: { id: string } })
account: project?.account,
memo: project?.memo,
phone: project?.phone,
username: nickname,
status: parseInt(status),
username: project?.username,
status: project?.status,
...(password && { password })
}),
})
@@ -103,8 +118,42 @@ export default function EditProjectPage({ params }: { params: { id: string } })
}
}
const handleAddDevice = () => {
router.push(`/dashboard/projects/${id}/devices/new`)
const handleAddDevice = async () => {
if (!project?.s2_accountId) {
toast.error("无法添加设备未找到账号ID")
return
}
setIsAddingDevice(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/api/device/add`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
accountId: project.s2_accountId
}),
})
const data = await response.json()
if (data.code === 200 && data.data?.qrCode) {
setQrCodeData(data.data.qrCode)
setIsModalOpen(true)
} else {
toast.error(data.msg || "获取设备二维码失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsAddingDevice(false)
}
}
const closeModal = () => {
setIsModalOpen(false)
setQrCodeData("")
}
if (isLoading) {
@@ -215,7 +264,7 @@ export default function EditProjectPage({ params }: { params: { id: string } })
<div className="space-y-2">
<Label></Label>
<div className="space-y-3">
{project?.devices.length > 0 && project.devices.map((device) => (
{project && project.devices && project.devices.length > 0 && project.devices.map((device) => (
<div key={device.id} className="flex items-center gap-2">
<Input
value={device.memo}
@@ -223,8 +272,18 @@ export default function EditProjectPage({ params }: { params: { id: string } })
/>
</div>
))}
<Button type="button" variant="outline" onClick={handleAddDevice} className="flex items-center gap-1">
<Plus className="h-4 w-4" />
<Button
type="button"
variant="outline"
onClick={handleAddDevice}
className="flex items-center gap-1"
disabled={isAddingDevice}
>
{isAddingDevice ? "添加中..." : (
<>
<Plus className="h-4 w-4" />
</>
)}
</Button>
</div>
</div>
@@ -245,11 +304,43 @@ export default function EditProjectPage({ params }: { params: { id: string } })
<Link href="/dashboard/projects"></Link>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "保存中..." : "保存修改"}
{isSubmitting ? "提交中..." : "保存修改"}
</Button>
</CardFooter>
</Card>
</form>
{/* 使用Dialog组件替代自定义模态框 */}
<Dialog open={isModalOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
使
</DialogDescription>
</DialogHeader>
<div className="flex justify-center p-6">
<div className="border p-4 rounded-lg">
{qrCodeData ? (
<img
src={qrCodeData}
alt="设备二维码"
className="w-64 h-64 object-contain"
/>
) : (
<div className="w-64 h-64 flex items-center justify-center bg-muted">
<p className="text-muted-foreground">...</p>
</div>
)}
</div>
</div>
<DialogFooter className="sm:justify-center">
<Button type="button" onClick={closeModal}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,5 +1,6 @@
"use client"
import React from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
@@ -11,6 +12,7 @@ import { ArrowLeft, Edit } from "lucide-react"
import { toast } from "sonner"
import { use } from "react"
import Image from "next/image"
import { Badge } from "@/components/ui/badge"
interface ProjectProfile {
id: number
@@ -50,7 +52,14 @@ interface SubUser {
typeId: number
}
export default function ProjectDetailPage({ params }: { params: { id: string } }) {
interface ProjectDetailPageProps {
params: {
id: string
}
}
export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
const { id } = use(params)
const router = useRouter()
const [isLoading, setIsLoading] = useState(true)
const [isDevicesLoading, setIsDevicesLoading] = useState(false)
@@ -59,7 +68,6 @@ export default function ProjectDetailPage({ params }: { params: { id: string } }
const [devices, setDevices] = useState<Device[]>([])
const [subUsers, setSubUsers] = useState<SubUser[]>([])
const [activeTab, setActiveTab] = useState("overview")
const { id } = use(params)
useEffect(() => {
const fetchProjectProfile = async () => {
@@ -112,15 +120,7 @@ export default function ProjectDetailPage({ params }: { params: { id: string } }
if (activeTab === "accounts") {
setIsSubUsersLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/subusers`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
companyId: parseInt(id)
})
})
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/subusers?companyId=${id}`)
const data = await response.json()
if (data.code === 200) {
@@ -243,32 +243,53 @@ export default function ProjectDetailPage({ params }: { params: { id: string } }
) : devices.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground"></div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IMEI</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.id}>
<TableCell className="font-medium">{device.memo}</TableCell>
<TableCell>{device.model}</TableCell>
<TableCell>{device.brand}</TableCell>
<TableCell>{device.imei}</TableCell>
<TableCell>{device.alive === 1 ? "在线" : "离线"}</TableCell>
<TableCell>{device.wAlive === 1 ? "在线" : device.wAlive === 0 ? "离线" : "未登录微信"}</TableCell>
<TableCell className="text-right">{device.friendCount || 0}</TableCell>
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IMEI</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.id}>
<TableCell className="font-medium">{device.memo}</TableCell>
<TableCell>{device.model}</TableCell>
<TableCell>{device.brand}</TableCell>
<TableCell>{device.imei}</TableCell>
<TableCell>
<Badge variant={device.alive === 1 ? "success" : "destructive"}>
{device.alive === 1 ? "在线" : "离线"}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
device.wAlive === 1
? "success"
: device.wAlive === 0
? "destructive"
: "secondary"
}
>
{device.wAlive === 1 ? "已登录" : device.wAlive === 0 ? "已登出" : "未登录微信"}
</Badge>
</TableCell>
<TableCell className="text-right">{device.friendCount || 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 text-sm text-muted-foreground">
{devices.length}
</div>
</>
)}
</CardContent>
</Card>
@@ -286,42 +307,51 @@ export default function ProjectDetailPage({ params }: { params: { id: string } }
) : subUsers.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground"></div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Image
src={user.avatar}
alt={user.username}
width={32}
height={32}
className="rounded-full"
/>
</TableCell>
<TableCell>{user.id}</TableCell>
<TableCell>{user.account}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>{user.status === 1 ? "登录" : "禁用"}</TableCell>
<TableCell>{user.typeId === 1 ? "操盘手" : "门店顾问"}</TableCell>
<TableCell className="text-right">{user.createTime}</TableCell>
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{subUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Image
src={user.avatar}
alt={user.username}
width={32}
height={32}
className="rounded-full"
/>
</TableCell>
<TableCell>{user.id}</TableCell>
<TableCell>{user.account}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>
<Badge variant={user.status === 1 ? "success" : "destructive"}>
{user.status === 1 ? "启用" : "禁用"}
</Badge>
</TableCell>
<TableCell>{user.typeId === 1 ? "操盘手" : "门店顾问"}</TableCell>
<TableCell className="text-right">{user.createTime}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 text-sm text-muted-foreground">
{subUsers.length}
</div>
</>
)}
</CardContent>
</Card>

View File

@@ -1,7 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { useSearchParams, useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { PaginationControls } from "@/components/ui/pagination-controls"
import { useTabContext } from "@/app/dashboard/layout"
interface Project {
id: number
@@ -32,17 +33,41 @@ interface Project {
}
export default function ProjectsPage() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const { addTab } = useTabContext()
const [searchTerm, setSearchTerm] = useState("")
const [projects, setProjects] = useState<Project[]>([])
const [isLoading, setIsLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [currentPage, setCurrentPage] = useState(parseInt(searchParams.get("page") || "1"))
const [totalPages, setTotalPages] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [pageSize, setPageSize] = useState(parseInt(searchParams.get("pageSize") || "10"))
const [totalItems, setTotalItems] = useState(0)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deletingProjectId, setDeletingProjectId] = useState<number | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// 从URL更新状态
useEffect(() => {
const page = parseInt(searchParams.get("page") || "1")
const size = parseInt(searchParams.get("pageSize") || "10")
setCurrentPage(page)
setPageSize(size)
}, [searchParams])
// 更新URL查询参数
const updateUrlParams = (page: number, size: number) => {
const params = new URLSearchParams()
params.set("page", page.toString())
params.set("pageSize", size.toString())
if (searchTerm) {
params.set("search", searchTerm)
}
router.replace(`${pathname}?${params.toString()}`)
}
// 获取项目列表
useEffect(() => {
const fetchProjects = async () => {
@@ -72,12 +97,21 @@ export default function ProjectsPage() {
}
fetchProjects()
}, [currentPage, pageSize])
// 更新URL参数
updateUrlParams(currentPage, pageSize)
}, [currentPage, pageSize, pathname])
// 处理页面大小变化
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize)
setCurrentPage(1)
updateUrlParams(1, newSize)
}
// 处理页面变化
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage)
updateUrlParams(newPage, pageSize)
}
const handleDeleteClick = (projectId: number) => {
@@ -104,6 +138,7 @@ export default function ProjectsPage() {
if (data.code === 200) {
toast.success("删除成功")
// Fetch projects again after delete
const fetchProjects = async () => {
setIsLoading(true)
@@ -136,14 +171,39 @@ export default function ProjectsPage() {
}
}
// 打开项目详情
const handleViewProject = (project: Project) => {
addTab({
label: `项目 #${project.id} - 详情`,
path: `/dashboard/projects/${project.id}`,
closable: true
});
}
// 打开编辑项目
const handleEditProject = (project: Project) => {
addTab({
label: `项目 #${project.id} - 编辑`,
path: `/dashboard/projects/${project.id}/edit`,
closable: true
});
}
// 打开新建项目
const handleNewProject = () => {
addTab({
label: "新建项目",
path: "/dashboard/projects/new",
closable: true
});
}
return (
<div className="space-y-6">
<div className="flex justify-between">
<h1 className="text-2xl font-bold"></h1>
<Button asChild>
<Link href="/dashboard/projects/new">
<Plus className="mr-2 h-4 w-4" />
</Link>
<Button onClick={handleNewProject}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
@@ -175,7 +235,7 @@ export default function ProjectsPage() {
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
<TableCell colSpan={6} className="h-24 text-center">
...
</TableCell>
</TableRow>
@@ -200,15 +260,11 @@ export default function ProjectsPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
</Link>
<DropdownMenuItem onClick={() => handleViewProject(project)}>
<Eye className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/dashboard/projects/${project.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
</Link>
<DropdownMenuItem onClick={() => handleEditProject(project)}>
<Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
@@ -223,7 +279,7 @@ export default function ProjectsPage() {
))
) : (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
<TableCell colSpan={6} className="h-24 text-center">
</TableCell>
</TableRow>
@@ -237,7 +293,7 @@ export default function ProjectsPage() {
totalPages={totalPages}
pageSize={pageSize}
totalItems={totalItems}
onPageChange={setCurrentPage}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>

5
SuperAdmin/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function Home() {
redirect("/dashboard")
}

View File

@@ -1,37 +1,178 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useState, useEffect } from "react"
import { usePathname } from "next/navigation"
import { getMenus, type MenuItem } from "@/lib/menu-api"
import * as LucideIcons from "lucide-react"
import { ChevronDown, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { useTabContext } from "@/app/dashboard/layout"
import { getMenus } from "@/lib/menu-api"
// 适配后端返回的菜单项格式
interface MenuItem {
id: number
parentId?: number | null
parent_id?: number // 后端返回的字段
name?: string
title?: string // 后端返回的字段
path: string
icon?: string
order?: number
sort?: number // 后端返回的字段
status?: number
children?: MenuItem[]
}
export function Sidebar() {
const pathname = usePathname()
const [menus, setMenus] = useState<MenuItem[]>([])
const [loading, setLoading] = useState(true)
// 使用Set来存储已展开的菜单ID
const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set())
const [collapsed, setCollapsed] = useState(false) // 添加折叠状态
const { addTab } = useTabContext()
// 字段适配:将后端返回的菜单数据格式转换为前端需要的格式
const adaptMenuItem = (item: MenuItem): MenuItem => {
return {
id: item.id,
parentId: item.parent_id || null,
name: item.title || item.name,
path: item.path,
icon: item.icon,
order: item.sort || item.order || 0,
children: item.children ? item.children.map(adaptMenuItem) : []
};
};
// 切换折叠状态
const toggleCollapsed = () => {
setCollapsed(prev => !prev);
};
// 获取菜单数据
useEffect(() => {
const fetchMenus = async () => {
setLoading(true)
try {
const data = await getMenus()
setMenus(data || [])
// 从后端API获取菜单数据
const menuData = await getMenus(true);
// 自动展开当前活动菜单的父菜单
autoExpandActiveMenuParent(data || []);
// 适配数据格式
const adaptedMenus = menuData.map(adaptMenuItem);
// 构建菜单树
const menuTree = buildMenuTree(adaptedMenus);
setMenus(menuTree);
// 初始自动展开当前活动菜单的父菜单
autoExpandActiveMenuParent(adaptedMenus);
} catch (error) {
console.error("获取菜单失败:", error)
console.error("获取菜单数据失败:", error);
// 获取失败时使用空菜单
setMenus([]);
} finally {
setLoading(false)
setLoading(false);
}
};
fetchMenus();
}, []); // 仅在组件挂载时执行一次移除pathname依赖
// 监听路径变化以更新菜单展开状态
useEffect(() => {
if (menus.length > 0) {
// 只在菜单数据存在且路径变化时更新展开状态
// 获取当前路径所需展开的所有父菜单ID
const pathMenuItems = menus.reduce((allItems, item) => {
const flattenMenu = (menuItem: MenuItem, items: MenuItem[] = []) => {
items.push(menuItem);
if (menuItem.children && menuItem.children.length > 0) {
menuItem.children.forEach(child => flattenMenu(child, items));
}
return items;
};
return [...allItems, ...flattenMenu(item)];
}, [] as MenuItem[]);
// 保存当前展开状态
setExpandedMenus(prev => {
// 创建新集合,保留所有已展开的菜单
const newExpanded = new Set(prev);
// 将需要展开的菜单添加到集合中
const currentPath = pathname === "/" ? "/dashboard" : pathname;
// 查找当前路径对应的菜单项和所有父菜单
const findActiveMenuParents = (items: MenuItem[], parentIds: number[] = []): number[] => {
for (const item of items) {
// 如果是"#"路径的菜单,检查其子菜单
if (item.path === "#" && item.children && item.children.length > 0) {
const found = findActiveMenuParents(item.children, [...parentIds, item.id]);
if (found.length > 0) {
return [...found, item.id];
}
}
// 检查菜单路径是否匹配当前路径
else if (currentPath === item.path || currentPath.startsWith(item.path + "/")) {
return [...parentIds];
}
// 递归检查子菜单
if (item.children && item.children.length > 0) {
const found = findActiveMenuParents(item.children, [...parentIds, item.id]);
if (found.length > 0) {
return found;
}
}
}
return [];
};
// 获取需要自动展开的菜单ID
const parentsToExpand = findActiveMenuParents(menus);
// 添加到展开集合中
parentsToExpand.forEach(id => newExpanded.add(id));
return newExpanded;
});
}
}, [pathname, menus]);
fetchMenus()
}, [])
// 构建菜单树结构
const buildMenuTree = (items: MenuItem[]) => {
const map = new Map<number, MenuItem>();
const roots: MenuItem[] = [];
// 先创建所有菜单项的映射
items.forEach(item => {
map.set(item.id, { ...item, children: item.children || [] });
});
// 构建树结构
items.forEach(item => {
if (!item.parentId || item.parentId === 0) {
// 根菜单
roots.push(map.get(item.id)!);
} else {
// 子菜单
const parent = map.get(item.parentId);
if (parent && parent.children) {
parent.children.push(map.get(item.id)!);
}
}
});
// 排序
roots.sort((a, b) => (a.order || 0) - (b.order || 0));
roots.forEach(root => {
if (root.children) {
root.children.sort((a, b) => (a.order || 0) - (b.order || 0));
}
});
return roots;
};
// 自动展开当前活动菜单的父菜单
const autoExpandActiveMenuParent = (menuItems: MenuItem[]) => {
@@ -40,10 +181,23 @@ export function Sidebar() {
// 递归查找当前路径匹配的菜单项
const findActiveMenu = (items: MenuItem[], parentIds: number[] = []) => {
for (const item of items) {
const currentPath = pathname === "/" ? "/dashboard" : pathname;
const itemPath = item.path;
// 如果是"#"路径的菜单,跳过路径检查
if (item.path === "#") {
if (item.children && item.children.length > 0) {
const found = findActiveMenu(item.children, [...parentIds, item.id]);
if (found) {
// 将所有父菜单ID添加到展开集合
parentIds.forEach(id => newExpandedMenus.add(id));
newExpandedMenus.add(item.id); // 确保当前菜单也被展开
return true;
}
}
continue;
}
if (currentPath === itemPath || currentPath.startsWith(itemPath + "/")) {
const currentPath = pathname === "/" ? "/dashboard" : pathname;
if (currentPath === item.path || currentPath.startsWith(item.path + "/")) {
// 将所有父菜单ID添加到展开集合
parentIds.forEach(id => newExpandedMenus.add(id));
return true;
@@ -60,11 +214,16 @@ export function Sidebar() {
};
findActiveMenu(menuItems);
// 将新的展开菜单集合设置到状态
setExpandedMenus(newExpandedMenus);
};
// 切换菜单展开状态
const toggleMenu = (menuId: number) => {
const toggleMenu = (menuId: number, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setExpandedMenus(prev => {
const newExpanded = new Set(prev);
if (newExpanded.has(menuId)) {
@@ -83,82 +242,207 @@ export function Sidebar() {
return Icon ? <Icon className="h-4 w-4 mr-2" /> : null;
};
// 递归渲染菜单项
// 渲染菜单项
const renderMenuItem = (item: MenuItem) => {
// 修改子菜单项活动状态判断逻辑
const isMenuPathActive = (menuPath: string, currentPath: string) => {
// 对于精确匹配的情况直接返回true
if (currentPath === menuPath) {
return true;
}
// 特殊处理项目列表路径
if (menuPath === "/dashboard/projects" && currentPath !== "/dashboard/projects") {
// 如果当前路径不是精确匹配项目列表,则项目列表不高亮
return false;
}
// 对于其他情况,保持原来的前缀匹配逻辑
// 但要确保父级路径后有斜杠再做前缀匹配
return currentPath.startsWith(menuPath + "/");
};
const currentPath = pathname === "/" ? "/dashboard" : pathname;
const isActive = isMenuPathActive(item.path, currentPath);
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedMenus.has(item.id);
const isActive = pathname === item.path;
const isChildActive = hasChildren && item.children!.some(child =>
pathname === child.path || pathname.startsWith(child.path + "/")
);
const name = item.name || item.title || "";
// 折叠状态下的菜单项
if (collapsed) {
return (
<li key={item.id} className="relative group">
<div
className={cn(
"flex justify-center items-center py-2 rounded-md transition-colors cursor-pointer",
isActive
? "text-white"
: "text-blue-100 hover:bg-blue-700/30"
)}
title={name}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!hasChildren) {
const tabId = addTab({
label: name,
path: item.path,
closable: item.path !== "/dashboard"
});
}
}}
>
{getLucideIcon(item.icon || "")}
{/* 悬浮提示 */}
{hasChildren ? (
<div className="absolute left-full ml-2 hidden group-hover:block z-50 bg-blue-800 rounded-md shadow-lg py-1 min-w-40">
<div className="font-medium px-3 py-1 border-b border-blue-700">{name}</div>
<ul className="py-1">
{item.children!.map((child) => (
<li key={child.id}>
<a
href={child.path}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const tabId = addTab({
label: child.name || child.title || "",
path: child.path,
closable: true
});
}}
className={cn(
"flex items-center px-3 py-1 transition-colors",
isMenuPathActive(child.path, currentPath)
? "bg-blue-700 text-white font-medium"
: "text-blue-100 hover:bg-blue-700/30"
)}
>
{getLucideIcon(child.icon || "")}
<span>{child.name || child.title}</span>
</a>
</li>
))}
</ul>
</div>
) : (
<div className="absolute left-full ml-2 hidden group-hover:block z-50 bg-blue-800 rounded-md shadow-lg px-3 py-1 whitespace-nowrap">
{name}
</div>
)}
</div>
</li>
);
}
// 展开状态下的菜单项
return (
<li key={item.id}>
{hasChildren ? (
<div className="flex flex-col">
<>
<button
onClick={() => toggleMenu(item.id)}
className={`flex items-center justify-between px-4 py-2 rounded-md text-sm w-full text-left ${
isActive || isChildActive
? "text-white font-semibold"
: "hover:bg-blue-600"
}`}
>
<div className="flex items-center">
{item.icon && getLucideIcon(item.icon)}
{item.title}
</div>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
onClick={(e) => toggleMenu(item.id, e)}
className={cn(
"flex items-center w-full py-2 px-3 rounded-md transition-colors",
isActive
? "text-white font-medium"
: "text-blue-100 hover:bg-blue-700/30"
)}
>
{getLucideIcon(item.icon || "")}
<span>{name}</span>
<span className="ml-auto">
{isExpanded ? (
<LucideIcons.ChevronDown className="h-4 w-4" />
) : (
<LucideIcons.ChevronRight className="h-4 w-4" />
)}
</span>
</button>
{isExpanded && hasChildren && (
{isExpanded && (
<ul className="ml-4 mt-1 space-y-1">
{item.children!.map(child => {
const isChildItemActive = pathname === child.path;
return (
<li key={child.id}>
<Link
href={child.path}
className={`flex items-center px-4 py-2 rounded-md text-sm ${
isChildItemActive
? "text-white font-semibold bg-blue-700"
: "hover:bg-blue-600"
}`}
>
{child.icon && getLucideIcon(child.icon)}
{child.title}
</Link>
</li>
);
})}
{item.children!.map((child) => (
<li key={child.id}>
<a
href={child.path}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// 使用addTab返回的标签ID在TabContext中处理确保标签被激活并导航
const tabId = addTab({
label: child.name || child.title || "",
path: child.path,
closable: true
});
}}
className={cn(
"flex items-center py-2 px-3 rounded-md transition-colors",
isMenuPathActive(child.path, currentPath)
? "bg-blue-700 text-white font-medium"
: "text-blue-100 hover:bg-blue-700/30"
)}
>
{getLucideIcon(child.icon || "")}
<span>{child.name || child.title}</span>
</a>
</li>
))}
</ul>
)}
</div>
</>
) : (
<Link
<a
href={item.path}
className={`flex items-center px-4 py-2 rounded-md text-sm ${
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// 使用addTab返回的标签ID在TabContext中处理确保标签被激活并导航
const tabId = addTab({
label: name,
path: item.path,
closable: item.path !== "/dashboard" // 仪表盘不可关闭
});
}}
className={cn(
"flex items-center py-2 px-3 rounded-md transition-colors",
isActive
? "text-white font-semibold"
: "hover:bg-blue-600"
}`}
? "text-white font-medium"
: "text-blue-100 hover:bg-blue-700/30"
)}
>
{item.icon && getLucideIcon(item.icon)}
{item.title}
</Link>
{getLucideIcon(item.icon || "")}
<span>{name}</span>
</a>
)}
</li>
);
};
return (
<div className="w-64 border-r bg-[#2563eb] h-full flex flex-col text-white">
<div className="p-4 border-b border-blue-500">
<h2 className="text-lg font-bold"></h2>
<div className={cn(
"border-r bg-[#2563eb] h-full flex flex-col text-white transition-all duration-300 ease-in-out",
collapsed ? "w-16" : "w-64"
)}>
<div className={cn(
"border-b border-blue-500 flex items-center justify-between",
collapsed ? "p-2" : "p-4"
)}>
{!collapsed && <h2 className="text-lg font-bold"></h2>}
<button
onClick={toggleCollapsed}
className="p-1 rounded-md hover:bg-blue-700 transition-colors"
title={collapsed ? "展开菜单" : "折叠菜单"}
>
{collapsed ? (
<LucideIcons.ChevronRight className="h-5 w-5" />
) : (
<LucideIcons.ChevronLeft className="h-5 w-5" />
)}
</button>
</div>
<nav className="flex-1 overflow-auto p-2">
@@ -176,8 +460,11 @@ export function Sidebar() {
</ul>
) : (
// 无菜单数据
<div className="text-center py-8 text-blue-200">
<p></p>
<div className={cn(
"text-center py-8 text-blue-200",
collapsed && "text-xs px-0"
)}>
<p>{collapsed ? "无菜单" : "暂无菜单数据"}</p>
</div>
)}
</nav>

View File

@@ -0,0 +1,244 @@
"use client"
import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { toast } from "sonner"
const formSchema = z.object({
name: z.string().min(2, "项目名称至少需要2个字符"),
account: z.string().min(3, "账号至少需要3个字符"),
password: z.string().min(6, "密码至少需要6个字符"),
confirmPassword: z.string().min(6, "确认密码至少需要6个字符"),
phone: z.string().optional(),
realname: z.string().optional(),
nickname: z.string().optional(),
memo: z.string().optional(),
}).refine((data) => data.password === data.confirmPassword, {
message: "两次输入的密码不一致",
path: ["confirmPassword"],
});
interface ProjectCreateProps {
onSuccess?: () => void
}
export default function ProjectCreate({ onSuccess }: ProjectCreateProps) {
const [isLoading, setIsLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
account: "",
password: "",
confirmPassword: "",
phone: "",
realname: "",
nickname: "",
memo: "",
},
})
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: values.name,
account: values.account,
password: values.password,
phone: values.phone || null,
realname: values.realname || null,
nickname: values.nickname || null,
memo: values.memo || null,
}),
})
const data = await response.json()
if (data.code === 200) {
toast.success("项目创建成功")
form.reset()
if (onSuccess) {
onSuccess()
}
} else {
toast.error(data.msg || "创建项目失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsLoading(false)
}
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form id="create-project-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入项目名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入账号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入手机号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="请输入密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="请再次输入密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="realname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入真实姓名(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入昵称(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="memo"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="请输入项目描述(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => form.reset()}
disabled={isLoading}
>
</Button>
<Button
type="submit"
form="create-project-form"
disabled={isLoading}
>
{isLoading ? "创建中..." : "创建项目"}
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,349 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ArrowLeft, Edit } from "lucide-react"
import { toast } from "sonner"
import Image from "next/image"
import { Badge } from "@/components/ui/badge"
interface ProjectProfile {
id: number
name: string
memo: string
companyId: number
createTime: string
account: string
phone: string | null
deviceCount: number
friendCount: number
userCount: number
}
interface Device {
id: number
memo: string
phone: string
model: string
brand: string
alive: number
deviceId: number
wechatId: string
friendCount: number
wAlive: number
imei: string
}
interface SubUser {
id: number
account: string
username: string
phone: string
avatar: string
status: number
createTime: string
typeId: number
}
interface ProjectDetailProps {
projectId: string
onEdit?: (projectId: string) => void
}
export default function ProjectDetail({ projectId, onEdit }: ProjectDetailProps) {
const [isLoading, setIsLoading] = useState(true)
const [isDevicesLoading, setIsDevicesLoading] = useState(false)
const [isSubUsersLoading, setIsSubUsersLoading] = useState(false)
const [profile, setProfile] = useState<ProjectProfile | null>(null)
const [devices, setDevices] = useState<Device[]>([])
const [subUsers, setSubUsers] = useState<SubUser[]>([])
const [activeTab, setActiveTab] = useState("overview")
useEffect(() => {
const fetchProjectProfile = async () => {
setIsLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/profile/${projectId}`)
const data = await response.json()
if (data.code === 200) {
setProfile(data.data)
} else {
toast.error(data.msg || "获取项目信息失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsLoading(false)
}
}
fetchProjectProfile()
}, [projectId])
useEffect(() => {
const fetchDevices = async () => {
if (activeTab === "devices") {
setIsDevicesLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/devices?companyId=${projectId}`)
const data = await response.json()
if (data.code === 200) {
setDevices(data.data)
} else {
toast.error(data.msg || "获取设备列表失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsDevicesLoading(false)
}
}
}
fetchDevices()
}, [activeTab, projectId])
useEffect(() => {
const fetchSubUsers = async () => {
if (activeTab === "accounts") {
setIsSubUsersLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/subusers?companyId=${projectId}`)
const data = await response.json()
if (data.code === 200) {
setSubUsers(data.data)
} else {
toast.error(data.msg || "获取子账号列表失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsSubUsersLoading(false)
}
}
}
fetchSubUsers()
}, [activeTab, projectId])
if (isLoading) {
return <div className="flex items-center justify-center min-h-64">...</div>
}
if (!profile) {
return <div className="flex items-center justify-center min-h-64"></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{profile.name}</h1>
{onEdit && (
<Button onClick={() => onEdit(projectId)}>
<Edit className="mr-2 h-4 w-4" />
</Button>
)}
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="devices"></TabsTrigger>
<TabsTrigger value="accounts"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.phone || "未设置"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.account}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.createTime}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm">{profile.memo}</dd>
</div>
</dl>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{profile.deviceCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{profile.userCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{profile.friendCount}</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="devices">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{isDevicesLoading ? (
<div className="flex items-center justify-center py-8">...</div>
) : devices.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground"></div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>IMEI</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.id}>
<TableCell className="font-medium">{device.memo}</TableCell>
<TableCell>{device.model}</TableCell>
<TableCell>{device.brand}</TableCell>
<TableCell>{device.imei}</TableCell>
<TableCell>
<Badge variant={device.alive === 1 ? "success" : "destructive"}>
{device.alive === 1 ? "在线" : "离线"}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
device.wAlive === 1
? "success"
: device.wAlive === 0
? "destructive"
: "secondary"
}
>
{device.wAlive === 1 ? "已登录" : device.wAlive === 0 ? "已登出" : "未登录微信"}
</Badge>
</TableCell>
<TableCell className="text-right">{device.friendCount || 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 text-sm text-muted-foreground">
{devices.length}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="accounts">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{isSubUsersLoading ? (
<div className="flex items-center justify-center py-8">...</div>
) : subUsers.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground"></div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Image
src={user.avatar}
alt={user.username}
width={32}
height={32}
className="rounded-full"
/>
</TableCell>
<TableCell>{user.id}</TableCell>
<TableCell>{user.account}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>
<Badge variant={user.status === 1 ? "success" : "destructive"}>
{user.status === 1 ? "启用" : "禁用"}
</Badge>
</TableCell>
<TableCell>{user.typeId === 1 ? "操盘手" : "门店顾问"}</TableCell>
<TableCell className="text-right">{user.createTime}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 text-sm text-muted-foreground">
{subUsers.length}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,264 @@
"use client"
import { useState, useEffect } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { toast } from "sonner"
const formSchema = z.object({
name: z.string().min(2, "项目名称至少需要2个字符"),
account: z.string().min(3, "账号至少需要3个字符"),
password: z.string().optional(),
confirmPassword: z.string().optional(),
phone: z.string().optional(),
memo: z.string().optional(),
})
interface ProjectEditProps {
projectId: string
onSuccess?: () => void
}
interface ProjectData {
id: number
name: string
account: string
memo?: string
phone?: string
}
export default function ProjectEdit({ projectId, onSuccess }: ProjectEditProps) {
const [isLoading, setIsLoading] = useState(false)
const [isFetching, setIsFetching] = useState(true)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
account: "",
password: "",
confirmPassword: "",
phone: "",
memo: "",
},
})
// 获取项目数据
useEffect(() => {
const fetchProject = async () => {
setIsFetching(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/profile/${projectId}`)
const data = await response.json()
if (data.code === 200) {
const project = data.data
form.reset({
name: project.name || "",
account: project.account || "",
password: "",
confirmPassword: "",
phone: project.phone || "",
memo: project.memo || "",
})
} else {
toast.error(data.msg || "获取项目信息失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsFetching(false)
}
}
fetchProject()
}, [projectId, form])
const onSubmit = async (values: z.infer<typeof formSchema>) => {
// 检查密码是否匹配
if (values.password && values.password !== values.confirmPassword) {
toast.error("两次输入的密码不一致")
return
}
setIsLoading(true)
try {
// 准备请求数据,根据需要添加或移除字段
const updateData: Record<string, any> = {
id: parseInt(projectId),
name: values.name,
account: values.account,
memo: values.memo,
phone: values.phone,
}
// 如果提供了密码,则包含密码字段
if (values.password) {
updateData.password = values.password
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/company/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updateData),
})
const data = await response.json()
if (data.code === 200) {
toast.success("项目更新成功")
if (onSuccess) {
onSuccess()
}
} else {
toast.error(data.msg || "更新项目失败")
}
} catch (error) {
toast.error("网络错误,请稍后重试")
} finally {
setIsLoading(false)
}
}
if (isFetching) {
return <div className="flex items-center justify-center min-h-64">...</div>
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form id="edit-project-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入项目名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入账号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入手机号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="不修改请留空" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="请再次输入密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="memo"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="请输入项目描述" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => form.reset()}
disabled={isLoading}
>
</Button>
<Button
type="submit"
form="edit-project-form"
disabled={isLoading}
>
{isLoading ? "保存中..." : "保存更改"}
</Button>
</CardFooter>
</Card>
)
}