Merge pull request #685 from touwaeriol/pr/admin-create-and-redeem-docs
feat(admin): add create-and-redeem endpoint for payment integrations
This commit is contained in:
@@ -290,6 +290,26 @@ const CreditCardIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const RechargeSubscriptionIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M2.25 7.5A2.25 2.25 0 014.5 5.25h15A2.25 2.25 0 0121.75 7.5v9A2.25 2.25 0 0119.5 18.75h-15A2.25 2.25 0 012.25 16.5v-9z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M6.75 12h3m4.5 0h3m-3-3v6'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const GlobeIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -490,7 +510,7 @@ const userNavItems = computed(() => {
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: CreditCardIcon,
|
||||
icon: RechargeSubscriptionIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
]
|
||||
@@ -515,7 +535,7 @@ const personalNavItems = computed(() => {
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: CreditCardIcon,
|
||||
icon: RechargeSubscriptionIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -280,7 +280,7 @@ export default {
|
||||
logout: 'Logout',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: 'My Subscriptions',
|
||||
buySubscription: 'Purchase Subscription',
|
||||
buySubscription: 'Recharge / Subscription',
|
||||
docs: 'Docs',
|
||||
sora: 'Sora Studio'
|
||||
},
|
||||
@@ -3590,15 +3590,17 @@ export default {
|
||||
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
|
||||
},
|
||||
purchase: {
|
||||
title: 'Purchase Page',
|
||||
description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe',
|
||||
enabled: 'Show Purchase Entry',
|
||||
title: 'Recharge / Subscription Page',
|
||||
description: 'Show a "Recharge / Subscription" entry in the sidebar and open the configured URL in an iframe',
|
||||
enabled: 'Show Recharge / Subscription Entry',
|
||||
enabledHint: 'Only shown in standard mode (not simple mode)',
|
||||
url: 'Purchase URL',
|
||||
url: 'Recharge / Subscription URL',
|
||||
urlPlaceholder: 'https://example.com/purchase',
|
||||
urlHint: 'Must be an absolute http(s) URL',
|
||||
iframeWarning:
|
||||
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.'
|
||||
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.',
|
||||
integrationDoc: 'Payment Integration Docs',
|
||||
integrationDocHint: 'Covers endpoint specs, idempotency semantics, and code samples'
|
||||
},
|
||||
soraClient: {
|
||||
title: 'Sora Client',
|
||||
@@ -3882,16 +3884,16 @@ export default {
|
||||
retry: 'Retry'
|
||||
},
|
||||
|
||||
// Purchase Subscription Page
|
||||
// Recharge / Subscription Page
|
||||
purchase: {
|
||||
title: 'Purchase Subscription',
|
||||
description: 'Purchase a subscription via the embedded page',
|
||||
title: 'Recharge / Subscription',
|
||||
description: 'Recharge balance or purchase subscription via the embedded page',
|
||||
openInNewTab: 'Open in new tab',
|
||||
notEnabledTitle: 'Feature not enabled',
|
||||
notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.',
|
||||
notConfiguredTitle: 'Purchase URL not configured',
|
||||
notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.',
|
||||
notConfiguredTitle: 'Recharge / Subscription URL not configured',
|
||||
notConfiguredDesc:
|
||||
'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.'
|
||||
'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
|
||||
@@ -280,7 +280,7 @@ export default {
|
||||
logout: '退出登录',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: '我的订阅',
|
||||
buySubscription: '购买订阅',
|
||||
buySubscription: '充值/订阅',
|
||||
docs: '文档',
|
||||
sora: 'Sora 创作'
|
||||
},
|
||||
@@ -3761,15 +3761,17 @@ export default {
|
||||
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
||||
},
|
||||
purchase: {
|
||||
title: '购买订阅页面',
|
||||
description: '在侧边栏展示”购买订阅”入口,并在页面内通过 iframe 打开指定链接',
|
||||
enabled: '显示购买订阅入口',
|
||||
title: '充值/订阅页面',
|
||||
description: '在侧边栏展示“充值/订阅”入口,并在页面内通过 iframe 打开指定链接',
|
||||
enabled: '显示充值/订阅入口',
|
||||
enabledHint: '仅在标准模式(非简单模式)下展示',
|
||||
url: '购买页面 URL',
|
||||
url: '充值/订阅页面 URL',
|
||||
urlPlaceholder: 'https://example.com/purchase',
|
||||
urlHint: '必须是完整的 http(s) 链接',
|
||||
iframeWarning:
|
||||
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。'
|
||||
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。',
|
||||
integrationDoc: '支付集成文档',
|
||||
integrationDocHint: '包含接口说明、幂等语义及示例代码'
|
||||
},
|
||||
soraClient: {
|
||||
title: 'Sora 客户端',
|
||||
@@ -4052,15 +4054,15 @@ export default {
|
||||
retry: '重试'
|
||||
},
|
||||
|
||||
// Purchase Subscription Page
|
||||
// Recharge / Subscription Page
|
||||
purchase: {
|
||||
title: '购买订阅',
|
||||
description: '通过内嵌页面完成订阅购买',
|
||||
title: '充值/订阅',
|
||||
description: '通过内嵌页面完成充值/订阅',
|
||||
openInNewTab: '新窗口打开',
|
||||
notEnabledTitle: '该功能未开启',
|
||||
notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。',
|
||||
notConfiguredTitle: '购买链接未配置',
|
||||
notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。'
|
||||
notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。',
|
||||
notConfiguredTitle: '充值/订阅链接未配置',
|
||||
notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
|
||||
@@ -1020,6 +1020,26 @@
|
||||
{{ t('admin.settings.purchase.iframeWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Integration Docs -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<a
|
||||
href="https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:underline dark:text-blue-400"
|
||||
download="ADMIN_PAYMENT_INTEGRATION_API.md"
|
||||
>
|
||||
{{ t('admin.settings.purchase.integrationDoc') }}
|
||||
</a>
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.integrationDocHint') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,30 +1,6 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="purchase-page-layout">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
v-if="isValidUrl"
|
||||
:href="purchaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ t('purchase.openInNewTab') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex-1 min-h-0 overflow-hidden">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-12">
|
||||
<div
|
||||
@@ -70,30 +46,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe v-else :src="purchaseUrl" class="h-full w-full border-0" allowfullscreen></iframe>
|
||||
<div v-else class="purchase-embed-shell">
|
||||
<a
|
||||
:href="purchaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary btn-sm purchase-open-fab"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ t('purchase.openInNewTab') }}
|
||||
</a>
|
||||
<iframe
|
||||
:src="purchaseUrl"
|
||||
class="purchase-embed-frame"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const PURCHASE_USER_ID_QUERY_KEY = 'user_id'
|
||||
const PURCHASE_AUTH_TOKEN_QUERY_KEY = 'token'
|
||||
const PURCHASE_THEME_QUERY_KEY = 'theme'
|
||||
const PURCHASE_UI_MODE_QUERY_KEY = 'ui_mode'
|
||||
const PURCHASE_UI_MODE_EMBEDDED = 'embedded'
|
||||
|
||||
const loading = ref(false)
|
||||
const purchaseTheme = ref<'light' | 'dark'>('light')
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
const purchaseEnabled = computed(() => {
|
||||
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
|
||||
})
|
||||
|
||||
function detectTheme(): 'light' | 'dark' {
|
||||
if (typeof document === 'undefined') return 'light'
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function buildPurchaseUrl(
|
||||
baseUrl: string,
|
||||
userId?: number,
|
||||
authToken?: string | null,
|
||||
theme: 'light' | 'dark' = 'light',
|
||||
): string {
|
||||
if (!baseUrl) return baseUrl
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
if (userId) {
|
||||
url.searchParams.set(PURCHASE_USER_ID_QUERY_KEY, String(userId))
|
||||
}
|
||||
if (authToken) {
|
||||
url.searchParams.set(PURCHASE_AUTH_TOKEN_QUERY_KEY, authToken)
|
||||
}
|
||||
url.searchParams.set(PURCHASE_THEME_QUERY_KEY, theme)
|
||||
url.searchParams.set(PURCHASE_UI_MODE_QUERY_KEY, PURCHASE_UI_MODE_EMBEDDED)
|
||||
return url.toString()
|
||||
} catch {
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
const purchaseUrl = computed(() => {
|
||||
return (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
||||
const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
||||
return buildPurchaseUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value)
|
||||
})
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
@@ -102,6 +132,18 @@ const isValidUrl = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
purchaseTheme.value = detectTheme()
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
themeObserver = new MutationObserver(() => {
|
||||
purchaseTheme.value = detectTheme()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
}
|
||||
|
||||
if (appStore.publicSettingsLoaded) return
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -110,12 +152,41 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect()
|
||||
themeObserver = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.purchase-page-layout {
|
||||
@apply flex flex-col gap-6;
|
||||
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
|
||||
@apply flex flex-col;
|
||||
height: calc(100vh - 64px - 4rem);
|
||||
}
|
||||
|
||||
.purchase-embed-shell {
|
||||
@apply relative;
|
||||
@apply h-full w-full overflow-hidden rounded-2xl;
|
||||
@apply bg-gradient-to-b from-gray-50 to-white dark:from-dark-900 dark:to-dark-950;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
.purchase-open-fab {
|
||||
@apply absolute right-3 top-3 z-10;
|
||||
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80;
|
||||
}
|
||||
|
||||
.purchase-embed-frame {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user