Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

473 linhas
18 KiB

  1. <template>
  2. <div>
  3. <AppHero
  4. kicker="AI ONLINE POINTS"
  5. title="点数充值"
  6. desc="点数长期有效,多端通用。小程序、H5、PC 端共享同一份点数。"
  7. >
  8. <template #aside>
  9. <!-- 余额卡 -->
  10. <div class="w-full rounded-3xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/80 p-5 shadow-[0_18px_34px_var(--sh-0-92-190-100)] backdrop-blur lg:w-[260px]">
  11. <div class="text-xs font-bold text-[var(--c-8a97a8)]">我的点数</div>
  12. <div class="mt-2 flex items-end gap-1.5">
  13. <span class="text-4xl font-extrabold leading-none text-[var(--c-0b8cff)]">{{ balanceText }}</span>
  14. <span v-if="balanceUnit" class="pb-1 text-sm font-bold text-[var(--c-8a97a8)]">{{ balanceUnit }}</span>
  15. </div>
  16. <NuxtLink to="/bill" class="mt-3 inline-flex items-center gap-1 text-xs font-bold text-[var(--c-0b8cff)] hover:underline">
  17. 查看账单明细
  18. <UIcon name="i-lucide-chevron-right" class="size-3.5" />
  19. </NuxtLink>
  20. </div>
  21. </template>
  22. </AppHero>
  23. <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
  24. <div class="lg:grid lg:grid-cols-12 lg:gap-8">
  25. <!-- ===== 左:套餐选择 ===== -->
  26. <div class="lg:col-span-7">
  27. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_8px_28px_var(--sh-34-68-112-50)] lg:p-6">
  28. <div class="flex items-baseline">
  29. <h2 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">选择充值套餐</h2>
  30. <span class="ml-auto text-xs text-[var(--c-8a95a6)]">点数长期有效</span>
  31. </div>
  32. <!-- 加载中 -->
  33. <div v-if="loading" class="grid grid-cols-2 gap-3 pt-4 sm:grid-cols-3">
  34. <div v-for="i in 3" :key="i" class="h-[124px] animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" />
  35. </div>
  36. <!-- 套餐网格 -->
  37. <div v-else-if="packages.length" class="grid grid-cols-2 gap-3 pt-4 sm:grid-cols-3">
  38. <button
  39. v-for="pkg in packages"
  40. :key="pkg.id"
  41. type="button"
  42. class="relative flex min-h-[124px] flex-col items-center justify-center gap-2 rounded-2xl border-2 p-4 transition-all"
  43. :class="current && current.id === pkg.id
  44. ? 'border-[var(--c-0b8cff)] bg-[var(--c-f4faff)] shadow-[0_0_0_4px_var(--sh-11-140-255-100)]'
  45. : 'border-transparent bg-[var(--c-f7f9fd)] hover:border-[var(--c-e5edf7)]'"
  46. @click="selectPackage(pkg)"
  47. >
  48. <span
  49. v-if="pkg.badge"
  50. class="absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full bg-[var(--c-0b8cff)]/10 px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-0b8cff)]"
  51. >{{ pkg.badge }}</span>
  52. <span class="mt-4 text-xl font-extrabold text-[var(--c-172033)]">{{ pkg.points }}点</span>
  53. <span class="text-sm font-extrabold text-[var(--c-0b8cff)]">¥{{ pkg.price_yuan }}</span>
  54. <span v-if="pkg.old_price_yuan" class="flex items-center gap-1.5 text-xs">
  55. <span class="text-[var(--c-a0aabb)] line-through">¥{{ pkg.old_price_yuan }}</span>
  56. <span
  57. v-if="pkg.bonus_percent"
  58. class="rounded-full bg-[var(--c-edfdf4)] px-1.5 py-0.5 font-extrabold text-[var(--c-159a54)]"
  59. >多送{{ pkg.bonus_percent }}%</span>
  60. </span>
  61. </button>
  62. </div>
  63. <!-- 空态 -->
  64. <div v-else class="py-14 text-center">
  65. <UIcon name="i-lucide-package-x" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" />
  66. <div class="text-sm text-[var(--c-8a95a6)]">暂无可用套餐</div>
  67. </div>
  68. <!-- 确认充值 -->
  69. <button
  70. type="button"
  71. class="mt-5 flex h-12 w-full items-center justify-center rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] text-base font-extrabold text-white shadow-[0_12px_24px_var(--sh-11-140-255-220)] transition-opacity disabled:cursor-not-allowed disabled:opacity-50 lg:h-14"
  72. :disabled="!current || payState === 'loading'"
  73. @click="startPay"
  74. >
  75. {{ buyText }}
  76. </button>
  77. </div>
  78. <!-- 权益 -->
  79. <div class="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
  80. <div class="rounded-2xl bg-[var(--c-0b8cff)]/[0.07] p-4">
  81. <div class="text-sm font-extrabold text-[var(--c-0b8cff)]">点数长期有效</div>
  82. <div class="mt-1.5 text-xs leading-relaxed text-[var(--c-66758a)]">购买后可用于所有 AI 图像工具</div>
  83. </div>
  84. <div class="rounded-2xl bg-[var(--c-0b8cff)]/[0.07] p-4">
  85. <div class="text-sm font-extrabold text-[var(--c-0b8cff)]">多端通用</div>
  86. <div class="mt-1.5 text-xs leading-relaxed text-[var(--c-66758a)]">小程序、H5、PC 端共享点数</div>
  87. </div>
  88. </div>
  89. <!-- 购买须知 -->
  90. <div class="mt-5 rounded-2xl bg-[var(--c-ffffff)] p-5 text-sm leading-relaxed text-[var(--c-8a95a6)] shadow-[0_8px_28px_var(--sh-34-68-112-40)]">
  91. <div class="mb-1 font-bold text-[var(--c-7f8896)]">购买须知:</div>
  92. <div>1、购买后长期有效,不支持退款;</div>
  93. <div>2、同一账号点数可在小程序、H5、PC端使用;</div>
  94. <div>3、点数适用于 AI 图片生成、高清修复、证件照、AI绘画等功能。</div>
  95. </div>
  96. </div>
  97. <!-- ===== 右:扫码支付(吸顶) ===== -->
  98. <div class="mt-5 lg:col-span-5 lg:mt-0">
  99. <div class="space-y-4 lg:sticky lg:top-20">
  100. <div class="rounded-2xl bg-[var(--c-ffffff)] p-6 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
  101. <!-- 未开始 -->
  102. <template v-if="payState === 'idle'">
  103. <UIcon :name="payIdleIcon" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" />
  104. <div class="mb-1 text-base font-bold text-[var(--c-172033)]">{{ payIdleTitle }}</div>
  105. <div class="text-sm text-[var(--c-a0aabb)]">{{ payIdleDesc1 }}</div>
  106. <div class="text-sm text-[var(--c-a0aabb)]">{{ payIdleDesc2 }}</div>
  107. </template>
  108. <!-- 生成中 / 待支付 / 成功 -->
  109. <template v-else>
  110. <div class="mb-1 text-base font-bold text-[var(--c-172033)]">
  111. {{ payPanelTitle }}
  112. </div>
  113. <div class="mb-4 text-sm text-[var(--c-8a95a6)]">
  114. {{ payStateText }}
  115. </div>
  116. <div class="mx-auto mb-4 flex h-[200px] w-[200px] items-center justify-center rounded-2xl bg-[var(--c-f7f9fd)]">
  117. <div v-if="payState === 'loading'" class="flex flex-col items-center gap-2">
  118. <span class="size-7 animate-spin rounded-full border-2 border-[var(--c-0b8cff)] border-t-transparent" />
  119. <span class="text-xs text-[var(--c-a0aabb)]">生成二维码...</span>
  120. </div>
  121. <div v-else-if="payState === 'success'" class="flex flex-col items-center gap-2">
  122. <UIcon name="i-lucide-circle-check" class="size-14 text-[var(--c-17a65a)]" />
  123. <span class="text-sm font-bold text-[var(--c-17a65a)]">+{{ current?.points || 0 }} 点已到账</span>
  124. </div>
  125. <div v-else-if="isMobileClient && payState === 'pending'" class="flex flex-col items-center gap-2">
  126. <UIcon name="i-lucide-smartphone" class="size-12 text-[var(--c-0b8cff)]" />
  127. <span class="text-xs font-bold text-[var(--c-0b8cff)]">等待支付完成...</span>
  128. </div>
  129. <ClientOnly v-else-if="codeUrl">
  130. <vue-qr :text="codeUrl" :size="180" :margin="8" />
  131. </ClientOnly>
  132. <div v-else class="flex flex-col items-center gap-2">
  133. <UIcon name="i-lucide-triangle-alert" class="size-9 text-[var(--c-f04438)]" />
  134. <span class="text-xs text-[var(--c-f04438)]">生成失败,请重试</span>
  135. </div>
  136. </div>
  137. <div v-if="payState !== 'success'" class="text-xs text-[var(--c-a0aabb)]">
  138. 支付金额:¥{{ current?.price_yuan || '0.00' }}
  139. </div>
  140. <div class="mt-4 flex justify-center gap-3">
  141. <button
  142. v-if="payState !== 'success'"
  143. class="rounded-full px-4 py-2 text-xs text-[var(--c-8a95a6)] transition-colors hover:text-[var(--c-172033)]"
  144. @click="cancelPay"
  145. >
  146. 取消支付
  147. </button>
  148. <button
  149. v-else
  150. class="rounded-full bg-[var(--c-0b8cff)] px-6 py-2 text-xs font-bold text-white transition-colors hover:bg-[var(--c-0a7ce0)]"
  151. @click="finishPay"
  152. >
  153. 完成
  154. </button>
  155. </div>
  156. </template>
  157. </div>
  158. </div>
  159. </div>
  160. </div>
  161. </div>
  162. </div>
  163. </template>
  164. <script setup>
  165. import VueQr from 'vue-qr'
  166. useSeoMeta({
  167. title: '点数充值 - AI在线',
  168. description: '选择套餐充值点数,点数长期有效、多端通用',
  169. })
  170. const { userInfo, isLogin, showLogin, refreshBalance } = useUser()
  171. const { post } = useApi()
  172. const { reportCv } = usePvcv()
  173. const toast = useToast()
  174. const route = useRoute()
  175. const { clientId, isMobileClient, isWechatClient } = useClientDevice()
  176. const wxPayOpenId = useCookie('wechat_pay_open_id', { maxAge: 30 * 24 * 3600, path: '/' })
  177. const WECHAT_MP_APPID = 'wxd2aa05b2249b16ce'
  178. const packages = ref([])
  179. const current = ref(null)
  180. const loading = ref(true)
  181. const balance = reactive({ points: 0 })
  182. // idle | loading | pending | success | error
  183. const payState = ref('idle')
  184. const codeUrl = ref('')
  185. const orderNo = ref('')
  186. let pollingTimer = null
  187. const balanceText = computed(() => (Number(balance.points) < 0 ? '不限' : String(Number(balance.points) || 0)))
  188. const balanceUnit = computed(() => (balanceText.value === '不限' ? '' : '点'))
  189. const buyText = computed(() => {
  190. if (!current.value) return '请选择套餐'
  191. return `确认充值(${current.value.price_yuan}元)`
  192. })
  193. const payIdleIcon = computed(() => (isMobileClient.value ? 'i-lucide-smartphone' : 'i-lucide-qr-code'))
  194. const payIdleTitle = computed(() => {
  195. if (!isMobileClient.value) return '微信扫码支付'
  196. return isWechatClient.value ? '微信内支付' : '手机微信支付'
  197. })
  198. const payIdleDesc1 = computed(() => {
  199. if (!isMobileClient.value) return '选择套餐后点击「确认充值」'
  200. return isWechatClient.value ? '选择套餐后将拉起微信支付' : '选择套餐后将跳转微信支付页'
  201. })
  202. const payIdleDesc2 = computed(() => {
  203. if (!isMobileClient.value) return '这里会生成支付二维码'
  204. return '支付完成后点数会自动到账'
  205. })
  206. const payPanelTitle = computed(() => {
  207. if (payState.value === 'success') return '充值成功'
  208. if (!isMobileClient.value) return '微信扫码支付'
  209. return isWechatClient.value ? '微信内支付' : '手机微信支付'
  210. })
  211. const payStateText = computed(() => {
  212. if (payState.value === 'loading') return isMobileClient.value ? '正在创建订单并准备拉起支付...' : '正在创建订单...'
  213. if (payState.value === 'pending') {
  214. if (!isMobileClient.value) return '请使用微信扫描二维码完成支付'
  215. return isWechatClient.value ? '请在微信支付弹窗中完成支付' : '请在打开的微信支付页面完成支付'
  216. }
  217. if (payState.value === 'success') return '点数已到账,可以开始创作了'
  218. return '订单创建失败'
  219. })
  220. function showMsg(msg, color = 'error') {
  221. toast.add({ title: msg, color })
  222. }
  223. // ==================== 套餐 & 余额 ====================
  224. async function loadPackages() {
  225. loading.value = true
  226. try {
  227. const res = await post('/point/packagelist')
  228. packages.value = res.list || []
  229. current.value = packages.value.find(p => p.badge === '推荐') || packages.value[0] || null
  230. } catch (e) {
  231. packages.value = []
  232. showMsg(e.message || '套餐加载失败')
  233. } finally {
  234. loading.value = false
  235. }
  236. }
  237. async function loadBalance() {
  238. if (!isLogin.value) return
  239. try {
  240. const res = await post('/point/balance')
  241. balance.points = res.data?.points || 0
  242. userInfo.value = { ...userInfo.value, balance: balance.points }
  243. } catch {
  244. // 忽略
  245. }
  246. }
  247. function selectPackage(pkg) {
  248. if (payState.value === 'pending' || payState.value === 'loading') cancelPay()
  249. current.value = pkg
  250. }
  251. // ==================== 支付(PC 扫码 / H5 微信支付) ====================
  252. function isInvalidOpenId(value) {
  253. return !value || value === 'undefined' || value === 'null'
  254. }
  255. function cleanOAuthQuery() {
  256. if (!import.meta.client) return
  257. const url = new URL(window.location.href)
  258. url.searchParams.delete('code')
  259. url.searchParams.delete('state')
  260. window.history.replaceState({}, document.title, url.toString())
  261. }
  262. function getWechatAuthUrl(packageId) {
  263. const url = new URL(window.location.href)
  264. url.searchParams.delete('code')
  265. url.searchParams.delete('state')
  266. const redirectUri = encodeURIComponent(url.toString())
  267. 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`
  268. }
  269. function getQueryValue(value) {
  270. return Array.isArray(value) ? value[0] : value
  271. }
  272. function invokeWechatPay(payInfo) {
  273. if (!import.meta.client) return
  274. const payload = {
  275. appId: payInfo.appId || payInfo.appid || WECHAT_MP_APPID,
  276. timeStamp: String(payInfo.timeStamp || payInfo.timestamp || ''),
  277. nonceStr: payInfo.nonceStr || payInfo.noncestr || '',
  278. package: payInfo.package || (payInfo.prepay_id ? `prepay_id=${payInfo.prepay_id}` : ''),
  279. signType: payInfo.signType || 'RSA',
  280. paySign: payInfo.paySign || payInfo.paysign || '',
  281. }
  282. const onReady = () => {
  283. window.WeixinJSBridge.invoke('getBrandWCPayRequest', payload, (res = {}) => {
  284. if (res.err_msg === 'get_brand_wcpay_request:ok') {
  285. pollOnce()
  286. } else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
  287. cancelPay()
  288. showMsg('支付已取消', 'warning')
  289. } else {
  290. payState.value = 'error'
  291. showMsg('支付失败,请重试')
  292. }
  293. })
  294. }
  295. if (typeof window.WeixinJSBridge === 'undefined') {
  296. document.addEventListener('WeixinJSBridgeReady', onReady, false)
  297. } else {
  298. onReady()
  299. }
  300. }
  301. async function handleWechatOAuthReturn() {
  302. if (!import.meta.client || !isMobileClient.value || !isWechatClient.value) return
  303. const code = getQueryValue(route.query.code)
  304. const state = String(getQueryValue(route.query.state) || '')
  305. if (!code || !state.startsWith('pointbuy_')) return
  306. try {
  307. const res = await post('/common/getopenid', { code, client: 2 })
  308. const openId = res.data?.wx_open_id || ''
  309. if (!openId) throw new Error('获取 openid 失败')
  310. wxPayOpenId.value = openId
  311. cleanOAuthQuery()
  312. const packageId = Number(state.replace('pointbuy_', ''))
  313. const target = packages.value.find(item => Number(item.id) === packageId)
  314. if (target) current.value = target
  315. if (current.value) await startPay({ skipOauth: true })
  316. } catch (e) {
  317. cleanOAuthQuery()
  318. showMsg(e.message || '微信支付身份获取失败,请重新点击充值')
  319. }
  320. }
  321. async function startPay(options = {}) {
  322. if (!current.value) return
  323. reportCv('point_recharge_click', { page_key: 'recharge' })
  324. if (!isLogin.value) {
  325. showLogin.value = true
  326. return
  327. }
  328. if (isMobileClient.value && isWechatClient.value && isInvalidOpenId(wxPayOpenId.value) && !options.skipOauth) {
  329. window.location.href = getWechatAuthUrl(current.value.id)
  330. return
  331. }
  332. payState.value = 'loading'
  333. codeUrl.value = ''
  334. try {
  335. const payload = {
  336. package_id: current.value.id,
  337. client: clientId.value,
  338. base_in_wechat: isWechatClient.value ? 1 : 0,
  339. }
  340. if (isMobileClient.value && isWechatClient.value) {
  341. payload.open_id = wxPayOpenId.value
  342. }
  343. const res = await post('/point/createorder', payload)
  344. const data = res.data || {}
  345. orderNo.value = data.order_no || ''
  346. reportCv('point_recharge_order', { page_key: 'recharge' })
  347. if (data.mode === 'wxpay_jsapi' || data.package || data.paySign) {
  348. payState.value = 'pending'
  349. startPolling()
  350. invokeWechatPay(data)
  351. return
  352. }
  353. const h5Url = data.h5_url || data.h5Url || ''
  354. if (data.mode === 'wxpay_h5' && h5Url) {
  355. payState.value = 'pending'
  356. startPolling()
  357. window.location.href = h5Url
  358. return
  359. }
  360. const url = data.code_url || data.codeUrl || data.qr_code || ''
  361. if (url) {
  362. codeUrl.value = url
  363. payState.value = 'pending'
  364. startPolling()
  365. } else {
  366. payState.value = 'error'
  367. showMsg(res.msg || '支付暂不可用')
  368. }
  369. } catch (e) {
  370. payState.value = 'error'
  371. showMsg(e.message || '创建订单失败')
  372. }
  373. }
  374. // 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账
  375. async function pollOnce() {
  376. if (!orderNo.value) return
  377. try {
  378. const res = await post('/point/orderstatus', { order_no: orderNo.value })
  379. const d = res.data || {}
  380. if (Number(d.status) === 20) {
  381. clearInterval(pollingTimer)
  382. payState.value = 'success'
  383. balance.points = d.balance ?? balance.points
  384. refreshBalance()
  385. reportCv('point_recharge_paid', { page_key: 'recharge' })
  386. }
  387. } catch {
  388. // 单次失败不中断轮询
  389. }
  390. }
  391. function startPolling() {
  392. clearInterval(pollingTimer)
  393. pollingTimer = setInterval(async () => {
  394. await pollOnce()
  395. }, 2000)
  396. }
  397. function cancelPay() {
  398. clearInterval(pollingTimer)
  399. payState.value = 'idle'
  400. codeUrl.value = ''
  401. orderNo.value = ''
  402. }
  403. function finishPay() {
  404. cancelPay()
  405. loadBalance()
  406. }
  407. onMounted(async () => {
  408. await loadPackages()
  409. await loadBalance()
  410. await handleWechatOAuthReturn()
  411. })
  412. watch(isLogin, async (loggedIn) => {
  413. if (!loggedIn) {
  414. cancelPay()
  415. balance.points = 0
  416. }
  417. await loadPackages()
  418. if (loggedIn) await loadBalance()
  419. })
  420. onUnmounted(() => clearInterval(pollingTimer))
  421. </script>