From a2ce70278ec32c3cacfe7970975cfc5bf38dc9ba Mon Sep 17 00:00:00 2001 From: Snowykami Date: Thu, 18 Sep 2025 23:29:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E4=B8=8A=E4=B8=8B=E6=96=87=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=94=A8=E6=88=B7=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/constant/constant.go | 100 +++++++++--------- web/src/api/user.ts | 20 ++-- web/src/app/console/layout.tsx | 19 ++-- web/src/app/layout.tsx | 16 ++- web/src/components/comment/index.tsx | 19 +--- web/src/components/console/app-sidebar.tsx | 34 ++---- web/src/components/console/nav-main.tsx | 12 ++- web/src/components/console/nav-user.tsx | 4 +- .../layout/avatar-with-dropdown-menu.tsx | 14 +-- web/src/contexts/auth-context.tsx | 38 +++++++ web/src/i18n/request.ts | 3 +- web/src/utils/common/gravatar.ts | 1 - 12 files changed, 147 insertions(+), 133 deletions(-) create mode 100644 web/src/contexts/auth-context.tsx diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index 82dabf4..55a3f25 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -1,57 +1,57 @@ package constant const ( - CaptchaTypeDisable = "disable" // 禁用验证码 - CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 - CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 - CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 - ContextKeyUserID = "user_id" // 上下文键:用户ID - ContextKeyRemoteAddr = "remote_addr" // 上下文键:远程地址 - ContextKeyUserAgent = "user_agent" // 上下文键:用户代理 - ModeDev = "dev" - ModeProd = "prod" - RoleUser = "user" // 普通用户 仅有阅读和评论权限 - RoleEditor = "editor" // 能够发布和管理自己内容的用户 - RoleAdmin = "admin" - EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL - EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 - EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 - EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url - EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key - EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式 - EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 - EnvKeyMode = "MODE" // 环境变量:运行模式 - EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 - EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 - EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 - EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 - EnvKeyTokenDurationDefault = 300 // Token有效时长 - EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长 - EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 - EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 - KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 - KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态 - ApiSuffix = "/api/v1" // API版本前缀 - OidcUri = "/user/oidc/login" // OIDC登录URI - OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey - OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub - DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl - TargetTypePost = "post" - TargetTypeComment = "comment" - OrderByCreatedAt = "created_at" // 按创建时间排序 - OrderByUpdatedAt = "updated_at" // 按更新时间排序 - OrderByLikeCount = "like_count" // 按点赞数排序 - OrderByCommentCount = "comment_count" // 按评论数排序 - OrderByViewCount = "view_count" // 按浏览量排序 - OrderByHeat = "heat" - MaxReplyDepthDefault = 3 // 默认最大回复深度 - HeatFactorViewWeight = 1 // 热度因子:浏览量权重 - HeatFactorLikeWeight = 5 // 热度因子:点赞权重 - HeatFactorCommentWeight = 10 // 热度因子:评论权重 - PageLimitDefault = 20 // 默认分页大小 + CaptchaTypeDisable = "disable" // 禁用验证码 + CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 + CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 + CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 + ContextKeyUserID = "user_id" // 上下文键:用户ID + ContextKeyRemoteAddr = "remote_addr" // 上下文键:远程地址 + ContextKeyUserAgent = "user_agent" // 上下文键:用户代理 + ModeDev = "dev" + ModeProd = "prod" + RoleUser = "user" // 普通用户 仅有阅读和评论权限 + RoleEditor = "editor" // 能够发布和管理自己内容的用户 + RoleAdmin = "admin" + EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL + EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 + EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 + EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url + EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key + EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式 + EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 + EnvKeyMode = "MODE" // 环境变量:运行模式 + EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 + EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 + EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 + EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 + EnvKeyTokenDurationDefault = 30 // Token有效时长 + EnvKeyRefreshTokenDurationDefault = 6000000 // refresh token有效时长 + EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 + EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 + KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 + KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态 + ApiSuffix = "/api/v1" // API版本前缀 + OidcUri = "/user/oidc/login" // OIDC登录URI + OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey + OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub + DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl + TargetTypePost = "post" + TargetTypeComment = "comment" + OrderByCreatedAt = "created_at" // 按创建时间排序 + OrderByUpdatedAt = "updated_at" // 按更新时间排序 + OrderByLikeCount = "like_count" // 按点赞数排序 + OrderByCommentCount = "comment_count" // 按评论数排序 + OrderByViewCount = "view_count" // 按浏览量排序 + OrderByHeat = "heat" + MaxReplyDepthDefault = 3 // 默认最大回复深度 + HeatFactorViewWeight = 1 // 热度因子:浏览量权重 + HeatFactorLikeWeight = 5 // 热度因子:点赞权重 + HeatFactorCommentWeight = 10 // 热度因子:评论权重 + PageLimitDefault = 20 // 默认分页大小 ) var ( - OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式 - OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式 + OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式 + OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式 ) diff --git a/web/src/api/user.ts b/web/src/api/user.ts index 5ecb3de..c3d64d1 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,6 +1,6 @@ import type { OidcConfig } from '@/models/oidc-config' import type { BaseResponse } from '@/models/resp' -import type { RegisterRequest, User } from '@/models/user' +import type { RegisterRequest, User } from '@/models/user' import axiosClient from './client' import { CaptchaProvider } from '@/models/captcha' @@ -47,12 +47,18 @@ export async function ListOidcConfigs(): Promise> { return res.data } -export async function getLoginUser(token: string = ''): Promise> { - const res = await axiosClient.get>('/user/me', { - headers: { - Authorization: `Bearer ${token}`, - }, - }) +export async function getLoginUser( + { token = '', refreshToken = '' }: { token?: string, refreshToken?: string } = {} +): Promise> { + if (token) { + const cookieParts = [`token=${token}`] + if (refreshToken) cookieParts.push(`refresh_token=${refreshToken}`) + const res = await axiosClient.get>('/user/me', { + headers: { Cookie: cookieParts.join('; ') }, + }) + return res.data + } + const res = await axiosClient.get>('/user/me') return res.data } diff --git a/web/src/app/console/layout.tsx b/web/src/app/console/layout.tsx index 60f06c4..aa053af 100644 --- a/web/src/app/console/layout.tsx +++ b/web/src/app/console/layout.tsx @@ -7,29 +7,22 @@ import { } from "@/components/ui/sidebar" import { useToLogin } from "@/hooks/use-route" -import { useEffect, useState } from "react" -import { User } from "@/models/user" -import { getLoginUser } from "@/api/user" +import { useEffect } from "react" +import { useAuth } from "@/contexts/auth-context" export default function ConsoleLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const [user, setUser] = useState(null); + const { user } = useAuth(); const toLogin = useToLogin(); useEffect(() => { - getLoginUser().then(res => { - setUser(res.data); - }).catch(() => { - setUser(null); + if (!user) { toLogin(); - }); - }, [toLogin]); - if (user === null) { - return null; - } + } + }, [user, toLogin]); return ( ) { + const token = (await cookies()).get("token")?.value || ""; + const refreshToken = (await cookies()).get("refresh_token")?.value || ""; + const user = await getLoginUser({token, refreshToken}).then(res => res.data).catch(() => null); + return ( - + + {children} - + + diff --git a/web/src/components/comment/index.tsx b/web/src/components/comment/index.tsx index deaa820..4ddabdf 100644 --- a/web/src/components/comment/index.tsx +++ b/web/src/components/comment/index.tsx @@ -1,6 +1,4 @@ "use client" - -import { User } from "@/models/user"; import { useTranslations } from "next-intl"; import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; @@ -12,9 +10,10 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { CommentInput } from "./comment-input"; import { CommentItem } from "./comment-item"; +import { useAuth } from "@/contexts/auth-context"; import config from "@/config"; import "./style.css"; -import { getLoginUser } from "@/api/user"; + export function CommentSection( @@ -29,7 +28,6 @@ export function CommentSection( } ) { const t = useTranslations('Comment') - const [loginUser, setLoginUser] = useState(null); const [comments, setComments] = useState([]); const [activeInput, setActiveInput] = useState<{ id: number; type: 'reply' | 'edit' } | null>(null); const [page, setPage] = useState(1); // 当前页码 @@ -37,14 +35,7 @@ export function CommentSection( const [needLoadMore, setNeedLoadMore] = useState(true); // 是否需要加载更多,当最后一次获取的评论数小于分页大小时设为false // 获取登录用户信息 - useEffect(() => { - getLoginUser().then(res => { - setLoginUser(res.data); - console.log("login user:", res.data); - }).catch(() => { - setLoginUser(null); - }); - }, []); + const {user} = useAuth(); // 加载0/顶层评论 useEffect(() => { listComments({ @@ -118,7 +109,7 @@ export function CommentSection(
{t("comment")} ({totalCommentCount})
@@ -127,7 +118,7 @@ export function CommentSection(
) { - const [loginUser, setLoginUser] = useState(null); - - useEffect(() => { - getLoginUser().then(resp => { - setLoginUser(resp.data); - }); - }, []) - - if (!loginUser) { - return null; // 或者返回一个加载指示器 - } - return ( @@ -89,7 +71,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + ) diff --git a/web/src/components/console/nav-main.tsx b/web/src/components/console/nav-main.tsx index fa8a3c4..9d60a6d 100644 --- a/web/src/components/console/nav-main.tsx +++ b/web/src/components/console/nav-main.tsx @@ -1,7 +1,5 @@ "use client" -import { type Icon } from "@tabler/icons-react" - import { SidebarGroup, SidebarGroupContent, @@ -10,6 +8,9 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar" import Link from "next/link" +import type { LucideProps } from "lucide-react"; +import { ComponentType, SVGProps } from "react" +import { usePathname } from "next/navigation"; export function NavMain({ items, @@ -17,9 +18,12 @@ export function NavMain({ items: { title: string url: string - icon?: Icon + icon?: ComponentType & LucideProps>; }[] }) { + const pathname = usePathname() ?? "/" + console.log("pathname", pathname) + return ( @@ -27,7 +31,7 @@ export function NavMain({ {items.map((item) => ( - + {item.icon && } {item.title} diff --git a/web/src/components/console/nav-user.tsx b/web/src/components/console/nav-user.tsx index bb38069..754b7f0 100644 --- a/web/src/components/console/nav-user.tsx +++ b/web/src/components/console/nav-user.tsx @@ -35,10 +35,10 @@ import { getFallbackAvatarFromUsername } from "@/utils/common/username" export function NavUser({ user, }: { - user: User + user?: User }) { const { isMobile } = useSidebar() - + if (!user) return null return ( diff --git a/web/src/components/layout/avatar-with-dropdown-menu.tsx b/web/src/components/layout/avatar-with-dropdown-menu.tsx index f7e9869..2d04a82 100644 --- a/web/src/components/layout/avatar-with-dropdown-menu.tsx +++ b/web/src/components/layout/avatar-with-dropdown-menu.tsx @@ -8,9 +8,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { User } from "@/models/user"; -import { useEffect, useState } from "react"; -import { getLoginUser, userLogout } from "@/api/user"; +import { userLogout } from "@/api/user"; import Link from "next/link"; import { toast } from "sonner"; import { useToLogin } from "@/hooks/use-route"; @@ -18,17 +16,11 @@ import { CircleUser } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getGravatarFromUser } from "@/utils/common/gravatar"; import { getFallbackAvatarFromUsername } from "@/utils/common/username"; +import { useAuth } from "@/contexts/auth-context"; export function AvatarWithDropdownMenu() { - const [user, setUser] = useState(null); + const {user} = useAuth(); const toLogin = useToLogin(); - useEffect(() => { - getLoginUser().then(res => { - setUser(res.data); - }).catch(() => { - setUser(null); - }); - }, []); const handleLogout = () => { userLogout().then(() => { diff --git a/web/src/contexts/auth-context.tsx b/web/src/contexts/auth-context.tsx new file mode 100644 index 0000000..7d792ba --- /dev/null +++ b/web/src/contexts/auth-context.tsx @@ -0,0 +1,38 @@ +"use client"; + +import React, { createContext, useContext, useState, useMemo } from "react"; +import type { User } from "@/models/user"; +import { userLogout } from "@/api/user"; + +type AuthContextValue = { + user: User | null; + setUser: (u: User | null) => void; + logout: () => void; +}; + +const AuthContext = createContext(undefined); + +export function AuthProvider({ + children, + initialUser = null, +}: { + children: React.ReactNode; + initialUser?: User | null; +}) { + const [user, setUser] = useState(initialUser); + + const logout = async () => { + setUser(null); + await userLogout(); + }; + const value = useMemo(() => ({ user, setUser, logout }), [user]); + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} \ No newline at end of file diff --git a/web/src/i18n/request.ts b/web/src/i18n/request.ts index 2f4da60..689dee8 100644 --- a/web/src/i18n/request.ts +++ b/web/src/i18n/request.ts @@ -26,7 +26,8 @@ export async function getUserLocales(): Promise { const cookieStore = await cookies(); try { const token = cookieStore.get('token')?.value || ''; - const user = (await getLoginUser(token)).data; + const refreshToken = cookieStore.get('refresh_token')?.value || ''; + const user = (await getLoginUser({token, refreshToken})).data; locales.push(user.language); locales.push(user.language.split('-')[0]); } catch { diff --git a/web/src/utils/common/gravatar.ts b/web/src/utils/common/gravatar.ts index c579e0a..9b0fa34 100644 --- a/web/src/utils/common/gravatar.ts +++ b/web/src/utils/common/gravatar.ts @@ -12,7 +12,6 @@ import type { User } from '@/models/user'; export function getGravatarUrl({ email, size, proxy }: { email: string, size?: number, proxy?: string }): string { const hash = md5(email.trim().toLowerCase()); - console.log(`https://${proxy ? proxy : "www.gravatar.com"}/avatar/${hash}?s=${size}&d=identicon`) return `https://${proxy ? proxy : "www.gravatar.com"}/avatar/${hash}?s=${size}&d=identicon`; }