diff --git a/src/app/api/content/privacy-policy/latest/route.ts b/src/app/api/content/privacy-policy/latest/route.ts new file mode 100644 index 00000000..d22fd7e3 --- /dev/null +++ b/src/app/api/content/privacy-policy/latest/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getRequestToken } from "@/app/api/_auth"; + +const UPSTREAM_PRIVACY_POLICY_LATEST_PATH = "/api_client/content/privacyPolicy/latest"; + +function getPopiBaseUrl(request: NextRequest): string { + const baseUrl = + process.env.POPIART_API_BASE_URL || + process.env.NEXT_PUBLIC_APP_URL || + request.nextUrl.origin; + return baseUrl.replace(/\/+$/, ""); +} + +export async function GET(request: NextRequest) { + const key = request.nextUrl.searchParams.get("key")?.trim(); + if (!key) { + return NextResponse.json( + { status: "4000", message: "key is required" }, + { status: 400 }, + ); + } + + const upstreamUrl = new URL(getPopiBaseUrl(request) + UPSTREAM_PRIVACY_POLICY_LATEST_PATH); + upstreamUrl.searchParams.set("key", key); + + const headers: Record = { + Accept: "application/json", + }; + const token = getRequestToken(request); + if (token) { + headers.Authorization = `Bearer ${token}`; + headers.token = token; + } + + const upstream = await fetch(upstreamUrl, { + method: "GET", + headers, + cache: "no-store", + }); + + const body = await upstream.text(); + return new NextResponse(body, { + status: upstream.status, + headers: { + "Content-Type": upstream.headers.get("Content-Type") || "application/json", + }, + }); +} diff --git a/src/app/api/recommend-banners/route.ts b/src/app/api/recommend-banners/route.ts index eafb933c..1ac2800f 100644 --- a/src/app/api/recommend-banners/route.ts +++ b/src/app/api/recommend-banners/route.ts @@ -9,7 +9,6 @@ function getPopiBaseUrl() { export async function GET(request: NextRequest) { const baseUrl = getPopiBaseUrl(); - console.log("request", request); if (!baseUrl) { return NextResponse.json( { status: "5000", message: "POPIART_API_BASE_URL is not configured" }, @@ -20,8 +19,8 @@ export async function GET(request: NextRequest) { console.log("token", token); const upstreamUrl = new URL(UPSTREAM_RECOMMEND_BANNER_LIST_PATH, baseUrl); upstreamUrl.searchParams.set( - "categoryCode", - request.nextUrl.searchParams.get("categoryCode") || "", + "origin", + request.nextUrl.searchParams.get("origin") || "", ); const response = await fetch(upstreamUrl, { diff --git a/src/app/globals.css b/src/app/globals.css index e2cdb3e3..30c9545d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -34,6 +34,8 @@ body { overscroll-behavior-x: none; /* Prevent touch gestures from triggering navigation */ touch-action: pan-y pinch-zoom; + overflow-y: initial !important; + width: 100% !important; } /* React Flow customizations */ diff --git a/src/app/page.tsx b/src/app/page.tsx index 15bb062c..73f7d210 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -57,7 +57,7 @@ function isVideoUrl(url: string) { async function getHeroCards(): Promise { try { - const params = new URLSearchParams({ categoryCode: "" }); + const params = new URLSearchParams({ origin: "canvas" }); const response = await authFetch(`/api/recommend-banners?${params.toString()}`, { method: "GET", }); diff --git a/src/components/HomeProjectsContent.tsx b/src/components/HomeProjectsContent.tsx index d07bbaed..4f86b2c8 100644 --- a/src/components/HomeProjectsContent.tsx +++ b/src/components/HomeProjectsContent.tsx @@ -32,7 +32,7 @@ import { updateCanvasWorkflow, uploadCanvasWorkflowCover, } from "@/lib/canvasWorkflowApi"; -import { Form, Input, Modal, message } from "antd"; +import { Form, Input, Modal, Tooltip, message } from "antd"; type Translate = (key: TranslationKey, values?: Record) => string; type ProjectData = { @@ -225,9 +225,11 @@ function ShowCard({ id, title, subtitle, image, avatar, vip, data, onOpen }: Sho ) : null} */} -

- {subtitle || '-'} -

+ +

+ {subtitle || '-'} +

+
); @@ -1496,7 +1498,7 @@ function ShowsSection() { const workflowId = (result.data as any)?.id; router.push(workflowId ? `/canvas?workflowId=${workflowId}` : "/canvas"); } catch (error) { - window.alert(error instanceof Error ? error.message : t("home.createFailed")); + message.error(error instanceof Error ? error.message : t("home.createFailed")); setIsCreating(false); } }, [isCreating, router, t]); diff --git a/src/components/auth/LoginModal.tsx b/src/components/auth/LoginModal.tsx index fc30827c..e06f5edc 100644 --- a/src/components/auth/LoginModal.tsx +++ b/src/components/auth/LoginModal.tsx @@ -5,6 +5,11 @@ import { Alert, Button, Checkbox, Input, Modal, Segmented } from "antd"; import { useI18n } from "@/i18n"; import { useLoginModalStore } from "@/store/loginModalStore"; import { useUserStore } from "@/store/userStore"; +import { + openLatestPrivacyPolicyLink, + PRIVACY_POLICY_KEY_PRIVACY_POLICY, + PRIVACY_POLICY_KEY_USER_TERMS, +} from "@/utils/openLatestPrivacyPolicyLink"; type LoginMode = "code" | "wechat" | "password"; type CodePurpose = "login" | "wechat-bind"; @@ -30,6 +35,12 @@ const getTokenFromResponse = (payload: ApiResponse): string | null => { return null; }; +function handleAgreementLinkClick(key: string): void { + void openLatestPrivacyPolicyLink(key).catch((error) => { + console.error("Failed to open agreement link:", error); + }); +} + async function readJsonResponse(response: Response, fallbackMessage: string): Promise { const payload = (await response.json()) as ApiResponse; if (!response.ok || payload.status !== "0000") { @@ -505,24 +516,22 @@ export function LoginModal() { {t("auth.wechatReloadQr")}

- 登录即代表同意 - handleAgreementLinkClick(PRIVACY_POLICY_KEY_USER_TERMS)} + className="appearance-none border-0 bg-transparent p-0 text-blue-500 hover:underline" > - 《用户协议》 - - 和 - + {t("common.and")} +

) @@ -590,25 +599,23 @@ export function LoginModal() { onChange={(event) => setAgreementChecked(event.target.checked)} className="text-xs leading-5 text-neutral-500" > - 登录即代表同意 - handleAgreementLinkClick(PRIVACY_POLICY_KEY_USER_TERMS)} + className="appearance-none border-0 bg-transparent p-0 text-blue-500 hover:underline" > - 《用户协议》 - - 和 - + {t("common.and")} + + {t("auth.autoRegisterSuffix")} )} diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 457e61c1..1561dcd8 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -23,6 +23,7 @@ const en = { "common.back": "Back", "common.next": "Next", "common.cancel": "Cancel", + "common.and": " and ", "common.close": "Close", "common.save": "Save", "common.create": "Create", @@ -181,6 +182,10 @@ const en = { "auth.wechatMissingBindToken": "Missing WeChat phone binding token", "auth.wechatPollFailed": "Failed to check WeChat login status. Reload the QR code.", "auth.bindPhoneFailed": "Failed to bind phone", + "auth.agreementPrefix": "By logging in, you agree to", + "auth.userAgreement": "User Agreement", + "auth.privacyPolicy": "Privacy Policy", + "auth.autoRegisterSuffix": ", and unregistered phone numbers will be registered automatically", "quickstart.description": "A node based workflow editor for generative AI pipelines. Connect nodes to build pipelines that transform and generate images, video, audio and 3D assets.", "quickstart.docs": "Docs", "quickstart.discord": "Discord", @@ -880,6 +885,10 @@ const zhCNAuth = { "auth.wechatMissingBindToken": "微信登录缺少绑定凭证", "auth.wechatPollFailed": "微信登录状态检查失败,请重新加载二维码", "auth.bindPhoneFailed": "手机号绑定失败", + "auth.agreementPrefix": "登录即代表同意", + "auth.userAgreement": "《用户协议》", + "auth.privacyPolicy": "《隐私政策》", + "auth.autoRegisterSuffix": ",未注册手机号将自动注册", } satisfies Pick; const zhTWAuth = { @@ -923,6 +932,10 @@ const zhTWAuth = { "auth.wechatMissingBindToken": "微信登入缺少綁定憑證", "auth.wechatPollFailed": "微信登入狀態檢查失敗,請重新載入 QR Code", "auth.bindPhoneFailed": "手機號碼綁定失敗", + "auth.agreementPrefix": "登入即代表同意", + "auth.userAgreement": "《使用者協議》", + "auth.privacyPolicy": "《隱私政策》", + "auth.autoRegisterSuffix": ",未註冊手機號碼將自動註冊", } satisfies Pick; const jaAuth = { @@ -966,11 +979,16 @@ const jaAuth = { "auth.wechatMissingBindToken": "WeChat ログインの紐付け情報が不足しています", "auth.wechatPollFailed": "WeChat ログイン状態の確認に失敗しました。QR コードを再読み込みしてください。", "auth.bindPhoneFailed": "電話番号の紐付けに失敗しました", + "auth.agreementPrefix": "ログインすると、以下に同意したものとみなされます", + "auth.userAgreement": "ユーザー契約", + "auth.privacyPolicy": "プライバシーポリシー", + "auth.autoRegisterSuffix": "。未登録の電話番号は自動的に登録されます", } satisfies Pick; const zhCN: Dictionary = { ...en, ...zhCNAuth, + "common.and": "和", "language.label": "语言", "home.closeBanner": "关闭公告", "home.messages": "消息", @@ -1753,6 +1771,7 @@ Object.assign(zhCN, { const zhTW: Dictionary = { ...zhCN, ...zhTWAuth, + "common.and": "和", "home.closeBanner": "關閉公告", "home.messages": "消息", "home.scanContactService": "掃碼聯繫客服", @@ -2378,6 +2397,7 @@ const zhTW: Dictionary = { const ja: Dictionary = { ...en, ...jaAuth, + "common.and": "、", "home.closeBanner": "お知らせを閉じる", "home.messages": "メッセージ", "home.scanContactService": "スキャンしてサポートに連絡", diff --git a/src/utils/openLatestPrivacyPolicyLink.ts b/src/utils/openLatestPrivacyPolicyLink.ts new file mode 100644 index 00000000..b6b2c689 --- /dev/null +++ b/src/utils/openLatestPrivacyPolicyLink.ts @@ -0,0 +1,68 @@ + + +/** 内容协议 key:使用说明 */ +export const PRIVACY_POLICY_KEY_USAGE_TUTORIAL = "web.usageTutorial"; +/** 内容协议 key:AI 功能使用须知 */ +export const PRIVACY_POLICY_KEY_AI_USE_NOTICE = "web.aiUseNotice"; +/** 内容协议 key:用户协议 */ +export const PRIVACY_POLICY_KEY_USER_TERMS = "web.userTerms"; +/** 内容协议 key:隐私政策 */ +export const PRIVACY_POLICY_KEY_PRIVACY_POLICY = "web.privacyPolicy"; +/** 内容协议 key:“Popi.art”付费服务协议 */ +export const PRIVACY_POLICY_KEY_PAYMENT_TERMS = "web.paymentTerms"; +/** 内容协议 key:Popi 存证协议 */ +export const PRIVACY_POLICY_KEY_EVIDENCE_PRESERVATION = "web.popiartEvidencePreservationAgreement"; +/** 内容协议 key:Popi Future Creator 计划用户服务协议 */ +export const PRIVACY_POLICY_KEY_FUTURE_CREATOR = "web.futureCreatorUserServiceAgreement"; + +export interface GetLatestPrivacyPolicyParams { + key: string; +} + +export async function getLatestPrivacyPolicy({ key }: GetLatestPrivacyPolicyParams): Promise { + const params = new URLSearchParams({ key }); + const response = await fetch(`/api/content/privacy-policy/latest?${params.toString()}`, { + method: "GET", + }); + + const payload = (await response.json().catch(() => null)) as unknown; + if (!response.ok) { + const message = + typeof payload === "object" && + payload !== null && + "message" in payload && + typeof payload.message === "string" + ? payload.message + : "Failed to fetch latest privacy policy."; + throw new Error(message); + } + + return payload; +} +interface PrivacyPolicyItem { + id: number; + title: string; + key: string; + type: number; + content: string; + status: number; +} + +interface PrivacyPolicyResponse { + data?: PrivacyPolicyItem; + message?: string; + status?: string; +} + +/** 按 key 拉取最新协议/说明,type 为 2 时在新标签页打开 content 链接 */ +export async function openLatestPrivacyPolicyLink(key: string): Promise { + const res = (await getLatestPrivacyPolicy({ key })) as PrivacyPolicyResponse; + if (res.status && res.status !== "0000") { + throw new Error(res.message || "Failed to fetch latest privacy policy."); + } + const item = res.data; + if (!item?.content || item.type !== 2) { + throw new Error(res.message || "Latest privacy policy link is unavailable."); + } + window.open(item.content, "_blank", "noopener,noreferrer"); +}