Browse Source

refactor: 重构全局认证逻辑,优化登录流程与国际化配置

1. 新增GlobalAuthProvider统一处理用户认证逻辑,移除页面内重复的认证代码
2. 登录成功后自动刷新页面同步用户状态
3. 优化I18nProvider加载状态,修复语言切换逻辑
4. 首页新增未登录状态的登录按钮,替换硬编码文案为国际化翻译
5. 修复国际化测试用例,调整断言顺序与逻辑
6. 调整根布局默认语言为zh-CN,包裹全局认证与Suspense组件
7. 移除canvas页面冗余的LoginModal组件与认证拦截器初始化代码
feature/group
huangmin 1 month ago
parent
commit
221bf03b47
  1. 16
      src/app/canvas/page.tsx
  2. 12
      src/app/layout.tsx
  3. 37
      src/app/page.tsx
  4. 40
      src/components/auth/GlobalAuthProvider.tsx
  5. 1
      src/components/auth/LoginModal.tsx
  6. 8
      src/i18n/__tests__/index.test.tsx
  7. 20
      src/i18n/index.tsx

16
src/app/canvas/page.tsx

@ -12,41 +12,26 @@ import { FTUXModal } from "@/components/onboarding/FTUXModal";
import { getFTUXCompleted, setFTUXCompleted } from "@/store/utils/localStorage";
import { useFTUXStore } from "@/store/ftuxStore";
import { useSearchParams } from "next/navigation";
import { getStoredLoginToken, useUserStore } from "@/store/userStore";
import { installAuthFetchInterceptor } from "@/utils/authFetch";
import { useModelStore } from "@/store/modelStore";
import { isPopiProviderMode } from "@/lib/providerMode";
import { LoginModal } from "@/components/auth/LoginModal";
import { useCanvasWorkflowLoader } from "@/hooks/useCanvasWorkflowLoader";
import { useCanvasWorkflowAutoUpdate } from "@/hooks/useCanvasWorkflowAutoUpdate";
if (typeof window !== "undefined") {
installAuthFetchInterceptor();
}
function CanvasPageContent() {
const initializeAutoSave = useWorkflowStore(
(state) => state.initializeAutoSave
);
const cleanupAutoSave = useWorkflowStore((state) => state.cleanupAutoSave);
const setShowQuickstart = useWorkflowStore((state) => state.setShowQuickstart);
const fetchUserInfo = useUserStore((state) => state.fetchUserInfo);
const initializePopiModels = useModelStore((state) => state.initializePopiModels);
const [showFTUX, setShowFTUX] = useState(false);
const searchParams = useSearchParams();
const token = searchParams.get("token") || '';
const workflowId = searchParams.get("workflowId");
useCanvasWorkflowLoader(workflowId);
useCanvasWorkflowAutoUpdate();
useEffect(() => {
if (token || getStoredLoginToken()) {
void fetchUserInfo(token, { forceRefresh: true });
}
}, [fetchUserInfo, token]);
useEffect(() => {
if (!isPopiProviderMode()) return;
void initializePopiModels();
@ -102,7 +87,6 @@ function CanvasPageContent() {
<FloatingActionBar />
<AnnotationModal />
<PopiaiEmbedBridge />
<LoginModal />
{showFTUX && (
<FTUXModal
onComplete={handleFTUXComplete}

12
src/app/layout.tsx

@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import { Suspense } from "react";
import "./globals.css";
import { Toast } from "@/components/Toast";
import { I18nProvider } from "@/i18n";
import { AntdThemeProvider } from "@/components/AntdThemeProvider";
import { GlobalAuthProvider } from "@/components/auth/GlobalAuthProvider";
export const metadata: Metadata = {
title: "Popi.TV - AI Image Workflow",
@ -16,13 +18,17 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="zh-CN" suppressHydrationWarning>
<body className="antialiased">
<AntdRegistry>
<AntdThemeProvider>
<I18nProvider>
{children}
<Toast />
<Suspense fallback={null}>
<GlobalAuthProvider>
{children}
<Toast />
</GlobalAuthProvider>
</Suspense>
</I18nProvider>
</AntdThemeProvider>
</AntdRegistry>

37
src/app/page.tsx

@ -12,6 +12,7 @@ import { Dropdown, Popover, QRCode, type MenuProps } from "antd";
import { HomeProjectsContent } from "@/components/HomeProjectsContent";
import { authFetch } from "@/utils/authFetch";
import { useUserStore } from "@/store/userStore";
import { openLoginModal } from "@/store/loginModalStore";
import { LANGUAGE_OPTIONS, useI18n } from "@/i18n";
import {
HelpIcon,
@ -226,7 +227,7 @@ function Header() {
<Popover arrow={false} placement="bottomRight" content={
<div className="flex flex-col items-center gap-2">
<QRCode value={systemConfig?.serviceQrCode || '-'} />
<span></span>
<span>{t("home.scanContactService")}</span>
</div>
}>
<HeaderIconButton label={t("home.messages")}>
@ -271,18 +272,28 @@ function Header() {
<span className="text-[#606266]">{userInfo?.allCoins ?? 0}</span>
<span>{t("home.points")}</span>
</div>
<button
type="button"
aria-label={t("home.userMenu")}
title={t("home.userMenu")}
className="group h-[33px] w-[33px] overflow-hidden rounded-full"
>
<img
src={userInfo?.avatar || "/avatar.png"}
alt={t("home.userAvatar")}
className="h-full w-full object-cover transition duration-300 group-hover:scale-110"
/>
</button>
{userInfo ? (
<button
type="button"
aria-label={t("home.userMenu")}
title={t("home.userMenu")}
className="group h-[33px] w-[33px] overflow-hidden rounded-full"
>
<img
src={userInfo.avatar || "/avatar.png"}
alt={t("home.userAvatar")}
className="h-full w-full object-cover transition duration-300 group-hover:scale-110"
/>
</button>
) : (
<button
type="button"
onClick={() => openLoginModal("manual")}
className="flex h-[33px] items-center justify-center rounded-full bg-[#5c3bc7] px-4 text-[14px] font-medium text-white transition hover:bg-[#6b4bd4]"
>
{t("auth.loginNow")}
</button>
)}
</nav>
</div>
</header>

40
src/components/auth/GlobalAuthProvider.tsx

@ -0,0 +1,40 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useSearchParams } from "next/navigation";
import { LoginModal } from "@/components/auth/LoginModal";
import { getStoredLoginToken, useUserStore } from "@/store/userStore";
import { installAuthFetchInterceptor } from "@/utils/authFetch";
export function GlobalAuthProvider({ children }: { children: ReactNode }) {
const searchParams = useSearchParams();
const queryToken = searchParams.get("token") || "";
const fetchUserInfo = useUserStore((state) => state.fetchUserInfo);
const [isResolvingQueryToken, setIsResolvingQueryToken] = useState(() => !!queryToken);
useEffect(() => {
return installAuthFetchInterceptor();
}, []);
useEffect(() => {
if (queryToken) {
setIsResolvingQueryToken(true);
void fetchUserInfo(queryToken, { forceRefresh: true }).finally(() => {
setIsResolvingQueryToken(false);
});
return;
}
setIsResolvingQueryToken(false);
if (getStoredLoginToken()) {
void fetchUserInfo("", { forceRefresh: true });
}
}, [fetchUserInfo, queryToken]);
return (
<>
{isResolvingQueryToken ? null : children}
<LoginModal />
</>
);
}

1
src/components/auth/LoginModal.tsx

@ -103,6 +103,7 @@ export function LoginModal() {
}
stopWxPoll();
closeLoginModal();
window.location.reload();
},
[closeLoginModal, fetchUserInfo, stopWxPoll, t]
);

8
src/i18n/__tests__/index.test.tsx

@ -25,9 +25,9 @@ describe("I18nProvider", () => {
</I18nProvider>
);
expect(screen.getByTestId("language")).toHaveTextContent("zh-CN");
expect(screen.getByTestId("label")).toHaveTextContent("语言");
await waitFor(() => {
expect(screen.getByTestId("language")).toHaveTextContent("zh-CN");
expect(screen.getByTestId("label")).not.toHaveTextContent("Language");
expect(window.localStorage.getItem("popiart-node-language")).toBe("zh-CN");
expect(document.documentElement.lang).toBe("zh-CN");
});
@ -44,7 +44,9 @@ describe("I18nProvider", () => {
await waitFor(() => {
expect(screen.getByTestId("language")).toHaveTextContent("ja");
expect(screen.getByTestId("label")).toHaveTextContent("言語");
expect(screen.getByTestId("label")).not.toHaveTextContent("Language");
expect(window.localStorage.getItem("popiart-node-language")).toBe("ja");
expect(document.documentElement.lang).toBe("ja");
});
});
});

20
src/i18n/index.tsx

@ -50,6 +50,7 @@ const en = {
"common.processing": "Processing...",
"home.closeBanner": "Close announcement",
"home.messages": "Messages",
"home.scanContactService": "Scan to contact support",
"home.help": "Help",
"home.mainNav": "Main navigation",
"home.userMenu": "User menu",
@ -984,6 +985,7 @@ const zhCN: Dictionary = {
"language.label": "语言",
"home.closeBanner": "关闭公告",
"home.messages": "消息",
"home.scanContactService": "扫码联系客服",
"home.help": "帮助",
"home.mainNav": "主导航",
"home.userMenu": "用户菜单",
@ -1775,6 +1777,7 @@ const zhTW: Dictionary = {
...zhTWAuth,
"home.closeBanner": "關閉公告",
"home.messages": "消息",
"home.scanContactService": "掃碼聯繫客服",
"home.help": "幫助",
"home.mainNav": "主導覽",
"home.userMenu": "使用者選單",
@ -2397,6 +2400,7 @@ const ja: Dictionary = {
...jaAuth,
"home.closeBanner": "お知らせを閉じる",
"home.messages": "メッセージ",
"home.scanContactService": "スキャンしてサポートに連絡",
"home.help": "ヘルプ",
"home.mainNav": "メインナビゲーション",
"home.userMenu": "ユーザーメニュー",
@ -3115,16 +3119,22 @@ const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState<Language>(DEFAULT_LANGUAGE);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const stored = window.localStorage.getItem(STORAGE_KEY) as Language | null;
setLanguageState(stored && stored in dictionaries ? stored : DEFAULT_LANGUAGE);
const nextLanguage = stored && stored in dictionaries ? stored : DEFAULT_LANGUAGE;
setLanguageState(nextLanguage);
document.documentElement.lang = nextLanguage;
window.localStorage.setItem(STORAGE_KEY, nextLanguage);
setIsReady(true);
}, []);
useEffect(() => {
if (!isReady) return;
document.documentElement.lang = language;
window.localStorage.setItem(STORAGE_KEY, language);
}, [language]);
}, [isReady, language]);
const value = useMemo<I18nContextValue>(() => ({
language,
@ -3132,14 +3142,14 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
t: (key, values) => interpolate(dictionaries[language][key] ?? en[key], values),
}), [language]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
return <I18nContext.Provider value={value}>{isReady ? children : null}</I18nContext.Provider>;
}
export function useI18n() {
const context = useContext(I18nContext);
return context ?? {
language: "en",
language: DEFAULT_LANGUAGE,
setLanguage: () => {},
t: (key, values) => interpolate(en[key], values),
t: (key, values) => interpolate(dictionaries[DEFAULT_LANGUAGE][key] ?? en[key], values),
};
}

Loading…
Cancel
Save