From ebc6755b33d911775fb66250a2443a96fb10107f Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 9 Mar 2026 19:38:23 +0800 Subject: [PATCH] feat(frontend): pass locale to iframe embedded pages via lang parameter Embedded pages (purchase subscription, custom pages) now receive the current user locale through a `lang` URL parameter, allowing iframe content to match the user's language preference. Co-Authored-By: Claude Opus 4.6 --- docs/ADMIN_PAYMENT_INTEGRATION_API.md | 14 ++-- .../src/utils/__tests__/embedded-url.spec.ts | 67 +++++++++++++++++++ frontend/src/utils/embedded-url.ts | 7 +- frontend/src/views/user/CustomPageView.vue | 3 +- .../views/user/PurchaseSubscriptionView.vue | 4 +- 5 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 frontend/src/utils/__tests__/embedded-url.spec.ts diff --git a/docs/ADMIN_PAYMENT_INTEGRATION_API.md b/docs/ADMIN_PAYMENT_INTEGRATION_API.md index 4cc21594..f674f86c 100644 --- a/docs/ADMIN_PAYMENT_INTEGRATION_API.md +++ b/docs/ADMIN_PAYMENT_INTEGRATION_API.md @@ -99,16 +99,17 @@ curl -X POST "${BASE}/api/v1/admin/users/123/balance" \ }' ``` -### 4) 购买页 URL Query 透传(iframe / 新窗口一致) -当 Sub2API 打开 `purchase_subscription_url` 时,会统一追加: +### 4) 购买页 / 自定义页面 URL Query 透传(iframe / 新窗口一致) +当 Sub2API 打开 `purchase_subscription_url` 或用户侧自定义页面 iframe URL 时,会统一追加: - `user_id` - `token` - `theme`(`light` / `dark`) +- `lang`(例如 `zh` / `en`,用于向嵌入页传递当前界面语言) - `ui_mode`(固定 `embedded`) 示例: ```text -https://pay.example.com/pay?user_id=123&token=&theme=light&ui_mode=embedded +https://pay.example.com/pay?user_id=123&token=&theme=light&lang=zh&ui_mode=embedded ``` ### 5) 失败处理建议 @@ -218,16 +219,17 @@ curl -X POST "${BASE}/api/v1/admin/users/123/balance" \ }' ``` -### 4) Purchase URL query forwarding (iframe and new tab) -When Sub2API opens `purchase_subscription_url`, it appends: +### 4) Purchase / Custom Page URL query forwarding (iframe and new tab) +When Sub2API opens `purchase_subscription_url` or a user-facing custom page iframe URL, it appends: - `user_id` - `token` - `theme` (`light` / `dark`) +- `lang` (for example `zh` / `en`, used to pass the current UI language to the embedded page) - `ui_mode` (fixed: `embedded`) Example: ```text -https://pay.example.com/pay?user_id=123&token=&theme=light&ui_mode=embedded +https://pay.example.com/pay?user_id=123&token=&theme=light&lang=zh&ui_mode=embedded ``` ### 5) Failure handling recommendations diff --git a/frontend/src/utils/__tests__/embedded-url.spec.ts b/frontend/src/utils/__tests__/embedded-url.spec.ts new file mode 100644 index 00000000..0026b7dd --- /dev/null +++ b/frontend/src/utils/__tests__/embedded-url.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildEmbeddedUrl, detectTheme } from '../embedded-url' + +describe('embedded-url', () => { + const originalLocation = window.location + + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { + origin: 'https://app.example.com', + href: 'https://app.example.com/user/purchase', + }, + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }) + document.documentElement.classList.remove('dark') + vi.restoreAllMocks() + }) + + it('adds embedded query parameters including locale and source context', () => { + const result = buildEmbeddedUrl( + 'https://pay.example.com/checkout?plan=pro', + 42, + 'token-123', + 'dark', + 'zh-CN', + ) + + const url = new URL(result) + expect(url.searchParams.get('plan')).toBe('pro') + expect(url.searchParams.get('user_id')).toBe('42') + expect(url.searchParams.get('token')).toBe('token-123') + expect(url.searchParams.get('theme')).toBe('dark') + expect(url.searchParams.get('lang')).toBe('zh-CN') + expect(url.searchParams.get('ui_mode')).toBe('embedded') + expect(url.searchParams.get('src_host')).toBe('https://app.example.com') + expect(url.searchParams.get('src_url')).toBe('https://app.example.com/user/purchase') + }) + + it('omits optional params when they are empty', () => { + const result = buildEmbeddedUrl('https://pay.example.com/checkout', undefined, '', 'light') + + const url = new URL(result) + expect(url.searchParams.get('theme')).toBe('light') + expect(url.searchParams.get('ui_mode')).toBe('embedded') + expect(url.searchParams.has('user_id')).toBe(false) + expect(url.searchParams.has('token')).toBe(false) + expect(url.searchParams.has('lang')).toBe(false) + }) + + it('returns original string for invalid url input', () => { + expect(buildEmbeddedUrl('not a url', 1, 'token')).toBe('not a url') + }) + + it('detects dark mode from document root class', () => { + document.documentElement.classList.add('dark') + expect(detectTheme()).toBe('dark') + }) +}) diff --git a/frontend/src/utils/embedded-url.ts b/frontend/src/utils/embedded-url.ts index 9319ee07..e70d30b4 100644 --- a/frontend/src/utils/embedded-url.ts +++ b/frontend/src/utils/embedded-url.ts @@ -1,12 +1,13 @@ /** * Shared URL builder for iframe-embedded pages. * Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs - * with user_id, token, theme, ui_mode, src_host, and src parameters. + * with user_id, token, theme, lang, ui_mode, src_host, and src parameters. */ const EMBEDDED_USER_ID_QUERY_KEY = 'user_id' const EMBEDDED_AUTH_TOKEN_QUERY_KEY = 'token' const EMBEDDED_THEME_QUERY_KEY = 'theme' +const EMBEDDED_LANG_QUERY_KEY = 'lang' const EMBEDDED_UI_MODE_QUERY_KEY = 'ui_mode' const EMBEDDED_UI_MODE_VALUE = 'embedded' const EMBEDDED_SRC_HOST_QUERY_KEY = 'src_host' @@ -17,6 +18,7 @@ export function buildEmbeddedUrl( userId?: number, authToken?: string | null, theme: 'light' | 'dark' = 'light', + lang?: string, ): string { if (!baseUrl) return baseUrl try { @@ -28,6 +30,9 @@ export function buildEmbeddedUrl( url.searchParams.set(EMBEDDED_AUTH_TOKEN_QUERY_KEY, authToken) } url.searchParams.set(EMBEDDED_THEME_QUERY_KEY, theme) + if (lang) { + url.searchParams.set(EMBEDDED_LANG_QUERY_KEY, lang) + } url.searchParams.set(EMBEDDED_UI_MODE_QUERY_KEY, EMBEDDED_UI_MODE_VALUE) // Source tracking: let the embedded page know where it's being loaded from if (typeof window !== 'undefined') { diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue index 8e711e86..ce930d96 100644 --- a/frontend/src/views/user/CustomPageView.vue +++ b/frontend/src/views/user/CustomPageView.vue @@ -75,7 +75,7 @@ import AppLayout from '@/components/layout/AppLayout.vue' import Icon from '@/components/icons/Icon.vue' import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url' -const { t } = useI18n() +const { t, locale } = useI18n() const route = useRoute() const appStore = useAppStore() const authStore = useAuthStore() @@ -107,6 +107,7 @@ const embeddedUrl = computed(() => { authStore.user?.id, authStore.token, pageTheme.value, + locale.value, ) }) diff --git a/frontend/src/views/user/PurchaseSubscriptionView.vue b/frontend/src/views/user/PurchaseSubscriptionView.vue index ef782f32..931591ab 100644 --- a/frontend/src/views/user/PurchaseSubscriptionView.vue +++ b/frontend/src/views/user/PurchaseSubscriptionView.vue @@ -76,7 +76,7 @@ import AppLayout from '@/components/layout/AppLayout.vue' import Icon from '@/components/icons/Icon.vue' import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url' -const { t } = useI18n() +const { t, locale } = useI18n() const appStore = useAppStore() const authStore = useAuthStore() @@ -90,7 +90,7 @@ const purchaseEnabled = computed(() => { const purchaseUrl = computed(() => { const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim() - return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value) + return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value, locale.value) }) const isValidUrl = computed(() => {