| @@ -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,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, | |||
| @@ -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, | |||
| } | |||
| } | |||
| @@ -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()) | |||
| @@ -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()) | |||
| @@ -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()) | |||
| @@ -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, | |||
| } | |||
| } | |||
| @@ -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') | |||
| @@ -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 }, | |||
| @@ -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) => { | |||
| @@ -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) | |||
| }) | |||
| @@ -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 | |||
| } | |||
| ] | |||
| } | |||
| @@ -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 "$@" | |||