瀏覽代碼

新增发布脚本

main
leiyun 1 月之前
父節點
當前提交
9205423626
共有 13 個檔案被更改,包括 519 行新增56 行删除
  1. +18
    -3
      app/components/LoginModal.vue
  2. +6
    -5
      app/composables/useApi.js
  3. +57
    -0
      app/composables/useClientDevice.js
  4. +3
    -2
      app/composables/useIdPhoto.js
  5. +3
    -2
      app/composables/useOldPhoto.js
  6. +3
    -2
      app/composables/usePortrait.js
  7. +44
    -0
      app/composables/usePvcv.js
  8. +4
    -3
      app/composables/useUpload.js
  9. +55
    -8
      app/pages/portrait-photo.vue
  10. +176
    -31
      app/pages/recharge.vue
  11. +29
    -0
      app/plugins/pvcv.client.js
  12. +32
    -0
      ecosystem.config.cjs
  13. +89
    -0
      restart.sh

+ 18
- 3
app/components/LoginModal.vue 查看文件

@@ -33,6 +33,7 @@
<div class="px-8">
<div class="flex border-b border-[var(--c-e2e8f0)]">
<button
v-if="!isMobileClient"
:class="[
'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
activeTab === 'qr'
@@ -58,7 +59,7 @@
</div>

<!-- 微信扫码登录 -->
<div v-if="activeTab === 'qr'" class="px-8 py-6">
<div v-if="!isMobileClient && activeTab === 'qr'" class="px-8 py-6">
<div class="flex flex-col items-center">
<!-- 二维码区域 -->
<div
@@ -210,10 +211,12 @@ const emit = defineEmits(['close', 'login-success'])
const { post } = useApi()
const { setLogin } = useUser()
const { inviteCode, withInvite, clearInvite } = useInvite()
const { isMobileClient } = useClientDevice()
const { reportCv } = usePvcv()
const toast = useToast()

// Tab
const activeTab = ref('qr')
const activeTab = ref(isMobileClient.value ? 'phone' : 'qr')

// ===== 微信扫码 =====
const qrLoading = ref(true)
@@ -226,6 +229,7 @@ let qrTimer = null

// 获取二维码
async function refreshQR() {
if (isMobileClient.value) return
clearInterval(qrTimer)
qrLoading.value = true
qrExpired.value = false
@@ -365,6 +369,7 @@ function handleLoginSuccess(data) {
const invited = Boolean(inviteCode.value)
setLogin(data)
clearInvite()
reportCv('login_success', { page_key: 'login_dialog' })
toast.add({
title: invited && data.is_register ? '登录并领取成功' : '登录成功',
description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '',
@@ -375,7 +380,17 @@ function handleLoginSuccess(data) {

// 初始化
onMounted(() => {
refreshQR()
reportCv('login_dialog_show', { page_key: 'login_dialog' })
if (!isMobileClient.value) refreshQR()
})

watch(isMobileClient, (mobile) => {
if (mobile) {
clearInterval(qrTimer)
activeTab.value = 'phone'
} else if (activeTab.value === 'qr' && !qrData.value.url) {
refreshQR()
}
})

onUnmounted(() => {


+ 6
- 5
app/composables/useApi.js 查看文件

@@ -6,7 +6,7 @@
*
* 自动注入(对齐 V2):
* Header: { token, timestr, nonce }
* Body: { token, uid, mac, base_timestamp, client:1, source, client_ios, version, version_code }
* Body: { token, uid, mac, base_timestamp, client, source, client_ios, version, version_code }
*
* 后端响应规范:
* { code: 0, data: {...}, list: [...], msg: '...' } // 成功
@@ -44,6 +44,7 @@ export function useApi() {
const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' })
const openIdCookie = useCookie('open_id', { path: '/' })
const sourceCookie = useCookie('source', { path: '/' })
const { getClientId, getClientIos } = useClientDevice()

/**
* 登录态从 useState 共享状态读取(与 useUser 同源)。
@@ -116,9 +117,9 @@ export function useApi() {
uid: openIdState.value || openIdCookie.value || '',
mac: macCookie.value || SSR_MAC,
base_timestamp: timestamp,
client: 1,
client: getClientId(),
source: sourceCookie.value || '',
client_ios: 0,
client_ios: getClientIos(),
version: '1.0.0',
version_code: 1,
}
@@ -180,9 +181,9 @@ export function useApi() {
uid: openIdState.value || openIdCookie.value || '',
mac: macCookie.value || SSR_MAC,
base_timestamp: Math.floor(Date.now() / 1000),
client: 1,
client: getClientId(),
source: sourceCookie.value || '',
client_ios: 0,
client_ios: getClientIos(),
version: '1.0.0',
version_code: 1,
...body,


+ 57
- 0
app/composables/useClientDevice.js 查看文件

@@ -0,0 +1,57 @@
const MOBILE_UA_RE = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i
const IOS_UA_RE = /iphone|ipad|ipod|ios/i
const WECHAT_UA_RE = /micromessenger/i

export function resolveClientDevice(input = {}) {
const host = String(input.host || '').split(':')[0].toLowerCase()
const userAgent = String(input.userAgent || '')
const isMobileHost = host === 'm.aionline.cc' || host.startsWith('m.')
const isMobile = isMobileHost || MOBILE_UA_RE.test(userAgent)

return {
client: isMobile ? 2 : 1,
isMobile,
isIos: IOS_UA_RE.test(userAgent),
isWechat: WECHAT_UA_RE.test(userAgent),
host,
userAgent,
}
}

export function useClientDevice() {
function getHost() {
if (import.meta.client) return window.location.hostname || ''
const headers = useRequestHeaders(['host'])
return headers.host || ''
}

function getUserAgent() {
if (import.meta.client) return navigator.userAgent || ''
const headers = useRequestHeaders(['user-agent'])
return headers['user-agent'] || ''
}

function getMeta() {
return resolveClientDevice({
host: getHost(),
userAgent: getUserAgent(),
})
}

const device = computed(() => getMeta())
const clientId = computed(() => device.value.client)
const isMobileClient = computed(() => device.value.isMobile)
const isIosClient = computed(() => device.value.isIos)
const isWechatClient = computed(() => device.value.isWechat)

return {
device,
clientId,
isMobileClient,
isIosClient,
isWechatClient,
getClientId: () => getMeta().client,
getClientIos: () => (getMeta().isIos ? 1 : 0),
getClientMeta: getMeta,
}
}

+ 3
- 2
app/composables/useIdPhoto.js 查看文件

@@ -5,6 +5,7 @@ export function useIdPhoto() {
const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' })
const openIdCookie = useCookie('open_id', { path: '/' })
const sourceCookie = useCookie('source', { path: '/' })
const { getClientId, getClientIos } = useClientDevice()
const tokenState = useState('auth_token', () => tokenCookie.value || '')
const openIdState = useState('auth_open_id', () => openIdCookie.value || '')

@@ -41,9 +42,9 @@ export function useIdPhoto() {
formData.append('uid', openIdState.value || openIdCookie.value || '')
formData.append('mac', macCookie.value || 'pc-id-photo')
formData.append('base_timestamp', String(Math.floor(Date.now() / 1000)))
formData.append('client', '1')
formData.append('client', String(getClientId()))
formData.append('source', sourceCookie.value || '')
formData.append('client_ios', '0')
formData.append('client_ios', String(getClientIos()))
formData.append('version', '1.0.0')
formData.append('version_code', '1')
formData.append('request_id', settings.request_id || createUuid())


+ 3
- 2
app/composables/useOldPhoto.js 查看文件

@@ -11,6 +11,7 @@ export function useOldPhoto() {
const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' })
const openIdCookie = useCookie('open_id', { path: '/' })
const sourceCookie = useCookie('source', { path: '/' })
const { getClientId, getClientIos } = useClientDevice()
const tokenState = useState('auth_token', () => tokenCookie.value || '')
const openIdState = useState('auth_open_id', () => openIdCookie.value || '')

@@ -49,9 +50,9 @@ export function useOldPhoto() {
formData.append('uid', openIdState.value || openIdCookie.value || '')
formData.append('mac', macCookie.value || 'pc-old-photo')
formData.append('base_timestamp', String(Math.floor(Date.now() / 1000)))
formData.append('client', '1')
formData.append('client', String(getClientId()))
formData.append('source', sourceCookie.value || '')
formData.append('client_ios', '0')
formData.append('client_ios', String(getClientIos()))
formData.append('version', '1.0.0')
formData.append('version_code', '1')
formData.append('request_id', settings.request_id || createUuid())


+ 3
- 2
app/composables/usePortrait.js 查看文件

@@ -12,6 +12,7 @@ export function usePortrait() {
const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' })
const openIdCookie = useCookie('open_id', { path: '/' })
const sourceCookie = useCookie('source', { path: '/' })
const { getClientId, getClientIos } = useClientDevice()
const tokenState = useState('auth_token', () => tokenCookie.value || '')
const openIdState = useState('auth_open_id', () => openIdCookie.value || '')

@@ -52,9 +53,9 @@ export function usePortrait() {
formData.append('uid', openIdState.value || openIdCookie.value || '')
formData.append('mac', macCookie.value || 'pc-portrait')
formData.append('base_timestamp', String(Math.floor(Date.now() / 1000)))
formData.append('client', '1')
formData.append('client', String(getClientId()))
formData.append('source', sourceCookie.value || '')
formData.append('client_ios', '0')
formData.append('client_ios', String(getClientIos()))
formData.append('version', '1.0.0')
formData.append('version_code', '1')
formData.append('request_id', settings.request_id || createUuid())


+ 44
- 0
app/composables/usePvcv.js 查看文件

@@ -0,0 +1,44 @@
export function usePvcv() {
const { post } = useApi()
const { userInfo } = useUser()
const route = useRoute()
const sourceCookie = useCookie('source', { path: '/' })

function getPagePayload(toRoute = route, extra = {}) {
const path = toRoute.path || window.location.pathname || '/'
const fullPath = toRoute.fullPath || path
const url = import.meta.client ? `${window.location.origin}${fullPath}` : fullPath
return {
source: sourceCookie.value || '',
referrer: extra.referrer || (import.meta.client ? document.referrer : ''),
url,
path,
page_key: extra.page_key || String(toRoute.name || path).replace(/^\/+/, ''),
user_id: userInfo.value?.user_id || 0,
user_agent: import.meta.client ? navigator.userAgent : '',
...extra,
}
}

async function reportPv(toRoute = route, extra = {}) {
if (!import.meta.client) return
try {
await post('/common/pvcv', getPagePayload(toRoute, extra))
} catch {
// 统计不能影响主流程
}
}

async function reportCv(key, extra = {}) {
if (!key) return
await reportPv(route, {
...extra,
key,
})
}

return {
reportPv,
reportCv,
}
}

+ 4
- 3
app/composables/useUpload.js 查看文件

@@ -3,7 +3,7 @@
*
* - POST multipart/form-data 到 /api/common/uploadpic(走服务端代理)
* - Header: { token, timestr, nonce }
* - FormData: { file, token, mac, client:1, source, version, version_code, upload_type:1 }
* - FormData: { file, token, mac, client, source, version, version_code, upload_type:1 }
*/
import md5 from '~/utils/md5'

@@ -12,6 +12,7 @@ export function useUpload() {
const tokenCookie = useCookie('token', { path: '/' })
const openIdCookie = useCookie('open_id', { path: '/' })
const sourceCookie = useCookie('source', { path: '/' })
const { getClientId, getClientIos } = useClientDevice()

function getNonce() {
const timeStr = Date.now().toString()
@@ -40,9 +41,9 @@ export function useUpload() {
formData.append('uid', openIdCookie.value || '')
formData.append('mac', macCookie.value || '')
formData.append('base_timestamp', timestamp)
formData.append('client', '1')
formData.append('client', String(getClientId()))
formData.append('source', sourceCookie.value || '')
formData.append('client_ios', '0')
formData.append('client_ios', String(getClientIos()))
formData.append('version', '1.0.0')
formData.append('version_code', '1')
formData.append('upload_type', '1')


+ 55
- 8
app/pages/portrait-photo.vue 查看文件

@@ -162,8 +162,8 @@
<!-- 4. 比例与张数 -->
<div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
<div class="flex items-start justify-between gap-4">
<h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">图片比例与张数</h3>
<span class="mt-1 text-xs font-extrabold text-[var(--c-0b8cff)]">{{ unitPoints }}点/张 · 共{{ totalPoints }}点</span>
<h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">{{ quantityEnabled ? '图片比例与张数' : '图片比例' }}</h3>
<span class="mt-1 text-xs font-extrabold text-[var(--c-0b8cff)]">{{ quantityPriceText }}</span>
</div>

<div class="mt-4 flex flex-wrap gap-2.5">
@@ -182,7 +182,9 @@
</button>
</div>

<div class="mt-5 flex items-center gap-4">
<div v-if="configLoading" class="mt-5 h-14 animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" />

<div v-else-if="quantityEnabled" class="mt-5 flex items-center gap-4">
<span class="text-sm font-bold text-[var(--c-526174)]">生成张数</span>
<div class="flex items-center gap-3">
<button
@@ -207,6 +209,14 @@
</div>
<span class="text-xs text-[var(--c-8a97a8)]">一次最多 {{ maxCount }} 张,按张扣点,失败的不扣</span>
</div>

<div v-else class="mt-5 rounded-2xl bg-[var(--c-f7faff)] px-4 py-3">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-bold text-[var(--c-526174)]">生成张数</span>
<span class="rounded-full bg-[var(--c-eef7ff)] px-3 py-1 text-xs font-extrabold text-[var(--c-0b8cff)]">固定 1 张</span>
</div>
<p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]">后台已关闭多张生成,本次只生成 1 张。</p>
</div>
</div>

<!-- 5. 补充描述 -->
@@ -288,7 +298,7 @@
<span class="animate-pulse-dot3 size-2.5 rounded-full bg-[var(--c-1f8cff)]" />
</div>
<div class="text-sm font-bold text-[var(--c-172033)]">
{{ job.success_count || 0 }} / {{ job.count || quantity }} 张 · 已等待 {{ waitedSeconds }}s
{{ job.success_count || 0 }} / {{ job.count || effectiveQuantity }} 张 · 已等待 {{ waitedSeconds }}s
</div>
<div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div>
</div>
@@ -484,6 +494,7 @@ const activeRatio = ref('')
const options = reactive({})
const quantity = ref(1)
const maxCount = ref(4)
const quantityEnabled = ref(true)
const unitPoints = ref(0)
const configLoading = ref(true)
const submitting = ref(false)
@@ -508,7 +519,12 @@ const currentStyle = computed(() => styles.value.find(item => item.key === activ
const currentGroups = computed(() => currentStyle.value.groups || [])
const resultUrls = computed(() => job.value.result_urls || [])
const activeResultUrl = computed(() => resultUrls.value[activeIndex.value] || resultUrls.value[0] || '')
const totalPoints = computed(() => unitPoints.value * quantity.value)
const effectiveQuantity = computed(() => (quantityEnabled.value ? quantity.value : 1))
const totalPoints = computed(() => unitPoints.value * effectiveQuantity.value)
const quantityPriceText = computed(() => {
if (!unitPoints.value) return quantityEnabled.value ? `共${totalPoints.value}点` : '固定1张'
return quantityEnabled.value ? `${unitPoints.value}点/张 · 共${totalPoints.value}点` : `${unitPoints.value}点/张 · 固定1张`
})
const currentPoints = computed(() => Number(balance.points || 0))
const isBalanceInsufficient = computed(
() => user.isLogin.value && totalPoints.value > 0 && currentPoints.value < totalPoints.value,
@@ -530,20 +546,51 @@ function showToast(message, color = 'error') {
toast.add({ title: message, color })
}

function pickFirstDefined(values) {
return values.find(value => value !== undefined && value !== null && value !== '')
}

function normalizeQuantityEnabled(data = {}) {
const raw = pickFirstDefined([
data.quantity_enabled,
data.quantity_supported,
data.product?.quantity_enabled,
data.billing?.product?.quantity_enabled,
data.billing?.quote?.product?.quantity_enabled,
])
if (raw === undefined) return Number(data.max_count || 1) > 1
if (typeof raw === 'boolean') return raw
return !['0', 'false', 'no', 'off', 'unsupported', 'not_support', '不支持'].includes(String(raw).toLowerCase())
}

function applyQuantityConfig(data = {}) {
const enabled = normalizeQuantityEnabled(data)
const rawMax = pickFirstDefined([
data.max_count,
data.product?.max_quantity,
data.billing?.product?.max_quantity,
data.billing?.quote?.product?.max_quantity,
])
const nextMax = enabled ? Math.max(1, Number(rawMax || maxCount.value || 1)) : 1
quantityEnabled.value = enabled
maxCount.value = nextMax
quantity.value = enabled ? Math.min(Math.max(1, quantity.value), nextMax) : 1
}

// ==================== 配置 / 示例 / 余额 ====================
async function loadConfig(silent = false) {
try {
const res = await post('/portrait/config', {
style: activeStyle.value || undefined,
model: activeModel.value || undefined,
count: quantity.value,
count: effectiveQuantity.value,
})
const data = res.data || {}
models.value = data.models || []
styles.value = data.styles || []
ratios.value = data.ratios || []
tips.value = data.tips || []
maxCount.value = Number(data.max_count || 4)
applyQuantityConfig(data)
unitPoints.value = Number(data.unit_points || 0)
if (!activeModel.value) activeModel.value = data.default_model || models.value[0]?.key || ''
if (!activeRatio.value) {
@@ -682,7 +729,7 @@ async function handleSubmit() {
style: activeStyle.value,
model: activeModel.value,
ratio: activeRatio.value,
count: quantity.value,
count: effectiveQuantity.value,
extra_prompt: extraPrompt.value,
request_id: createRequestId(),
options: { ...options },


+ 176
- 31
app/pages/recharge.vue 查看文件

@@ -108,16 +108,16 @@
<div class="rounded-2xl bg-[var(--c-ffffff)] p-6 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
<!-- 未开始 -->
<template v-if="payState === 'idle'">
<UIcon name="i-lucide-qr-code" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" />
<div class="mb-1 text-base font-bold text-[var(--c-172033)]">微信扫码支付</div>
<div class="text-sm text-[var(--c-a0aabb)]">选择套餐后点击「确认充值」</div>
<div class="text-sm text-[var(--c-a0aabb)]">这里会生成支付二维码</div>
<UIcon :name="payIdleIcon" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" />
<div class="mb-1 text-base font-bold text-[var(--c-172033)]">{{ payIdleTitle }}</div>
<div class="text-sm text-[var(--c-a0aabb)]">{{ payIdleDesc1 }}</div>
<div class="text-sm text-[var(--c-a0aabb)]">{{ payIdleDesc2 }}</div>
</template>

<!-- 生成中 / 待支付 / 成功 -->
<template v-else>
<div class="mb-1 text-base font-bold text-[var(--c-172033)]">
{{ payState === 'success' ? '充值成功' : '微信扫码支付' }}
{{ payPanelTitle }}
</div>
<div class="mb-4 text-sm text-[var(--c-8a95a6)]">
{{ payStateText }}
@@ -134,6 +134,11 @@
<span class="text-sm font-bold text-[var(--c-17a65a)]">+{{ current?.points || 0 }} 点已到账</span>
</div>

<div v-else-if="isMobileClient && payState === 'pending'" class="flex flex-col items-center gap-2">
<UIcon name="i-lucide-smartphone" class="size-12 text-[var(--c-0b8cff)]" />
<span class="text-xs font-bold text-[var(--c-0b8cff)]">等待支付完成...</span>
</div>

<ClientOnly v-else-if="codeUrl">
<vue-qr :text="codeUrl" :size="180" :margin="8" />
</ClientOnly>
@@ -183,7 +188,12 @@ useSeoMeta({

const { userInfo, isLogin, showLogin, refreshBalance } = useUser()
const { post } = useApi()
const { reportCv } = usePvcv()
const toast = useToast()
const route = useRoute()
const { clientId, isMobileClient, isWechatClient } = useClientDevice()
const wxPayOpenId = useCookie('wechat_pay_open_id', { maxAge: 30 * 24 * 3600, path: '/' })
const WECHAT_MP_APPID = 'wxd2aa05b2249b16ce'

const packages = ref([])
const current = ref(null)
@@ -202,9 +212,30 @@ const buyText = computed(() => {
if (!current.value) return '请选择套餐'
return `确认充值(${current.value.price_yuan}元)`
})
const payIdleIcon = computed(() => (isMobileClient.value ? 'i-lucide-smartphone' : 'i-lucide-qr-code'))
const payIdleTitle = computed(() => {
if (!isMobileClient.value) return '微信扫码支付'
return isWechatClient.value ? '微信内支付' : '手机微信支付'
})
const payIdleDesc1 = computed(() => {
if (!isMobileClient.value) return '选择套餐后点击「确认充值」'
return isWechatClient.value ? '选择套餐后将拉起微信支付' : '选择套餐后将跳转微信支付页'
})
const payIdleDesc2 = computed(() => {
if (!isMobileClient.value) return '这里会生成支付二维码'
return '支付完成后点数会自动到账'
})
const payPanelTitle = computed(() => {
if (payState.value === 'success') return '充值成功'
if (!isMobileClient.value) return '微信扫码支付'
return isWechatClient.value ? '微信内支付' : '手机微信支付'
})
const payStateText = computed(() => {
if (payState.value === 'loading') return '正在创建订单...'
if (payState.value === 'pending') return '请使用微信扫描二维码完成支付'
if (payState.value === 'loading') return isMobileClient.value ? '正在创建订单并准备拉起支付...' : '正在创建订单...'
if (payState.value === 'pending') {
if (!isMobileClient.value) return '请使用微信扫描二维码完成支付'
return isWechatClient.value ? '请在微信支付弹窗中完成支付' : '请在打开的微信支付页面完成支付'
}
if (payState.value === 'success') return '点数已到账,可以开始创作了'
return '订单创建失败'
})
@@ -244,32 +275,140 @@ function selectPackage(pkg) {
current.value = pkg
}

// ==================== 支付(PC 走微信 NATIVE 扫码) ====================
async function startPay() {
// ==================== 支付(PC 扫码 / H5 微信支付) ====================
function isInvalidOpenId(value) {
return !value || value === 'undefined' || value === 'null'
}

function cleanOAuthQuery() {
if (!import.meta.client) return
const url = new URL(window.location.href)
url.searchParams.delete('code')
url.searchParams.delete('state')
window.history.replaceState({}, document.title, url.toString())
}

function getWechatAuthUrl(packageId) {
const url = new URL(window.location.href)
url.searchParams.delete('code')
url.searchParams.delete('state')
const redirectUri = encodeURIComponent(url.toString())
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${WECHAT_MP_APPID}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=pointbuy_${packageId}#wechat_redirect`
}

function getQueryValue(value) {
return Array.isArray(value) ? value[0] : value
}

function invokeWechatPay(payInfo) {
if (!import.meta.client) return

const payload = {
appId: payInfo.appId || payInfo.appid || WECHAT_MP_APPID,
timeStamp: String(payInfo.timeStamp || payInfo.timestamp || ''),
nonceStr: payInfo.nonceStr || payInfo.noncestr || '',
package: payInfo.package || (payInfo.prepay_id ? `prepay_id=${payInfo.prepay_id}` : ''),
signType: payInfo.signType || 'RSA',
paySign: payInfo.paySign || payInfo.paysign || '',
}

const onReady = () => {
window.WeixinJSBridge.invoke('getBrandWCPayRequest', payload, (res = {}) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
pollOnce()
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
cancelPay()
showMsg('支付已取消', 'warning')
} else {
payState.value = 'error'
showMsg('支付失败,请重试')
}
})
}

if (typeof window.WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', onReady, false)
} else {
onReady()
}
}

async function handleWechatOAuthReturn() {
if (!import.meta.client || !isMobileClient.value || !isWechatClient.value) return
const code = getQueryValue(route.query.code)
const state = String(getQueryValue(route.query.state) || '')
if (!code || !state.startsWith('pointbuy_')) return

try {
const res = await post('/common/getopenid', { code, client: 2 })
const openId = res.data?.wx_open_id || ''
if (!openId) throw new Error('获取 openid 失败')
wxPayOpenId.value = openId
cleanOAuthQuery()

const packageId = Number(state.replace('pointbuy_', ''))
const target = packages.value.find(item => Number(item.id) === packageId)
if (target) current.value = target
if (current.value) await startPay({ skipOauth: true })
} catch (e) {
cleanOAuthQuery()
showMsg(e.message || '微信支付身份获取失败,请重新点击充值')
}
}

async function startPay(options = {}) {
if (!current.value) return
reportCv('point_recharge_click', { page_key: 'recharge' })
if (!isLogin.value) {
showLogin.value = true
return
}

if (isMobileClient.value && isWechatClient.value && isInvalidOpenId(wxPayOpenId.value) && !options.skipOauth) {
window.location.href = getWechatAuthUrl(current.value.id)
return
}

payState.value = 'loading'
codeUrl.value = ''
try {
const res = await post('/point/createorder', {
const payload = {
package_id: current.value.id,
client: 1, // PC
})
client: clientId.value,
base_in_wechat: isWechatClient.value ? 1 : 0,
}
if (isMobileClient.value && isWechatClient.value) {
payload.open_id = wxPayOpenId.value
}

const res = await post('/point/createorder', payload)
const data = res.data || {}
// NATIVE 支付返回二维码链接(兼容多种字段命名)
const url = data.code_url || data.codeUrl || data.qr_code || ''
orderNo.value = data.order_no || ''
reportCv('point_recharge_order', { page_key: 'recharge' })

if (data.mode === 'wxpay_jsapi' || data.package || data.paySign) {
payState.value = 'pending'
startPolling()
invokeWechatPay(data)
return
}

const h5Url = data.h5_url || data.h5Url || ''
if (data.mode === 'wxpay_h5' && h5Url) {
payState.value = 'pending'
startPolling()
window.location.href = h5Url
return
}

const url = data.code_url || data.codeUrl || data.qr_code || ''
if (url) {
codeUrl.value = url
payState.value = 'pending'
startPolling()
} else {
payState.value = 'error'
showMsg(res.msg || 'PC 端支付暂不可用')
showMsg(res.msg || '支付暂不可用')
}
} catch (e) {
payState.value = 'error'
@@ -278,22 +417,27 @@ async function startPay() {
}

// 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账
async function pollOnce() {
if (!orderNo.value) return
try {
const res = await post('/point/orderstatus', { order_no: orderNo.value })
const d = res.data || {}
if (Number(d.status) === 20) {
clearInterval(pollingTimer)
payState.value = 'success'
balance.points = d.balance ?? balance.points
refreshBalance()
reportCv('point_recharge_paid', { page_key: 'recharge' })
}
} catch {
// 单次失败不中断轮询
}
}

function startPolling() {
clearInterval(pollingTimer)
pollingTimer = setInterval(async () => {
if (!orderNo.value) return
try {
const res = await post('/point/orderstatus', { order_no: orderNo.value })
const d = res.data || {}
if (Number(d.status) === 20) {
clearInterval(pollingTimer)
payState.value = 'success'
balance.points = d.balance ?? balance.points
refreshBalance() // 同步 header 点数
}
} catch {
// 单次失败不中断轮询
}
await pollOnce()
}, 2000)
}

@@ -309,9 +453,10 @@ function finishPay() {
loadBalance()
}

onMounted(() => {
loadPackages()
loadBalance()
onMounted(async () => {
await loadPackages()
await loadBalance()
await handleWechatOAuthReturn()
})

watch(isLogin, async (loggedIn) => {


+ 29
- 0
app/plugins/pvcv.client.js 查看文件

@@ -0,0 +1,29 @@
export default defineNuxtPlugin(() => {
const router = useRouter()
const { reportPv } = usePvcv()

let lastFullPath = ''
let lastReportedAt = 0

function track(to, from) {
if (!to || to.fullPath === lastFullPath) return
const now = Date.now()
if (now - lastReportedAt < 300 && to.fullPath === lastFullPath) return

const referrer = from?.fullPath
? `${window.location.origin}${from.fullPath}`
: document.referrer

lastFullPath = to.fullPath
lastReportedAt = now
reportPv(to, { referrer })
}

router.afterEach((to, from) => {
window.setTimeout(() => track(to, from), 0)
})

window.setTimeout(() => {
track(router.currentRoute.value, null)
}, 0)
})

+ 32
- 0
ecosystem.config.cjs 查看文件

@@ -0,0 +1,32 @@
const appName = process.env.APP_NAME || 'pc_nuxt'
const host = process.env.HOST || '0.0.0.0'
const port = process.env.PORT || '6888'
const apiBase = process.env.API_BASE || 'https://api.aionline.cc'
const env = {
NODE_ENV: 'production',
HOST: host,
PORT: port,
NITRO_HOST: host,
NITRO_PORT: port,
API_BASE: apiBase
}

module.exports = {
apps: [
{
name: appName,
script: '.output/server/index.mjs',
cwd: __dirname,
exec_mode: 'cluster',
instances: Number(process.env.PM2_INSTANCES || 2),
autorestart: true,
watch: false,
max_memory_restart: process.env.PM2_MAX_MEMORY || '512M',
time: true,
out_file: `./logs/${appName}-out.log`,
error_file: `./logs/${appName}-error.log`,
env,
env_production: env
}
]
}

+ 89
- 0
restart.sh 查看文件

@@ -0,0 +1,89 @@
#!/usr/bin/env bash

set -Eeuo pipefail

APP_NAME="${APP_NAME:-pc_nuxt}"
BRANCH="${BRANCH:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

cd "$SCRIPT_DIR"

log() {
printf '\n[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}

require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
log "Missing command: $1"
exit 1
fi
}

ensure_clean_worktree() {
if [ -n "$(git status --porcelain)" ]; then
log "Working tree is not clean. Commit/stash/clean changes before deploy."
git status --short
exit 1
fi
}

resolve_branch() {
if [ -z "$BRANCH" ]; then
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
fi
if [ "$BRANCH" = "HEAD" ]; then
log "Cannot detect git branch. Please run with BRANCH=main ./restart.sh"
exit 1
fi
}

pull_latest_code() {
log "Pull latest code from origin/$BRANCH"
ensure_clean_worktree
git fetch origin "$BRANCH"
git pull --ff-only origin "$BRANCH"
}

install_dependencies() {
log "Install dependencies"
pnpm install --frozen-lockfile
}

build_app() {
log "Build Nuxt app"
pnpm run build

if [ ! -f ".output/server/index.mjs" ]; then
log "Build output missing: .output/server/index.mjs"
exit 1
fi
}

restart_app() {
log "Reload app with pm2 ecosystem config"
mkdir -p logs
APP_NAME="$APP_NAME" pm2 startOrReload ecosystem.config.cjs --env production --update-env
pm2 save >/dev/null 2>&1 || true
}

main() {
require_command git
require_command node
require_command pnpm
require_command pm2
resolve_branch

log "Deploy start: $APP_NAME"
log "Node: $(node -v 2>/dev/null || echo 'unknown')"
log "pnpm: $(pnpm -v)"
log "pm2: $(pm2 -v)"

pull_latest_code
install_dependencies
build_app
restart_app

log "Deploy done: $APP_NAME"
}

main "$@"

Loading…
取消
儲存