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

12
src/app/layout.tsx

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

37
src/app/page.tsx

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

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

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

Loading…
Cancel
Save