2 Commits

Author SHA1 Message Date
Eamon
887ff2f07c 修改客户管理认领和管理员字段展示逻辑 2026-01-09 18:36:54 +08:00
Eamon
c85293fd1d 修复登录缺陷和用户信息展示部分 2026-01-09 17:37:33 +08:00
15 changed files with 188 additions and 66 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "lanhu-admin", "name": "lanhu-admin",
"version": "1.0.0", "version": "1.0.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0 --turbopack", "dev": "next dev -H 0.0.0.0 --turbopack",

View File

@@ -1,7 +1,8 @@
"use server" "use server"
import { cookies } from "next/headers" import { cookies } from "next/headers"
import type { ApiResponse } from "@/lib/api" import type { ApiResponse } from "@/lib/api"
import { callByDevice } from "./base" import type { User } from "@/models/user"
import { callByDevice, callByUser } from "./base"
export type TokenResp = { export type TokenResp = {
access_token: string access_token: string
@@ -29,12 +30,12 @@ export async function login(params: {
// 保存到 cookies // 保存到 cookies
const data = resp.data const data = resp.data
const cookieStore = await cookies() const cookieStore = await cookies()
cookieStore.set("auth_token", data.access_token, { cookieStore.set("admin/auth_token", data.access_token, {
httpOnly: true, httpOnly: true,
sameSite: "strict", sameSite: "strict",
maxAge: Math.max(data.expires_in, 0), maxAge: Math.max(data.expires_in, 0),
}) })
cookieStore.set("auth_refresh", data.refresh_token, { cookieStore.set("admin/auth_refresh", data.refresh_token, {
httpOnly: true, httpOnly: true,
sameSite: "strict", sameSite: "strict",
maxAge: Number.MAX_SAFE_INTEGER, maxAge: Number.MAX_SAFE_INTEGER,
@@ -46,10 +47,47 @@ export async function login(params: {
} }
} }
export async function logout() {
const cookieStore = await cookies()
// 尝试删除后台会话
const access_token = cookieStore.get("admin/auth_token")?.value
const refresh_token = cookieStore.get("admin/auth_refresh")?.value
if (access_token && refresh_token) {
await callByUser("/api/auth/revoke", {
access_token,
refresh_token,
})
}
// 删除 cookies
cookieStore.set("admin/auth_token", "", {
httpOnly: true,
sameSite: "strict",
maxAge: -1,
})
cookieStore.set("admin/auth_refresh", "", {
httpOnly: true,
sameSite: "strict",
maxAge: -1,
})
return {
success: true,
data: undefined,
}
}
export async function getProfile() {
return await callByUser<User>("/api/auth/introspect")
}
export async function refreshAuth() { export async function refreshAuth() {
const cookie = await cookies() const cookie = await cookies()
const userRefresh = cookie.get("auth_refresh")?.value const userRefresh = cookie.get("admin/auth_refresh")?.value
console.log(userRefresh, "userRefresh")
if (!userRefresh) { if (!userRefresh) {
throw new Error("未授权访问") throw new Error("未授权访问")
} }
@@ -63,7 +101,7 @@ export async function refreshAuth() {
// 处理请求 // 处理请求
if (!resp.success) { if (!resp.success) {
if (resp.status === 401) { if (resp.status === 401) {
cookie.delete("auth_refresh") cookie.delete("admin/auth_refresh")
} }
throw new Error("未授权访问") throw new Error("未授权访问")
} }
@@ -75,12 +113,12 @@ export async function refreshAuth() {
const expiresIn = data.expires_in const expiresIn = data.expires_in
// 保存令牌到 cookies // 保存令牌到 cookies
cookie.set("auth_token", nextAccessToken, { cookie.set("admin/auth_token", nextAccessToken, {
httpOnly: true, httpOnly: true,
sameSite: "strict", sameSite: "strict",
maxAge: Math.max(expiresIn, 0), maxAge: Math.max(expiresIn, 0),
}) })
cookie.set("auth_refresh", nextRefreshToken, { cookie.set("admin/auth_refresh", nextRefreshToken, {
httpOnly: true, httpOnly: true,
sameSite: "strict", sameSite: "strict",
maxAge: Number.MAX_SAFE_INTEGER, maxAge: Number.MAX_SAFE_INTEGER,

View File

@@ -80,7 +80,7 @@ const _callByUser = cache(
): Promise<ApiResponse<R>> => { ): Promise<ApiResponse<R>> => {
// 获取用户令牌 // 获取用户令牌
const cookie = await cookies() const cookie = await cookies()
const token = cookie.get("auth_token")?.value const token = cookie.get("admin/auth_token")?.value
if (!token) { if (!token) {
return { return {
success: false, success: false,

View File

@@ -12,18 +12,16 @@ import {
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { getProfile, logout } from "@/actions/auth"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import type { User } from "@/models/user"
export default function Appbar() { export default function Appbar() {
const [currentUser] = useState({ const [currentUser, setCurrentUser] = useState<User>()
name: "张三", const router = useRouter()
avatar: "/avatar.png",
role: "管理员",
})
const [showDropdown, setShowDropdown] = useState(false) const [showDropdown, setShowDropdown] = useState(false)
const [showNotifications, setShowNotifications] = useState(false) const [showNotifications, setShowNotifications] = useState(false)
const [notifications] = useState([ const [notifications] = useState([
@@ -116,6 +114,35 @@ export default function Appbar() {
const breadcrumbs = generateBreadcrumbs() const breadcrumbs = generateBreadcrumbs()
const unreadCount = notifications.filter(n => !n.read).length const unreadCount = notifications.filter(n => !n.read).length
const doLogout = async () => {
const resp = await logout()
if (resp.success) {
router.replace("/")
router.refresh()
}
}
useEffect(() => {
async function fetchUserProfile() {
try {
const resp = await getProfile()
console.log(resp, "resp")
if (resp.success) {
setCurrentUser(resp.data)
} else {
console.error("获取用户信息失败:", resp.message)
if (resp.status === 401) {
router.replace("/login")
}
}
} catch (error) {
console.error("获取用户信息时出错:", error)
}
}
fetchUserProfile()
}, [router])
return ( return (
<header className="bg-white h-16 border-b border-gray-200 flex items-center justify-between px-6"> <header className="bg-white h-16 border-b border-gray-200 flex items-center justify-between px-6">
@@ -232,23 +259,45 @@ export default function Appbar() {
aria-label="用户菜单" aria-label="用户菜单"
> >
<div className="h-8 w-8 rounded-full bg-blue-100 text-blue-800 flex items-center justify-center overflow-hidden border-2 border-white shadow-sm"> <div className="h-8 w-8 rounded-full bg-blue-100 text-blue-800 flex items-center justify-center overflow-hidden border-2 border-white shadow-sm">
<Image {currentUser ? (
src={currentUser.avatar} currentUser.avatar ? (
alt="用户头像" <Image
width={32} src={currentUser.avatar}
height={32} alt="用户头像"
onError={e => { width={32}
const target = e.target as HTMLImageElement height={32}
target.style.display = "none" className="h-full w-full object-cover"
target.parentElement!.innerHTML = currentUser.name.charAt(0) onError={e => {
}} const target = e.target as HTMLImageElement
/> target.style.display = "none"
const parent = target.parentElement
if (parent && currentUser?.name) {
parent.textContent = currentUser.name
.charAt(0)
.toUpperCase()
}
}}
/>
) : (
// 如果没有头像,直接显示用户名首字母
<span className="text-sm font-semibold">
{currentUser.name.charAt(0).toUpperCase()}
</span>
)
) : (
// 加载状态或用户信息为空时
<UserIcon size={18} />
)}
</div> </div>
<div className="hidden md:block text-left"> <div className="hidden md:block text-left">
<p className="text-sm font-medium text-gray-800"> {currentUser && (
{currentUser.name} <div>
</p> <p className="text-sm font-medium text-gray-800">
<p className="text-xs text-gray-500">{currentUser.role}</p> {currentUser.name}
</p>
<p className="text-xs text-gray-500">{currentUser.username}</p>
</div>
)}
</div> </div>
<ChevronDownIcon /> <ChevronDownIcon />
</Button> </Button>
@@ -256,10 +305,15 @@ export default function Appbar() {
{/* 用户下拉内容 */} {/* 用户下拉内容 */}
{showDropdown && ( {showDropdown && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg py-2 z-20 border border-gray-200"> <div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg py-2 z-20 border border-gray-200">
<div className="px-4 py-2 border-b border-gray-100 md:hidden"> {currentUser && (
<p className="font-medium text-gray-800">{currentUser.name}</p> <div className="px-4 py-2 border-b border-gray-100 md:hidden">
<p className="text-xs text-gray-500">{currentUser.role}</p> <p className="font-medium text-gray-800">
</div> {currentUser.name}
</p>
<p className="text-xs text-gray-500">{currentUser.name}</p>
</div>
)}
<div className="py-1"> <div className="py-1">
<Link <Link
@@ -284,15 +338,15 @@ export default function Appbar() {
<span className="pl-3"></span> <span className="pl-3"></span>
</Link> </Link>
</div> </div>
<div className="border-t border-gray-100 pt-1">
<div className="border-t border-gray-100 mt-1"> <Button
<Link variant="ghost"
href="/login" onClick={doLogout}
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100" className="flex items-center justify-start px-4 py-2 w-full text-sm text-red-600 hover:text-red-700 hover:bg-gray-100 font-normal"
> >
<LogOutIcon size={18} /> <LogOutIcon size={18} className="ml-2" />
<span className="pl-3">退</span> 退
</Link> </Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -9,7 +9,6 @@ export default function BatchPage() {
const table = useDataTable<Batch>((page, size) => const table = useDataTable<Batch>((page, size) =>
getPageBatch({ page, size }), getPageBatch({ page, size }),
) )
console.log(table, "table")
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>

View File

@@ -10,7 +10,6 @@ export default function BillingPage() {
const table = useDataTable<Billing>((page, size) => const table = useDataTable<Billing>((page, size) =>
getPageBill({ page, size }), getPageBill({ page, size }),
) )
console.log(table, "table")
return ( return (
<Suspense> <Suspense>
@@ -24,7 +23,6 @@ export default function BillingPage() {
accessorKey: "info", accessorKey: "info",
cell: ({ row }) => { cell: ({ row }) => {
const bill = row.original const bill = row.original
console.log(bill, "bill")
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -49,7 +49,6 @@ export default function ChannelPage() {
header: "认证方式", header: "认证方式",
cell: ({ row }) => { cell: ({ row }) => {
const channel = row.original const channel = row.original
console.log(channel, "channel")
const hasWhitelist = const hasWhitelist =
channel.whitelists && channel.whitelists.trim() !== "" channel.whitelists && channel.whitelists.trim() !== ""

View File

@@ -0,0 +1,5 @@
"use client"
export default function SecurityPage() {
return <div>~</div>
}

View File

@@ -0,0 +1,5 @@
"use client"
export default function StatisticsPage() {
return <div>~</div>
}

View File

@@ -10,7 +10,6 @@ export default function TradePage() {
const table = useDataTable<Trade>((page, size) => const table = useDataTable<Trade>((page, size) =>
getPageTrade({ page, size }), getPageTrade({ page, size }),
) )
console.log(table, "table")
return ( return (
<Suspense> <Suspense>

View File

@@ -10,7 +10,7 @@ import type { User } from "@/models/user"
export default function UserPage() { export default function UserPage() {
const table = useDataTable<User>((page, size) => getPageUsers({ page, size })) const table = useDataTable<User>((page, size) => getPageUsers({ page, size }))
const bind = useFetch((id: number) => bindAdmin({ id }), { const bind = useFetch(table, (id: number) => bindAdmin({ id }), {
done: "用户已认领", done: "用户已认领",
fail: "用户认领失败", fail: "用户认领失败",
}) })
@@ -73,7 +73,10 @@ export default function UserPage() {
}, },
}, },
{ header: "联系方式", accessorKey: "contact_wechat" }, { header: "联系方式", accessorKey: "contact_wechat" },
{ header: "管理员", accessorKey: "admin_id" }, {
header: "管理员",
cell: ({ row }) => row.original.admin?.name,
},
{ {
header: "最后登录时间", header: "最后登录时间",
accessorKey: "last_login", accessorKey: "last_login",
@@ -92,9 +95,9 @@ export default function UserPage() {
<Button <Button
size={"sm"} size={"sm"}
onClick={() => bind(ctx.row.original.id)} onClick={() => bind(ctx.row.original.id)}
disabled={ctx.row.original.admin_id !== null} disabled={!!ctx.row.original.admin_id}
> >
{ctx.row.original.admin_id !== null ? "已认领" : "认领"} {ctx.row.original.admin_id ? "已认领" : "认领"}
</Button> </Button>
), ),
}, },

View File

@@ -51,8 +51,9 @@ export function useDataTable<T>(
}, [refresh, page, size]) }, [refresh, page, size])
return { return {
status,
data, data,
status,
setStatus,
pagination: { pagination: {
page, page,
size, size,
@@ -60,5 +61,6 @@ export function useDataTable<T>(
onPageChange, onPageChange,
onSizeChange, onSizeChange,
}, },
refresh,
} }
} }

View File

@@ -5,6 +5,7 @@ import {
useState, useState,
} from "react" } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import type { useDataTable } from "@/components/data-table"
import type { ApiResponse } from "@/lib/api" import type { ApiResponse } from "@/lib/api"
export function useStatus() { export function useStatus() {
@@ -12,31 +13,39 @@ export function useStatus() {
} }
export function useFetch<TArgs extends unknown[], TResult>( export function useFetch<TArgs extends unknown[], TResult>(
table: ReturnType<typeof useDataTable>,
fetchData: (...args: TArgs) => Promise<ApiResponse<TResult>>, fetchData: (...args: TArgs) => Promise<ApiResponse<TResult>>,
messages: { messages: {
done?: string done?: string
fail?: string fail?: string
}, },
setStatus?: Dispatch<SetStateAction<"load" | "fail" | "done">>,
) { ) {
return useCallback( return useCallback(
async (...args: TArgs) => { async (...args: TArgs) => {
try { try {
setStatus?.("load") table.setStatus?.("load")
const resp = await fetchData(...args) const resp = await fetchData(...args)
if (!resp.success) { if (!resp.success) {
throw new Error(resp.message) throw new Error(resp.message)
} }
setStatus?.("done") table.setStatus?.("done")
table.refresh(table.pagination.page, table.pagination.size)
toast.success(messages.done || "获取数据成功") toast.success(messages.done || "获取数据成功")
} catch (e) { } catch (e) {
setStatus?.("fail") table.setStatus?.("fail")
toast.error(messages.fail || "获取数据失败", { toast.error(messages.fail || "获取数据失败", {
description: (e as Error).message || "未知错误", description: (e as Error).message || "未知错误",
}) })
} }
}, },
[fetchData, setStatus, messages], [
fetchData,
table.setStatus,
table.pagination.page,
table.pagination.size,
table.refresh,
messages,
],
) )
} }

View File

@@ -1,6 +1,7 @@
export type User = { export type User = {
id: number id: number
admin_id: number admin_id?: number
admin?: Admin
phone: string phone: string
has_password: boolean has_password: boolean
username: string username: string
@@ -20,3 +21,7 @@ export type User = {
created_at: Date created_at: Date
updated_at: Date updated_at: Date
} }
export type Admin = {
name: string
}

View File

@@ -19,22 +19,28 @@ export async function proxy(request: NextRequest) {
// 刷新访问令牌 // 刷新访问令牌
try { try {
const accessToken = request.cookies.get("auth_token") const accessToken = request.cookies.get("admin/auth_token")
const refreshToken = request.cookies.get("auth_refresh") const refreshToken = request.cookies.get("admin/auth_refresh")
if (!accessToken && !!refreshToken) { if (!accessToken && !!refreshToken) {
console.log("💡 refresh token") console.log("💡 refresh token")
const token = await refreshAuth() const token = await refreshAuth()
request.cookies.set("auth_token", token.access_token) request.cookies.set("admin/auth_token", token.access_token)
request.cookies.set("auth_refresh", token.refresh_token) request.cookies.set("admin/auth_refresh", token.refresh_token)
} }
} catch (e) { } catch (e) {
console.log("刷新访问令牌失败", request.url, (e as Error).message) console.log("刷新访问令牌失败", request.url, (e as Error).message)
} }
// 验证访问令牌 // 验证访问令牌
const hasToken = !!request.cookies.get("auth_token") const hasToken = !!request.cookies.get("admin/auth_token")
const isToAdmin = request.nextUrl.pathname.startsWith("/admin") // const isToAdmin = request.nextUrl.pathname.startsWith("/admin")
if (!hasToken && isToAdmin) { const protectedPaths = ["/", "/admin"]
const isProtectedPath = protectedPaths.some(
path =>
request.nextUrl.pathname === path ||
request.nextUrl.pathname.startsWith(`${path}/`),
)
if (!hasToken && isProtectedPath) {
return NextResponse.redirect( return NextResponse.redirect(
`${request.nextUrl.origin}/login?redirect=${request.nextUrl.pathname}`, `${request.nextUrl.origin}/login?redirect=${request.nextUrl.pathname}`,
) )