Compare commits
13 Commits
20b0b0483f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e1fc55140f | |||
| c08b453e91 | |||
| 70f4f97d53 | |||
| 013dfb4bee | |||
| f02f3f5c04 | |||
| adde209c57 | |||
| 104e04a8cc | |||
| 11bc49d1e0 | |||
| 3639e10a11 | |||
| 3f0be7575c | |||
| 63d40b3e76 | |||
| 19d27194e2 | |||
| 4776d22aeb |
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-[60px] md:h-[125px]">
|
||||||
|
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
|
||||||
|
<div class="container">
|
||||||
|
<header
|
||||||
|
class="lucid-glass-bar flex h-[40px] items-center justify-between rounded-[90px] p-[4px] transition-all duration-300 md:h-[60px]"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
class="ml-[clamp(0.25rem,2vw,0.625rem)] flex min-w-0 items-center gap-[clamp(0.25rem,1vw,0.5rem)] whitespace-nowrap md:ml-[47px]"
|
||||||
|
>
|
||||||
|
<Logo
|
||||||
|
alt="Hi快VPN"
|
||||||
|
class="h-[clamp(0.875rem,4vw,1.125rem)] w-auto shrink-0 md:h-[29px]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-[clamp(0.875rem,4.25vw,1.5rem)] leading-none font-black whitespace-nowrap md:ml-3 md:text-2xl"
|
||||||
|
>
|
||||||
|
高能合伙人
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
v-if="isLoggedIn"
|
||||||
|
to="/user-center"
|
||||||
|
aria-label="进入用户中心"
|
||||||
|
class="flex size-[30px] shrink-0 items-center justify-center rounded-full bg-[#78788029] text-xl font-bold text-white shadow-lg transition hover:scale-105 md:size-[40px] md:text-3xl"
|
||||||
|
>
|
||||||
|
{{ userLetter }}
|
||||||
|
</router-link>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="header-login-button flex h-[30px] shrink-0 cursor-pointer items-center justify-center rounded-full px-[clamp(0.625rem,3vw,1.5rem)] text-[clamp(0.75rem,3.2vw,0.875rem)] leading-none font-bold whitespace-nowrap transition hover:brightness-110 md:h-[50px] md:w-[220px] md:px-6 md:text-xl"
|
||||||
|
@click="openLoginModal"
|
||||||
|
>
|
||||||
|
登录/注册
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoginFormModal ref="loginModalRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import LoginFormModal from '@/pages/Home/components/LoginFormModal.vue'
|
||||||
|
import Logo from '@/pages/Home/logo.svg?component'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const token = useLocalStorage('Authorization', '')
|
||||||
|
const userEmail = useLocalStorage('UserEmail', '')
|
||||||
|
const loginModalRef = ref<InstanceType<typeof LoginFormModal> | null>(null)
|
||||||
|
|
||||||
|
interface UserInfoResponse {
|
||||||
|
auth_methods?: Array<{
|
||||||
|
auth_type?: string
|
||||||
|
auth_identifier?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
const userLetter = computed(() => userEmail.value?.charAt(0).toUpperCase() || '?')
|
||||||
|
|
||||||
|
const openLoginModal = () => {
|
||||||
|
loginModalRef.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoginQuery = () => {
|
||||||
|
if (route.query.login !== 'true') return
|
||||||
|
|
||||||
|
openLoginModal()
|
||||||
|
router.replace({ query: { ...route.query, login: undefined } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUserInfo = async () => {
|
||||||
|
if (route.path !== '/' || !token.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = (await request.get('/api/v1/public/user/info')) as UserInfoResponse
|
||||||
|
const emailInfo = res.auth_methods?.find((item) => item.auth_type === 'email')
|
||||||
|
if (emailInfo?.auth_identifier) userEmail.value = emailInfo.auth_identifier
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Failed to fetch user info:', error)
|
||||||
|
const status =
|
||||||
|
typeof error === 'object' && error !== null
|
||||||
|
? (error as { code?: number; status?: number })
|
||||||
|
: {}
|
||||||
|
if (status.code === 401 || status.status === 401) {
|
||||||
|
token.value = ''
|
||||||
|
userEmail.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchUserInfo()
|
||||||
|
handleLoginQuery()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.login, handleLoginQuery)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-login-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: rgb(120 120 128 / 16%);
|
||||||
|
box-shadow: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.header-login-button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
appearance: button;
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
@pointer-down-outside="(event) => event.preventDefault()"
|
@pointer-down-outside="(event) => event.preventDefault()"
|
||||||
@focus-outside="(event) => event.preventDefault()"
|
@focus-outside="(event) => event.preventDefault()"
|
||||||
class="max-w-[300px] rounded-4xl bg-[#DDDDDD] p-[14px] md:max-w-[390px]"
|
@focusin="handleFocusIn"
|
||||||
|
class="login-dialog max-w-[300px] overflow-visible rounded-4xl bg-[#DDDDDD] p-[14px] md:max-w-[390px]"
|
||||||
|
:style="dialogViewportStyle"
|
||||||
:showCloseButton="false"
|
:showCloseButton="false"
|
||||||
>
|
>
|
||||||
<DialogHeader class="py-2 pl-2">
|
<DialogHeader class="py-2 pl-2">
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -29,21 +31,66 @@ import {
|
|||||||
import LoginForm from './LoginForm.vue'
|
import LoginForm from './LoginForm.vue'
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
const visualViewportTop = ref(window.visualViewport?.offsetTop ?? 0)
|
||||||
|
const visualViewportHeight = ref(window.visualViewport?.height ?? window.innerHeight)
|
||||||
|
let focusTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const syncVisualViewport = () => {
|
||||||
|
const viewport = window.visualViewport
|
||||||
|
visualViewportTop.value = viewport?.offsetTop ?? 0
|
||||||
|
visualViewportHeight.value = viewport?.height ?? window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogViewportStyle = computed(() => ({
|
||||||
|
top: `${visualViewportTop.value + visualViewportHeight.value / 2}px`,
|
||||||
|
maxHeight: `${Math.max(visualViewportHeight.value - 24, 0)}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
const setOpen = (value: boolean) => {
|
const setOpen = (value: boolean) => {
|
||||||
isOpen.value = value
|
isOpen.value = value
|
||||||
|
if (value) nextTick(syncVisualViewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
isOpen.value = true
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hide = () => {
|
const hide = () => {
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFocusIn = () => {
|
||||||
|
if (focusTimer) clearTimeout(focusTimer)
|
||||||
|
focusTimer = setTimeout(() => {
|
||||||
|
syncVisualViewport()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncVisualViewport()
|
||||||
|
window.visualViewport?.addEventListener('resize', syncVisualViewport)
|
||||||
|
window.visualViewport?.addEventListener('scroll', syncVisualViewport)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (focusTimer) clearTimeout(focusTimer)
|
||||||
|
window.visualViewport?.removeEventListener('resize', syncVisualViewport)
|
||||||
|
window.visualViewport?.removeEventListener('scroll', syncVisualViewport)
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show,
|
show,
|
||||||
hide,
|
hide,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-dialog {
|
||||||
|
will-change: top, max-height;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-dialog::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,55 +2,37 @@
|
|||||||
<div
|
<div
|
||||||
class="relative min-h-screen overflow-hidden bg-black bg-cover bg-center bg-no-repeat pb-[calc(4rem+env(safe-area-inset-bottom))] font-sans text-white md:flex md:flex-col"
|
class="relative min-h-screen overflow-hidden bg-black bg-cover bg-center bg-no-repeat pb-[calc(4rem+env(safe-area-inset-bottom))] font-sans text-white md:flex md:flex-col"
|
||||||
>
|
>
|
||||||
<!-- Full Width Header -->
|
<AppHeader />
|
||||||
<div class="h-[60px] md:h-[125px]">
|
|
||||||
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
|
|
||||||
<div class="container">
|
|
||||||
<header
|
|
||||||
class="lucid-glass-bar flex h-[40px] items-center justify-between rounded-[90px] pr-[5px] pl-5 transition-all duration-300 md:h-[60px] md:pr-[10px]"
|
|
||||||
>
|
|
||||||
<router-link to="/" class="flex items-center gap-2">
|
|
||||||
<!-- Desktop Logo -->
|
|
||||||
<Logo alt="Hi快VPN" class="h-[18px] w-auto md:ml-8 md:h-[29px]" />
|
|
||||||
<span class="ml-3 text-2xl font-black">高能合伙人</span>
|
|
||||||
</router-link>
|
|
||||||
<div v-if="isLoggedIn" class="flex items-center">
|
|
||||||
<router-link
|
|
||||||
to="/user-center"
|
|
||||||
class="flex size-[30px] items-center justify-center rounded-full bg-[#78788029] text-xl font-bold text-white shadow-lg transition hover:scale-105 md:size-[40px] md:text-3xl"
|
|
||||||
>
|
|
||||||
{{ userLetter }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
@click="openLoginModal"
|
|
||||||
class="flex h-[30px] cursor-pointer items-center justify-center rounded-full bg-[#78788029] px-6 text-sm font-bold backdrop-blur-md transition hover:brightness-110 md:h-[40px] md:w-[220px] md:text-xl"
|
|
||||||
>
|
|
||||||
代理后台登录/注册
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content Container -->
|
<!-- Main Content Container -->
|
||||||
<div class="container mx-auto flex max-w-[1220px] flex-col">
|
<div class="container mx-auto flex max-w-[1220px] flex-col">
|
||||||
<main class="pt-10">
|
<main class="pt-10">
|
||||||
<!-- module0 -->
|
<!-- module0 -->
|
||||||
<div class="mb-[80px] flex justify-between">
|
<div class="home-hero mb-[80px] flex flex-col justify-between md:flex-row">
|
||||||
<div class="flex flex-col items-center">
|
<div
|
||||||
<div class="mb-[20px] ml-[42px] md:ml-[17px]">
|
class="flex w-full items-center justify-between gap-4 md:w-auto md:flex-col md:justify-start md:gap-0"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 md:mb-[20px] md:ml-[17px]">
|
||||||
<h2 class="mb-2 text-2xl font-black md:text-8xl">
|
<h2 class="mb-2 text-2xl font-black md:text-8xl">
|
||||||
<Logo class="h-[34px] md:h-[43px]" />
|
<Logo class="h-[34px] md:h-[43px]" />
|
||||||
</h2>
|
</h2>
|
||||||
<p class="font-600 text-3xl">网在我在, 网快我快</p>
|
<p
|
||||||
|
class="font-600 text-[clamp(1.25rem,7vw,1.875rem)] leading-tight md:text-3xl md:leading-9"
|
||||||
|
>
|
||||||
|
网在我在, 网快我快
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<img src="./Group%20133.png" alt="" class="mt-[52px] h-[287px] w-[182px]" />
|
<img
|
||||||
|
src="./Group%20133.png"
|
||||||
|
alt="Hi快VPN 手机应用"
|
||||||
|
class="home-hero__product w-auto shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- video -->
|
<!-- video -->
|
||||||
<div
|
<button
|
||||||
class="max-w-[800px] flex-1 cursor-pointer overflow-hidden rounded-[40px] transition duration-300 hover:scale-[1.02]"
|
type="button"
|
||||||
|
aria-label="播放介绍视频"
|
||||||
|
class="block aspect-video w-full max-w-[800px] flex-1 cursor-pointer overflow-hidden rounded-[clamp(1.25rem,5vw,2.5rem)] p-0 text-left transition duration-300 hover:scale-[1.02]"
|
||||||
@click="openVideoModal"
|
@click="openVideoModal"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -58,16 +40,37 @@
|
|||||||
class="h-full w-full object-cover object-center"
|
class="h-full w-full object-cover object-center"
|
||||||
alt="Video Cover"
|
alt="Video Cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- modules1 -->
|
<!-- modules1 -->
|
||||||
<div class="mb-[80px] w-full">
|
<div class="mb-[80px] w-full">
|
||||||
<img src="./modules1/Group%20235.png" alt="邀请规则" class="mx-auto w-[1024px]" />
|
<img
|
||||||
|
src="./modules1/module2-mobile.png"
|
||||||
|
alt="高能合伙人权益"
|
||||||
|
class="mx-auto h-auto w-full md:hidden"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="./modules1/Group%20235.png"
|
||||||
|
alt="高能合伙人权益"
|
||||||
|
class="mx-auto hidden h-auto w-full max-w-[1024px] md:block"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- modules2 -->
|
<!-- modules2 -->
|
||||||
<div class="mb-[80px] w-full rounded-[40px] bg-[#ADFF5B] p-8 pb-2 text-black">
|
<div
|
||||||
<div class="text-center text-4xl font-black">“超强产品力,全面秒杀同级app”</div>
|
class="mb-[80px] w-full rounded-[40px] bg-[#ADFF5B] px-[clamp(1rem,5vw,2rem)] pt-8 pb-2 text-black md:p-8 md:pb-2"
|
||||||
<div class="mt-2 text-center">为高能合伙人提供强力的的产品背书,每个点拿出去都能打。</div>
|
>
|
||||||
|
<div
|
||||||
|
class="text-left text-[clamp(1.75rem,9.6vw,2.25rem)] leading-tight font-black md:text-center md:text-4xl md:leading-10"
|
||||||
|
>
|
||||||
|
“超强产品力,<br class="md:hidden" />全面秒杀同级app”
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-left md:text-center">
|
||||||
|
为高能合伙人提供强力的的产品背书,每个点拿出去都能打。
|
||||||
|
</div>
|
||||||
<div class="el-style-scrollbar mt-8 flex gap-2 overflow-x-auto pb-4">
|
<div class="el-style-scrollbar mt-8 flex gap-2 overflow-x-auto pb-4">
|
||||||
<img
|
<img
|
||||||
:src="image"
|
:src="image"
|
||||||
@@ -78,31 +81,44 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- modules3 -->
|
<!-- modules3 + modules4 -->
|
||||||
<div class="mb-[40px] w-full rounded-[40px] pb-2 text-black">
|
<section class="home-showcase text-black" aria-labelledby="home-showcase-description">
|
||||||
<div class="text-center text-4xl font-black">
|
<div class="home-showcase__heading text-center">
|
||||||
<Group215Icon class="mx-auto" />
|
<Group215Icon class="home-showcase__title mx-auto h-auto w-full" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center text-[#999999]">给您的客户提供专业视角的建议</div>
|
<p id="home-showcase-description" class="home-showcase__description text-center">
|
||||||
<div class="mt-[20px] flex justify-center">
|
给您的客户提供专业视角的建议
|
||||||
<Carousel class="relative w-full max-w-[700px]" :opts="{ loop: true }">
|
</p>
|
||||||
<div class="size-[700px] overflow-hidden rounded-[40px]">
|
<div class="home-showcase__content flex justify-center">
|
||||||
|
<Carousel
|
||||||
|
class="home-showcase__carousel relative w-full"
|
||||||
|
:opts="{ loop: true }"
|
||||||
|
aria-label="产品优势展示"
|
||||||
|
>
|
||||||
|
<div class="home-showcase__viewport aspect-square w-full overflow-hidden">
|
||||||
<CarouselContent>
|
<CarouselContent>
|
||||||
<CarouselItem v-for="(img, index) in modules4Images" :key="index">
|
<CarouselItem v-for="(img, index) in modules4Images" :key="index">
|
||||||
<img
|
<img
|
||||||
:src="img"
|
:src="img"
|
||||||
alt=""
|
:alt="`产品优势展示 ${index + 1}`"
|
||||||
class="h-[700px] w-[700px] object-cover select-none"
|
class="aspect-square h-auto w-full object-cover select-none"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
</CarouselContent>
|
</CarouselContent>
|
||||||
</div>
|
</div>
|
||||||
<CarouselPrevious class="-left-[100px]" />
|
<CarouselPrevious
|
||||||
<CarouselNext class="-right-[100px]" />
|
class="home-showcase__nav home-showcase__nav--previous"
|
||||||
|
aria-label="上一张"
|
||||||
|
/>
|
||||||
|
<CarouselNext
|
||||||
|
class="home-showcase__nav home-showcase__nav--next"
|
||||||
|
aria-label="下一张"
|
||||||
|
/>
|
||||||
</Carousel>
|
</Carousel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div class="container flex flex-col items-center justify-center">
|
<div class="container flex flex-col items-center justify-center">
|
||||||
@@ -115,19 +131,19 @@
|
|||||||
<router-link to="/privacy" class="ml-2 underline">Privacy Policy</router-link>
|
<router-link to="/privacy" class="ml-2 underline">Privacy Policy</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LoginFormModal ref="loginModalRef" />
|
|
||||||
|
|
||||||
<!-- Video Modal -->
|
<!-- Video Modal -->
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:open="videoModalVisible"
|
v-model:open="videoModalVisible"
|
||||||
@update:open="(val) => !val && handleVideoModalClose()"
|
@update:open="(val) => !val && handleVideoModalClose()"
|
||||||
>
|
>
|
||||||
<DialogContent class="max-w-[60vw]! overflow-hidden rounded-2xl border-none bg-black/90 p-0">
|
<DialogContent
|
||||||
<div class="relative w-full pt-[56.25%]">
|
class="home-video-dialog overflow-hidden border-none bg-black/90 p-0 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="aspect-video w-full overflow-hidden rounded-[inherit] bg-black">
|
||||||
<video
|
<video
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
src="/fast-video.MP4"
|
src="/fast-video.MP4"
|
||||||
class="absolute inset-0 h-full w-full"
|
class="block h-full w-full object-contain"
|
||||||
controls
|
controls
|
||||||
autoplay
|
autoplay
|
||||||
></video>
|
></video>
|
||||||
@@ -138,10 +154,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
|
||||||
import LoginFormModal from './components/LoginFormModal.vue'
|
|
||||||
import Logo from './logo.svg?component'
|
import Logo from './logo.svg?component'
|
||||||
import Group215Icon from './modules3/Group 215.svg?component'
|
import Group215Icon from './modules3/Group 215.svg?component'
|
||||||
import {
|
import {
|
||||||
@@ -152,7 +166,6 @@ import {
|
|||||||
CarouselPrevious,
|
CarouselPrevious,
|
||||||
} from '@/components/ui/carousel'
|
} from '@/components/ui/carousel'
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
import request from '@/utils/request'
|
|
||||||
import Frame98 from './modules2/Frame 98.png'
|
import Frame98 from './modules2/Frame 98.png'
|
||||||
import Frame99 from './modules2/Frame 99.png'
|
import Frame99 from './modules2/Frame 99.png'
|
||||||
import Frame100 from './modules2/Frame 100.png'
|
import Frame100 from './modules2/Frame 100.png'
|
||||||
@@ -176,66 +189,168 @@ const videoRef = ref<HTMLVideoElement | null>(null)
|
|||||||
const openVideoModal = () => {
|
const openVideoModal = () => {
|
||||||
videoModalVisible.value = true
|
videoModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
// console.log('11')
|
||||||
const handleVideoModalClose = () => {
|
const handleVideoModalClose = () => {
|
||||||
if (videoRef.value) {
|
if (videoRef.value) {
|
||||||
videoRef.value.pause()
|
videoRef.value.pause()
|
||||||
videoRef.value.currentTime = 0
|
videoRef.value.currentTime = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const token = useLocalStorage('Authorization', '')
|
|
||||||
const userEmail = useLocalStorage('UserEmail', '')
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
|
||||||
const userLetter = computed(() => {
|
|
||||||
if (!userEmail.value) return '?'
|
|
||||||
return userEmail.value.charAt(0).toUpperCase()
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchUserInfo = async () => {
|
|
||||||
if (!token.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = (await request.get('/api/v1/public/user/info')) as any
|
|
||||||
const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email')
|
|
||||||
if (emailInfo) {
|
|
||||||
userEmail.value = emailInfo.auth_identifier
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to fetch user info:', error)
|
|
||||||
if (error?.code === 401 || error?.status === 401) {
|
|
||||||
token.value = ''
|
|
||||||
userEmail.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchUserInfo()
|
|
||||||
if (route.query.login === 'true') {
|
|
||||||
openLoginModal()
|
|
||||||
router.replace({ query: { ...route.query, login: undefined } })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.query.login,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal === 'true') {
|
|
||||||
openLoginModal()
|
|
||||||
router.replace({ query: { ...route.query, login: undefined } })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const loginModalRef = ref<InstanceType<typeof LoginFormModal> | null>(null)
|
|
||||||
|
|
||||||
const openLoginModal = () => {
|
|
||||||
loginModalRef.value?.show()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.home-hero {
|
||||||
|
gap: clamp(1.5rem, 6vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero__product {
|
||||||
|
block-size: clamp(4rem, 18.133vw, 4.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-video-dialog {
|
||||||
|
inline-size: min(
|
||||||
|
calc(100vw - 2rem - env(safe-area-inset-left) - env(safe-area-inset-right)),
|
||||||
|
calc((100dvh - 4rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)) * 16 / 9)
|
||||||
|
) !important;
|
||||||
|
max-inline-size: none !important;
|
||||||
|
border-radius: clamp(0.75rem, 4vw, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.home-video-dialog [data-slot='dialog-close']) {
|
||||||
|
top: clamp(0.375rem, 2vw, 0.75rem);
|
||||||
|
right: clamp(0.375rem, 2vw, 0.75rem);
|
||||||
|
z-index: 10;
|
||||||
|
display: grid;
|
||||||
|
inline-size: 2.75rem;
|
||||||
|
block-size: 2.75rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: white;
|
||||||
|
background: rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.home-video-dialog [data-slot='dialog-close'] svg) {
|
||||||
|
inline-size: 1.25rem;
|
||||||
|
block-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 48rem) {
|
||||||
|
.home-hero {
|
||||||
|
gap: clamp(2rem, 4vw, 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero__product {
|
||||||
|
inline-size: 11.375rem;
|
||||||
|
block-size: 17.9375rem;
|
||||||
|
margin-block-start: 3.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-video-dialog {
|
||||||
|
inline-size: min(
|
||||||
|
60vw,
|
||||||
|
calc((100dvh - 4rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)) * 16 / 9)
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase {
|
||||||
|
inline-size: min(100%, calc(100vw - 2.25rem));
|
||||||
|
margin-block-end: clamp(2rem, 5vw, 4rem);
|
||||||
|
padding-block-end: clamp(0.5rem, 1.5vw, 1rem);
|
||||||
|
border-radius: clamp(1.25rem, 4vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__heading {
|
||||||
|
padding-inline: clamp(0.25rem, 2vw, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__title {
|
||||||
|
max-inline-size: min(20.625rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__description {
|
||||||
|
margin-block-start: clamp(0.5rem, 1.5vw, 0.875rem);
|
||||||
|
padding-inline: clamp(0.75rem, 3vw, 1.5rem);
|
||||||
|
color: #999;
|
||||||
|
font-size: clamp(1rem, 2.5vw, 1.125rem);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__content {
|
||||||
|
margin-block-start: clamp(1rem, 3vw, 1.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__carousel {
|
||||||
|
max-inline-size: min(43.75rem, 100%, 85dvh);
|
||||||
|
touch-action: pan-y pinch-zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__viewport {
|
||||||
|
border-radius: clamp(1.25rem, 5vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav {
|
||||||
|
display: grid;
|
||||||
|
inline-size: clamp(2.75rem, 10vw, 4rem);
|
||||||
|
block-size: clamp(2.75rem, 10vw, 4rem);
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 35%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(0 0 0 / 48%);
|
||||||
|
backdrop-filter: blur(0.5rem);
|
||||||
|
transition:
|
||||||
|
background-color 180ms ease,
|
||||||
|
opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav:hover,
|
||||||
|
.home-showcase__nav:focus-visible {
|
||||||
|
background: rgb(0 0 0 / 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav:focus-visible {
|
||||||
|
outline: 2px solid white;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav--previous {
|
||||||
|
left: clamp(0.5rem, 2.5vw, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav--next {
|
||||||
|
right: clamp(0.5rem, 2.5vw, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav :deep(svg) {
|
||||||
|
inline-size: auto;
|
||||||
|
block-size: clamp(1.75rem, 7vw, 2.5rem);
|
||||||
|
max-inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
.home-showcase__nav {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav--previous {
|
||||||
|
left: clamp(-6.25rem, -7vw, -4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav--next {
|
||||||
|
right: clamp(-6.25rem, -7vw, -4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-showcase__nav :deep(svg) {
|
||||||
|
inline-size: auto;
|
||||||
|
block-size: clamp(4rem, 8vw, 7.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.home-showcase__nav {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 104 KiB |
@@ -1,3 +1,3 @@
|
|||||||
<svg width="48" height="120" viewBox="0 0 48 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 48 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M0 59.9033L37.5049 0L47.4883 4.35156L12.8008 59.9033L47.4883 115.456L37.5049 119.808L0 59.9033Z" fill="#ADFF5B"/>
|
<path d="M0 59.9033L37.5049 0L47.4883 4.35156L12.8008 59.9033L47.4883 115.456L37.5049 119.808L0 59.9033Z" fill="#ADFF5B"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 228 B After Width: | Height: | Size: 204 B |
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Main Neon Green Card -->
|
<!-- Main Neon Green Card -->
|
||||||
<div class="mt-4 flex flex-col justify-center gap-[20px] md:mt-0 md:h-[668px] md:flex-row">
|
<div
|
||||||
|
class="mt-4 flex flex-col justify-center gap-[10px] md:mt-0 md:h-[668px] md:flex-row md:gap-[20px]"
|
||||||
|
>
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="h-full w-[345px]">
|
<div class="h-full w-[345px]">
|
||||||
<Transition name="fade" mode="out-in">
|
<Transition name="fade" mode="out-in">
|
||||||
@@ -9,20 +11,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Middle Column -->
|
<!-- Middle Column -->
|
||||||
<div class="flex h-full w-[345px] flex-col gap-[20px]">
|
<div class="flex h-full w-[345px] flex-col gap-[10px] md:gap-[20px]">
|
||||||
<DownloadStats />
|
<DownloadStats />
|
||||||
<QuickTools />
|
<QuickTools />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="flex h-full w-[345px] flex-col gap-[20px]">
|
<div class="flex h-full w-[345px] flex-col gap-[10px] md:gap-[20px]">
|
||||||
<ProxyData />
|
<ProxyData />
|
||||||
<div class="flex-1">
|
<div class="">
|
||||||
<SalesData @show-history="orderDetailsModalRef?.show()" />
|
<SalesData @show-history="orderDetailsModalRef?.show()" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<RefundData @show-history="refundDetailsModalRef?.show()" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OrderDetailsModal ref="orderDetailsModalRef" />
|
<OrderDetailsModal ref="orderDetailsModalRef" />
|
||||||
|
<WithdrawalLogDialog
|
||||||
|
ref="refundDetailsModalRef"
|
||||||
|
title="退费记录"
|
||||||
|
biz-type="commission_refund"
|
||||||
|
amount-label="退费金额"
|
||||||
|
empty-text="暂无退费记录"
|
||||||
|
show-content
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -33,9 +46,12 @@ import DownloadStats from '@/pages/UserCenter/components/DownloadStats/index.vue
|
|||||||
import QuickTools from '@/pages/UserCenter/components/QuickTools/index.vue'
|
import QuickTools from '@/pages/UserCenter/components/QuickTools/index.vue'
|
||||||
import ProxyData from '@/pages/UserCenter/components/ProxyData/index.vue'
|
import ProxyData from '@/pages/UserCenter/components/ProxyData/index.vue'
|
||||||
import SalesData from '@/pages/UserCenter/components/SalesData/index.vue'
|
import SalesData from '@/pages/UserCenter/components/SalesData/index.vue'
|
||||||
|
import RefundData from '@/pages/UserCenter/components/RefundData/index.vue'
|
||||||
import OrderDetailsModal from '@/pages/UserCenter/components/OrderDetails/index.vue'
|
import OrderDetailsModal from '@/pages/UserCenter/components/OrderDetails/index.vue'
|
||||||
|
import WithdrawalLogDialog from '@/pages/UserCenter/components/UserInfo/components/WithdrawalLogDialog.vue'
|
||||||
|
|
||||||
const orderDetailsModalRef = ref<InstanceType<typeof OrderDetailsModal> | null>(null)
|
const orderDetailsModalRef = ref<InstanceType<typeof OrderDetailsModal> | null>(null)
|
||||||
|
const refundDetailsModalRef = ref<InstanceType<typeof WithdrawalLogDialog> | null>(null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<!-- Time -->
|
<!-- Time -->
|
||||||
<div class="col-span-2 pl-4">
|
<div class="col-span-2 pl-4">
|
||||||
<div class="text-xs text-gray-500">支付时间</div>
|
<div class="text-xs text-gray-500">支付时间</div>
|
||||||
<div class="whitespace-nowrap">{{ formatTime(order.update_at) }}</div>
|
<div class="whitespace-nowrap">{{ formatTime(order.updated_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +139,7 @@ function statusText(status: number) {
|
|||||||
function formatTime(timestamp: number) {
|
function formatTime(timestamp: number) {
|
||||||
if (!timestamp) return '-'
|
if (!timestamp) return '-'
|
||||||
const date = new Date(timestamp > 10000000000 ? timestamp : timestamp * 1000)
|
const date = new Date(timestamp > 10000000000 ? timestamp : timestamp * 1000)
|
||||||
return date.toLocaleString('en-US', {
|
return date.toLocaleString('zh-CN', {
|
||||||
month: 'numeric',
|
month: 'numeric',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="relative ml-1 pb-2 text-base font-bold text-white">本月链接转化</div>
|
<div class="relative ml-1 pb-2 text-base font-bold text-white">本月链接转化</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 flex flex-col gap-2">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
v-for="item in data"
|
v-for="item in data"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 text-xs text-white/40">相比前一个月 {{ growthRate }}</div>
|
<div class="mt-1 text-xs text-white/40">相比前一个月 {{ growthRate }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ import request from '@/utils/request'
|
|||||||
const data = ref([
|
const data = ref([
|
||||||
{ label: '点击量', value: 0, key: 'clicks' },
|
{ label: '点击量', value: 0, key: 'clicks' },
|
||||||
{ label: '浏览量', value: 0, key: 'views' },
|
{ label: '浏览量', value: 0, key: 'views' },
|
||||||
{ label: '付费数量', value: 0, key: 'paid_count' },
|
{ label: '付费用户数', value: 0, key: 'paid_count' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const growthRate = ref('N/A')
|
const growthRate = ref('N/A')
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="lucid-glass-bar flex h-full w-full flex-col rounded-4xl! border-1 border-white px-6 py-7"
|
||||||
|
>
|
||||||
|
<div class="mb-[20px] flex justify-between border-b-1 border-dashed pb-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-base font-bold text-white">退费数据</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
class="h-auto p-0 text-sm font-medium text-white/60 underline hover:text-white"
|
||||||
|
@click="$emit('show-history')"
|
||||||
|
>
|
||||||
|
查看全部
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto pr-1">
|
||||||
|
<div v-if="loading" class="flex h-10 items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="h-6 w-6 animate-spin rounded-full border-2 border-[#ADFF5B] border-t-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in salesPage.list"
|
||||||
|
:key="index"
|
||||||
|
class="mb-3 flex items-center justify-between last:mb-0"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-semibold text-white">{{ item.id }}</span>
|
||||||
|
<span class="text-[10px] text-white/40">{{ formatTime(item.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-bold text-white tabular-nums">
|
||||||
|
$ {{ formatAmount(item.amount) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!salesPage.list.length">暂无数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
defineEmits(['show-history'])
|
||||||
|
|
||||||
|
const salesPage = ref({
|
||||||
|
total: 0,
|
||||||
|
list: [] as any[],
|
||||||
|
})
|
||||||
|
function formatAmount(amount: number) {
|
||||||
|
return ((amount || 0) / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: number) {
|
||||||
|
if (!timestamp) return '-'
|
||||||
|
const date = new Date(timestamp > 10000000000 ? timestamp : timestamp * 1000)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchSales() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await request.get('/api/v1/public/user/withdrawal_log', {
|
||||||
|
page: 1,
|
||||||
|
size: 2,
|
||||||
|
biz_type: 'commission_refund',
|
||||||
|
})
|
||||||
|
salesPage.value = res
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch sales error:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSales()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
<div
|
<div
|
||||||
class="lucid-glass-bar flex h-full w-full flex-col rounded-4xl! border-1 border-white px-6 py-7"
|
class="lucid-glass-bar flex h-full w-full flex-col rounded-4xl! border-1 border-white px-6 py-7"
|
||||||
>
|
>
|
||||||
<div class="mb-[20px] flex justify-between border-b-1 border-dashed pb-4">
|
<div class="mb-[10px] flex justify-between border-b-1 border-dashed pb-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 text-base font-bold text-white">本月销售数据</div>
|
<div class="mb-1 text-base font-bold text-white">本月销售数据</div>
|
||||||
<div class="text-sm text-white/40">本月已成交订单{{ salesPage.total }}人/次</div>
|
<div class="text-sm text-white/40">本月已成交{{ salesPage.total }}单</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -17,10 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto pr-1">
|
<div class="flex-1 overflow-y-auto pr-1">
|
||||||
<div
|
<div v-if="loading" class="flex h-[100px] items-center justify-center">
|
||||||
v-if="loading && salesPage.list.length === 0"
|
|
||||||
class="flex h-20 items-center justify-center"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="h-6 w-6 animate-spin rounded-full border-2 border-[#ADFF5B] border-t-transparent"
|
class="h-6 w-6 animate-spin rounded-full border-2 border-[#ADFF5B] border-t-transparent"
|
||||||
></div>
|
></div>
|
||||||
@@ -29,14 +26,15 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(item, index) in salesPage.list"
|
v-for="(item, index) in salesPage.list"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="mb-5 flex items-center justify-between last:mb-0"
|
class="mb-3 flex items-center justify-between last:mb-0"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-sm font-semibold text-white">{{ item.user_hash }}</span>
|
<span class="text-sm font-semibold text-white">{{ item.user_hash }}</span>
|
||||||
<span class="text-[10px] text-white/40">{{ formatTime(item.update_at) }}</span>
|
<span class="text-[10px] text-white/40">{{ formatTime(item.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-lg font-bold text-white tabular-nums">$ {{ item.amount }}</div>
|
<div class="text-lg font-bold text-white tabular-nums">$ {{ item.amount }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!salesPage.list.length">暂无数据</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +67,7 @@ async function fetchSales() {
|
|||||||
const end_time = Math.floor(endOfMonth.getTime() / 1000)
|
const end_time = Math.floor(endOfMonth.getTime() / 1000)
|
||||||
const res: any = await request.get('/api/v1/public/user/invite/sales', {
|
const res: any = await request.get('/api/v1/public/user/invite/sales', {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 8,
|
size: 3,
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
:value="t"
|
:value="t"
|
||||||
class="py-3 text-sm focus:bg-[#ADFF5B] focus:text-black"
|
class="py-3 text-sm focus:bg-[#ADFF5B] focus:text-black"
|
||||||
>
|
>
|
||||||
{{ t === 'USDT(TRC20)' ? 'USDT' : t }}
|
{{ t }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -129,7 +129,10 @@
|
|||||||
@click="fileInputRef?.click()"
|
@click="fileInputRef?.click()"
|
||||||
>
|
>
|
||||||
<template v-if="values.avatar">
|
<template v-if="values.avatar">
|
||||||
<img :src="values.avatar" class="max-h-[300px] w-full rounded-2xl object-contain" />
|
<img
|
||||||
|
:src="receiptPreviewUrl || values.avatar"
|
||||||
|
class="max-h-[300px] w-full rounded-2xl object-contain"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100"
|
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
@@ -140,6 +143,13 @@
|
|||||||
<Upload class="mb-3 h-12 w-12 text-white/20" />
|
<Upload class="mb-3 h-12 w-12 text-white/20" />
|
||||||
<span class="text-base font-medium text-white/30">点击上传高清收款二维码</span>
|
<span class="text-base font-medium text-white/30">点击上传高清收款二维码</span>
|
||||||
</template>
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="isUploadingReceipt"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black/70"
|
||||||
|
>
|
||||||
|
<Loader2 class="mr-2 h-6 w-6 animate-spin text-[#ADFF5B]" />
|
||||||
|
<span class="text-sm font-bold text-white">上传中</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormMessage class="ml-4 text-red-400" />
|
<FormMessage class="ml-4 text-red-400" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -148,7 +158,7 @@
|
|||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isPending"
|
:disabled="isPending || isUploadingReceipt"
|
||||||
class="h-[30px] w-[100px] rounded-full bg-[#ADFF5B] text-sm font-black text-black shadow-[0_10px_30px_rgba(173,255,91,0.3)] transition-all hover:scale-105 hover:bg-[#9ded4e] active:scale-95 disabled:opacity-50"
|
class="h-[30px] w-[100px] rounded-full bg-[#ADFF5B] text-sm font-black text-black shadow-[0_10px_30px_rgba(173,255,91,0.3)] transition-all hover:scale-105 hover:bg-[#9ded4e] active:scale-95 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isPending" class="mr-2 h-7 w-7 animate-spin" />
|
<Loader2 v-if="isPending" class="mr-2 h-7 w-7 animate-spin" />
|
||||||
@@ -189,64 +199,88 @@ const emit = defineEmits(['confirm'])
|
|||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const isPending = ref(false) // 手动管理加载状态
|
const isPending = ref(false) // 手动管理加载状态
|
||||||
|
const isUploadingReceipt = ref(false)
|
||||||
|
const receiptPreviewUrl = ref('')
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const ACCOUNT_TYPE = ['USDT(TRC20)', '微信', '支付宝'] as const
|
const ACCOUNT_TYPE = ['USDT(TRC20)', '微信', '支付宝'] as const
|
||||||
|
const RECEIPT_UPLOAD_BIZ_TYPE = 'app-package'
|
||||||
|
const WITHDRAW_METHOD = {
|
||||||
|
支付宝: 1,
|
||||||
|
微信: 2,
|
||||||
|
'USDT(TRC20)': 3,
|
||||||
|
} as const
|
||||||
|
|
||||||
const compressImage = (file: File, quality = 0.7, maxWidth = 1024): Promise<string> => {
|
type FileUploadResponse =
|
||||||
return new Promise((resolve, reject) => {
|
| string
|
||||||
const reader = new FileReader()
|
| {
|
||||||
reader.readAsDataURL(file)
|
url?: string
|
||||||
reader.onload = (e) => {
|
file_url?: string
|
||||||
const img = new Image()
|
full_url?: string
|
||||||
img.src = e.target?.result as string
|
path?: string
|
||||||
img.onload = () => {
|
uri?: string
|
||||||
const canvas = document.createElement('canvas')
|
file?: string
|
||||||
let width = img.width
|
src?: string
|
||||||
let height = img.height
|
|
||||||
|
|
||||||
if (width > maxWidth) {
|
|
||||||
height = (maxWidth / width) * height
|
|
||||||
width = maxWidth
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.width = width
|
const revokeReceiptPreview = () => {
|
||||||
canvas.height = height
|
if (receiptPreviewUrl.value) {
|
||||||
|
URL.revokeObjectURL(receiptPreviewUrl.value)
|
||||||
const ctx = canvas.getContext('2d')
|
receiptPreviewUrl.value = ''
|
||||||
ctx?.drawImage(img, 0, 0, width, height)
|
|
||||||
|
|
||||||
const compressedBase64 = canvas.toDataURL('image/jpeg', quality)
|
|
||||||
resolve(compressedBase64)
|
|
||||||
}
|
}
|
||||||
img.onerror = reject
|
}
|
||||||
}
|
|
||||||
reader.onerror = reject
|
const getUploadedFileUrl = (data: FileUploadResponse) => {
|
||||||
})
|
if (typeof data === 'string') return data
|
||||||
|
|
||||||
|
return (
|
||||||
|
data.url ||
|
||||||
|
data.file_url ||
|
||||||
|
data.full_url ||
|
||||||
|
data.path ||
|
||||||
|
data.uri ||
|
||||||
|
data.file ||
|
||||||
|
data.src ||
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadReceiptCode = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('biz_type', RECEIPT_UPLOAD_BIZ_TYPE)
|
||||||
|
formData.append('file', file, file.name)
|
||||||
|
|
||||||
|
const data = await request.post<FormData, FileUploadResponse>(
|
||||||
|
'/api/v1/public/file/upload',
|
||||||
|
formData,
|
||||||
|
)
|
||||||
|
return getUploadedFileUrl(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onFileChange = async (e: Event) => {
|
const onFileChange = async (e: Event) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
if (!file.type.startsWith('image/')) {
|
||||||
// 压缩图片
|
toast.error('请上传图片文件')
|
||||||
const compressedBase64 = await compressImage(file)
|
|
||||||
|
|
||||||
// 计算压缩后的大小
|
|
||||||
const base64Length = compressedBase64.split(',')[1].length
|
|
||||||
const sizeInBytes = base64Length * (3 / 4)
|
|
||||||
|
|
||||||
if (sizeInBytes > 2 * 1024 * 1024) {
|
|
||||||
// 限制 2MB
|
|
||||||
toast.error('图片过大,压缩后仍超过 2MB')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setFieldValue('avatar', compressedBase64)
|
try {
|
||||||
|
isUploadingReceipt.value = true
|
||||||
|
const uploadedUrl = await uploadReceiptCode(file)
|
||||||
|
if (!uploadedUrl) {
|
||||||
|
toast.error('上传失败,请重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeReceiptPreview()
|
||||||
|
receiptPreviewUrl.value = URL.createObjectURL(file)
|
||||||
|
setFieldValue('avatar', uploadedUrl)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('图片处理失败:', err)
|
console.error('收款码上传失败:', err)
|
||||||
toast.error('图片处理失败,请重试')
|
toast.error('上传失败,请重试')
|
||||||
} finally {
|
} finally {
|
||||||
|
isUploadingReceipt.value = false
|
||||||
// 重置 input,允许重新选择同一张图
|
// 重置 input,允许重新选择同一张图
|
||||||
if (e.target) {
|
if (e.target) {
|
||||||
;(e.target as HTMLInputElement).value = ''
|
;(e.target as HTMLInputElement).value = ''
|
||||||
@@ -282,6 +316,7 @@ const { handleSubmit, resetForm, values, setFieldValue } = useForm({
|
|||||||
|
|
||||||
// --- 暴露给父组件的方法 ---
|
// --- 暴露给父组件的方法 ---
|
||||||
const show = () => {
|
const show = () => {
|
||||||
|
revokeReceiptPreview()
|
||||||
resetForm()
|
resetForm()
|
||||||
open.value = true
|
open.value = true
|
||||||
}
|
}
|
||||||
@@ -307,29 +342,16 @@ const onSubmit = handleSubmit(async (val) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查是否有未完成工单
|
|
||||||
const data = await request.get<any>('/api/v1/public/ticket/list', {
|
|
||||||
page: 1,
|
|
||||||
size: 1,
|
|
||||||
issue_type: 1,
|
|
||||||
})
|
|
||||||
if (data?.list?.length > 0) {
|
|
||||||
toast.info('已有待处理申请')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 执行提现请求
|
// 3. 执行提现请求
|
||||||
const description =
|
await request.post('/api/v1/public/user/commission_withdraw', {
|
||||||
val.type === 'USDT(TRC20)' ? `${val.type}-${val.account}` : `${val.type}-${val.avatar}`
|
amount: Math.round(amount * 100),
|
||||||
|
method: WITHDRAW_METHOD[val.type],
|
||||||
await request.post('/api/v1/public/ticket/', {
|
account: val.type === 'USDT(TRC20)' ? val.account : '',
|
||||||
title: `提现-${val.money}`,
|
qr_code_url: val.type === 'USDT(TRC20)' ? '' : val.avatar,
|
||||||
description,
|
|
||||||
issue_type: 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. 成功后处理
|
// 4. 成功后处理
|
||||||
toast.success('提交成功')
|
toast.success('提现申请已提交,请等待审核')
|
||||||
open.value = false
|
open.value = false
|
||||||
emit('confirm') // 触发父组件刷新 info 接口
|
emit('confirm') // 触发父组件刷新 info 接口
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="setOpen">
|
||||||
|
<DialogContent
|
||||||
|
class="top-[94px] flex h-[calc(100vh-94px-50px)] w-[386px] translate-y-0 flex-col items-center justify-center border-none bg-transparent p-0 shadow-none outline-none focus:ring-0"
|
||||||
|
:showCloseButton="false"
|
||||||
|
>
|
||||||
|
<div class="relative flex h-[628px] w-full flex-col rounded-[32px] bg-[#DDDDDD] px-4 py-6">
|
||||||
|
<button
|
||||||
|
@click="hide"
|
||||||
|
class="absolute top-6 right-6 z-10 flex size-8 items-center justify-center rounded-lg transition-colors hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="text-black"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 class="mb-2 px-4 pt-8 text-center text-[20px] font-bold text-black">{{ title }}</h2>
|
||||||
|
|
||||||
|
<WithdrawalLogList
|
||||||
|
ref="logListRef"
|
||||||
|
:biz-type="bizType"
|
||||||
|
:amount-label="amountLabel"
|
||||||
|
:empty-text="emptyText"
|
||||||
|
:show-content="showContent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
|
import WithdrawalLogList from './WithdrawalLogList.vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
bizType?: string
|
||||||
|
amountLabel?: string
|
||||||
|
emptyText?: string
|
||||||
|
showContent?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: '提现记录',
|
||||||
|
bizType: '',
|
||||||
|
amountLabel: '提现金额',
|
||||||
|
emptyText: '暂无提现记录',
|
||||||
|
showContent: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const logListRef = ref<InstanceType<typeof WithdrawalLogList> | null>(null)
|
||||||
|
|
||||||
|
const setOpen = (value: boolean) => {
|
||||||
|
isOpen.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
isOpen.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
logListRef.value?.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div class="h-[430px] overflow-y-auto pr-1">
|
||||||
|
<div v-if="loading" class="flex h-40 items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 animate-spin rounded-full border-4 border-[#ADFF5B] border-t-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="list.length === 0"
|
||||||
|
class="flex h-40 items-center justify-center text-gray-500"
|
||||||
|
>
|
||||||
|
{{ emptyText }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-[10px]">
|
||||||
|
<div
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.id"
|
||||||
|
class="rounded-[20px] bg-[#CECECF] py-2 text-[14px] font-normal text-black"
|
||||||
|
>
|
||||||
|
<div v-if="showContent" class="grid grid-cols-2 gap-y-2">
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="text-xs text-gray-500">{{ amountLabel }}</div>
|
||||||
|
<div class="font-bold text-[#222]">${{ formatAmount(item.amount) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">退费时间</div>
|
||||||
|
<div class="whitespace-nowrap">{{ formatTime(item.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 px-4">
|
||||||
|
<div class="text-xs text-gray-500">说明</div>
|
||||||
|
<div class="break-words leading-snug">{{ item.content || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-2 gap-y-1">
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="text-xs text-gray-500">{{ amountLabel }}</div>
|
||||||
|
<div class="font-bold text-[#222]">${{ formatAmount(item.amount) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">状态</div>
|
||||||
|
<div class="font-medium">{{ statusText(item.status) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="text-xs text-gray-500">提现方式</div>
|
||||||
|
<div>{{ methodText(item.method) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">申请时间</div>
|
||||||
|
<div class="whitespace-nowrap">{{ formatTime(item.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="item.reason" class="col-span-2 pl-4">
|
||||||
|
<div class="text-xs text-gray-500">拒绝原因</div>
|
||||||
|
<div class="break-all">{{ item.reason }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="total > 0" class="flex flex-col items-center pt-[18px]">
|
||||||
|
<div class="mb-2 flex items-center gap-[10px]">
|
||||||
|
<button
|
||||||
|
@click="changePage(1)"
|
||||||
|
:disabled="page === 1"
|
||||||
|
class="flex h-[30px] min-w-[30px] items-center justify-center rounded-full bg-[#EAEAEA] transition-opacity disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-bold text-[#848484]"><<</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="changePage(page - 1)"
|
||||||
|
:disabled="page === 1"
|
||||||
|
class="flex h-[30px] min-w-[30px] items-center justify-center rounded-full bg-[#EAEAEA] transition-opacity disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-bold text-[#848484]"><</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex h-[30px] min-w-[65px] items-center justify-center rounded-full bg-[#EAEAEA] px-2"
|
||||||
|
>
|
||||||
|
<span class="text-base text-[#848484]">{{ page }}</span>
|
||||||
|
<span class="ml-1 text-base text-[#848484]">v</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="changePage(page + 1)"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
class="flex h-[30px] min-w-[30px] items-center justify-center rounded-full bg-[#EAEAEA] transition-opacity disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-bold text-[#848484]">></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="changePage(totalPages)"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
class="flex h-[30px] min-w-[30px] items-center justify-center rounded-full bg-[#EAEAEA] transition-opacity disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-bold text-[#848484]">>></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-[300] text-[#848484]">第 {{ page }} / {{ totalPages }} 页</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
bizType?: string
|
||||||
|
amountLabel?: string
|
||||||
|
emptyText?: string
|
||||||
|
showContent?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
bizType: '',
|
||||||
|
amountLabel: '提现金额',
|
||||||
|
emptyText: '暂无提现记录',
|
||||||
|
showContent: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
interface WithdrawalLog {
|
||||||
|
id: number
|
||||||
|
amount: number
|
||||||
|
status: number
|
||||||
|
method: number
|
||||||
|
content?: string
|
||||||
|
reason?: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const size = ref(4)
|
||||||
|
const total = ref(0)
|
||||||
|
const list = ref<WithdrawalLog[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(total.value / size.value) || 1)
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await request.get('/api/v1/public/user/withdrawal_log', {
|
||||||
|
page: page.value,
|
||||||
|
size: size.value,
|
||||||
|
...(props.bizType ? { biz_type: props.bizType } : {}),
|
||||||
|
})
|
||||||
|
list.value = res.list || []
|
||||||
|
total.value = res.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch withdrawal logs error:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
page.value = 1
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(p: number) {
|
||||||
|
if (p < 1 || p > totalPages.value) return
|
||||||
|
page.value = p
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(status: number) {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
0: '待审核',
|
||||||
|
1: '已通过',
|
||||||
|
2: '已拒绝',
|
||||||
|
3: '已取消',
|
||||||
|
}
|
||||||
|
return map[status] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodText(method: number) {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
0: '其他',
|
||||||
|
1: '支付宝',
|
||||||
|
2: '微信',
|
||||||
|
3: 'USDT(TRC20)',
|
||||||
|
}
|
||||||
|
return map[method] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount: number) {
|
||||||
|
return ((amount || 0) / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: number) {
|
||||||
|
if (!timestamp) return '-'
|
||||||
|
const date = new Date(timestamp > 10000000000 ? timestamp : timestamp * 1000)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refresh,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M2.14678 6.25189H11.9369C12.5063 6.25189 13.0523 6.47268 13.4549 6.86569C13.8575 7.2587 14.0837 7.79174 14.0837 8.34754V17.9046C14.0837 18.4604 13.8575 18.9934 13.4549 19.3864C13.0523 19.7794 12.5063 20.0002 11.9369 20.0003H2.14682C1.57745 20.0003 1.03139 19.7795 0.628788 19.3864C0.226182 18.9934 4.67869e-10 18.4604 4.67869e-10 17.9046V8.34758C-5.88518e-06 8.07237 0.055518 7.79986 0.163401 7.5456C0.271285 7.29133 0.429415 7.06031 0.628762 6.8657C0.82811 6.6711 1.06477 6.51673 1.32523 6.41141C1.58569 6.30609 1.86486 6.25189 2.14678 6.25189Z" fill="black"/>
|
<path d="M2.14678 6.25189H11.9369C12.5063 6.25189 13.0523 6.47268 13.4549 6.86569C13.8575 7.2587 14.0837 7.79174 14.0837 8.34754V17.9046C14.0837 18.4604 13.8575 18.9934 13.4549 19.3864C13.0523 19.7794 12.5063 20.0002 11.9369 20.0003H2.14682C1.57745 20.0003 1.03139 19.7795 0.628788 19.3864C0.226182 18.9934 4.67869e-10 18.4604 4.67869e-10 17.9046V8.34758C-5.88518e-06 8.07237 0.055518 7.79986 0.163401 7.5456C0.271285 7.29133 0.429415 7.06031 0.628762 6.8657C0.82811 6.6711 1.06477 6.51673 1.32523 6.41141C1.58569 6.30609 1.86486 6.25189 2.14678 6.25189Z" fill="currentColor"/>
|
||||||
<path d="M18.3412 0H8.55111C7.98218 0.00138285 7.43695 0.222623 7.03465 0.615344C6.63235 1.00807 6.40571 1.54031 6.4043 2.0957V5.00011H11.9369C12.8457 5.00221 13.7166 5.35556 14.3592 5.98286C15.0018 6.61016 15.3638 7.46036 15.366 8.3475V13.7484H18.3412C18.9101 13.747 19.4553 13.5257 19.8576 13.133C20.2599 12.7403 20.4866 12.2081 20.488 11.6527V2.09572C20.4866 1.54033 20.2599 1.00808 19.8576 0.615355C19.4553 0.222629 18.9101 0.00138455 18.3412 0Z" fill="black"/>
|
<path d="M18.3412 0H8.55111C7.98218 0.00138285 7.43695 0.222623 7.03465 0.615344C6.63235 1.00807 6.40571 1.54031 6.4043 2.0957V5.00011H11.9369C12.8457 5.00221 13.7166 5.35556 14.3592 5.98286C15.0018 6.61016 15.3638 7.46036 15.366 8.3475V13.7484H18.3412C18.9101 13.747 19.4553 13.5257 19.8576 13.133C20.2599 12.7403 20.4866 12.2081 20.488 11.6527V2.09572C20.4866 1.54033 20.2599 1.00808 19.8576 0.615355C19.4553 0.222629 18.9101 0.00138455 18.3412 0Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -12,13 +12,21 @@
|
|||||||
<div class="ml-2 flex flex-col justify-center text-white">
|
<div class="ml-2 flex flex-col justify-center text-white">
|
||||||
<div class="text-base font-semibold">{{ userInfo.email }}</div>
|
<div class="text-base font-semibold">{{ userInfo.email }}</div>
|
||||||
<div class="flex items-center text-base font-semibold">
|
<div class="flex items-center text-base font-semibold">
|
||||||
<span class="mr-0.5 text-3xl">🌞</span> 超级合伙人 返佣比例{{
|
<span class="mr-0.5 text-3xl">🌞</span> {{ referralText }}
|
||||||
userInfo.referral_percentage
|
|
||||||
}}%
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-7 mb-1 ml-[18px] font-semibold text-[#ADFF5B]">专属代理链接</div>
|
<div class="mt-7 mb-1 ml-[18px] flex justify-between font-semibold text-[#ADFF5B]">
|
||||||
|
专属代理链接
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
class="mr-6 h-auto p-0 text-sm font-bold text-[#999] underline outline-none hover:text-[#666]"
|
||||||
|
@click="withdrawalLogDialogRef?.show()"
|
||||||
|
>
|
||||||
|
提现记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mb-[10px] flex h-[50px] w-full items-center justify-between rounded-[32px] bg-[#ADFF5B] px-4 font-medium text-black"
|
class="mb-[10px] flex h-[50px] w-full items-center justify-between rounded-[32px] bg-[#ADFF5B] px-4 font-medium text-black"
|
||||||
>
|
>
|
||||||
@@ -29,13 +37,18 @@
|
|||||||
class="flex min-h-[90px] w-full items-center justify-between rounded-[25px] bg-[#ADFF5B] px-4 font-medium text-black"
|
class="flex min-h-[90px] w-full items-center justify-between rounded-[25px] bg-[#ADFF5B] px-4 font-medium text-black"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold">佣金账户余额</div>
|
<div class="flex items-center text-xl font-semibold">佣金账户余额</div>
|
||||||
<div class="text-3xl font-black">
|
<div class="text-3xl font-black">
|
||||||
$ {{ (userInfo.commission / 100 || 0).toFixed(2) }}
|
$ {{ (userInfo.commission / 100 || 0).toFixed(2) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" class="hover:bg-transparent" @click="walletDialogRef?.show()">
|
<Button
|
||||||
点击提现
|
variant="ghost"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
:disabled="isCheckingWithdrawal"
|
||||||
|
@click="handleWithdrawClick"
|
||||||
|
>
|
||||||
|
{{ hasPendingWithdrawal ? '审核中' : '点击提现' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,8 +56,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<WalletDialog ref="walletDialogRef" :commission="userInfo.commission || 0" @confirm="init" />
|
<WalletDialog ref="walletDialogRef" :commission="userInfo.commission || 0" @confirm="init" />
|
||||||
|
<WithdrawalLogDialog ref="withdrawalLogDialogRef" />
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-col gap-[10px] px-6 pt-8 pb-9">
|
<div class="flex flex-col gap-[10px] px-6 pt-8 pb-4">
|
||||||
|
<div
|
||||||
|
class="flex h-[50px] w-full items-center justify-between rounded-[32px] bg-[#222222] px-4 pr-6 leading-[50px] font-medium"
|
||||||
|
>
|
||||||
|
我的邀请码:{{ userInfo.refer_code }}
|
||||||
|
<CopyIcon class="size-5 cursor-pointer text-white" @click="copy(userInfo.refer_code)" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-[-2px] mb-1 px-2 text-[11px] leading-snug text-[#ADFF5B]">
|
||||||
|
*
|
||||||
|
提醒:如果客户没有海外苹果ID,使用官方提供的ID会导致绑定失败,用户可以手动输入此邀请码绑定代理。
|
||||||
|
</div>
|
||||||
<div class="h-[50px] w-full rounded-[32px] bg-[#222222] px-4 leading-[50px] font-medium">
|
<div class="h-[50px] w-full rounded-[32px] bg-[#222222] px-4 leading-[50px] font-medium">
|
||||||
历史佣金总计:$ {{ (inviteStats.friendly_count / 100).toFixed(2) }}
|
历史佣金总计:$ {{ (inviteStats.friendly_count / 100).toFixed(2) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -68,6 +92,7 @@
|
|||||||
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
|
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import WalletDialog from './components/WalletDialog.vue'
|
import WalletDialog from './components/WalletDialog.vue'
|
||||||
|
import WithdrawalLogDialog from './components/WithdrawalLogDialog.vue'
|
||||||
import CopyIcon from './copy.svg?component'
|
import CopyIcon from './copy.svg?component'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
@@ -76,17 +101,21 @@ import { toast } from 'vue-sonner'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const walletDialogRef = ref<InstanceType<typeof WalletDialog> | null>(null)
|
const walletDialogRef = ref<InstanceType<typeof WalletDialog> | null>(null)
|
||||||
|
const withdrawalLogDialogRef = ref<InstanceType<typeof WithdrawalLogDialog> | null>(null)
|
||||||
const userInfo = ref({
|
const userInfo = ref({
|
||||||
email: '',
|
email: '',
|
||||||
created_at: '',
|
created_at: '',
|
||||||
share_link: '',
|
share_link: '',
|
||||||
commission: 0,
|
commission: 0,
|
||||||
|
referral_percentage: 0,
|
||||||
})
|
})
|
||||||
const inviteStats = ref({
|
const inviteStats = ref({
|
||||||
friendly_count: 0,
|
friendly_count: 0,
|
||||||
history_count: 0,
|
history_count: 0,
|
||||||
})
|
})
|
||||||
const isUserLoading = ref(true)
|
const isUserLoading = ref(true)
|
||||||
|
const isCheckingWithdrawal = ref(false)
|
||||||
|
const hasPendingWithdrawal = ref(false)
|
||||||
async function init() {
|
async function init() {
|
||||||
// 1. 用户信息 & 设备列表
|
// 1. 用户信息 & 设备列表
|
||||||
isUserLoading.value = true
|
isUserLoading.value = true
|
||||||
@@ -108,6 +137,8 @@ async function init() {
|
|||||||
request.get('/api/v1/public/user/invite/stats').then((res: any) => {
|
request.get('/api/v1/public/user/invite/stats').then((res: any) => {
|
||||||
inviteStats.value = res
|
inviteStats.value = res
|
||||||
})
|
})
|
||||||
|
|
||||||
|
checkPendingWithdrawal()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -131,6 +162,51 @@ const formattedDate = computed(() => {
|
|||||||
}).format(date)
|
}).format(date)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const referralText = computed(() => {
|
||||||
|
const percentage = Number(userInfo.value.referral_percentage || 0)
|
||||||
|
if (percentage <= 0) return '暂未激活比例,请联系客服激活'
|
||||||
|
if (percentage <= 20) return `初级合伙人 返佣比例${percentage}%`
|
||||||
|
if (percentage <= 39) return `高级合伙人 返佣比例${percentage}%`
|
||||||
|
return `超级合伙人 返佣比例${percentage}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleWithdrawClick() {
|
||||||
|
if (hasPendingWithdrawal.value) {
|
||||||
|
toast.info('已有提现申请审核中,请勿重复提交')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isCheckingWithdrawal.value) return
|
||||||
|
|
||||||
|
const canWithdraw = await checkPendingWithdrawal(true)
|
||||||
|
if (canWithdraw) {
|
||||||
|
walletDialogRef.value?.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPendingWithdrawal(showErrorToast = false) {
|
||||||
|
try {
|
||||||
|
isCheckingWithdrawal.value = true
|
||||||
|
const res: any = await request.get('/api/v1/public/user/withdrawal_log', {
|
||||||
|
page: 1,
|
||||||
|
size: 20,
|
||||||
|
})
|
||||||
|
hasPendingWithdrawal.value = (res?.list || []).some((item: any) => item.status === 0)
|
||||||
|
if (hasPendingWithdrawal.value) {
|
||||||
|
toast.info('已有提现申请审核中,请勿重复提交')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check withdrawal logs error:', error)
|
||||||
|
if (showErrorToast) {
|
||||||
|
toast.error('提现记录检查失败,请稍后重试')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isCheckingWithdrawal.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copy(text: string) {
|
function copy(text: string) {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
toast.success('已复制到剪贴板')
|
toast.success('已复制到剪贴板')
|
||||||
|
|||||||
@@ -1,35 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-screen flex-col bg-black text-white">
|
<div class="flex min-h-screen flex-col bg-black text-white">
|
||||||
<!-- Full Width Header -->
|
<AppHeader />
|
||||||
<div class="h-[60px] md:h-[125px]">
|
|
||||||
<div class="fixed top-[20px] z-50 w-full md:top-[45px]">
|
|
||||||
<div class="container">
|
|
||||||
<header
|
|
||||||
class="lucid-glass-bar flex h-[40px] items-center justify-between rounded-[90px] pr-[5px] pl-5 transition-all duration-300 md:h-[60px] md:pr-[10px]"
|
|
||||||
>
|
|
||||||
<router-link to="/" class="flex items-center gap-2">
|
|
||||||
<!-- Desktop Logo -->
|
|
||||||
<Logo alt="Hi快VPN" class="h-[18px] w-auto md:ml-8 md:h-[29px]" />
|
|
||||||
<span class="ml-3 text-2xl font-black">高能合伙人</span>
|
|
||||||
</router-link>
|
|
||||||
<div v-if="isLoggedIn" class="flex items-center">
|
|
||||||
<router-link
|
|
||||||
to="/user-center"
|
|
||||||
class="flex size-[30px] items-center justify-center rounded-full bg-[#78788029] text-xl font-bold text-white shadow-lg transition hover:scale-105 md:size-[40px] md:text-3xl"
|
|
||||||
>
|
|
||||||
{{ userLetter }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="flex h-[30px] cursor-pointer items-center justify-center rounded-full bg-[#78788029] px-6 text-sm font-bold backdrop-blur-md transition hover:brightness-110 md:h-[40px] md:w-[220px] md:text-xl"
|
|
||||||
>
|
|
||||||
代理后台登录/注册
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-1 flex-col">
|
<div class="flex flex-1 flex-col">
|
||||||
<!-- Main Neon Green Card -->
|
<!-- Main Neon Green Card -->
|
||||||
<!-- <div class="container md:hidden">
|
<!-- <div class="container md:hidden">
|
||||||
@@ -45,21 +16,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
import DesktopLayout from './DesktopLayout/index.vue'
|
import DesktopLayout from './DesktopLayout/index.vue'
|
||||||
import Logo from '@/pages/Home/logo.svg?component'
|
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
|
||||||
|
|
||||||
const token = useLocalStorage('Authorization', '')
|
|
||||||
const userEmail = useLocalStorage('UserEmail', '')
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
|
||||||
const userLetter = computed(() => {
|
|
||||||
if (!userEmail.value) return '?'
|
|
||||||
return userEmail.value.charAt(0).toUpperCase()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ html, body {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|
||||||
/* 1. 移动端默认:左右 18px 边距 */
|
/* 1. 移动端默认:左右保持相同的 24px 边距 */
|
||||||
padding-left: 18px;
|
padding-left: 24px;
|
||||||
padding-right: 18px;
|
padding-right: 24px;
|
||||||
|
|
||||||
/* 2. 桌面端逻辑:当屏幕达到 1440px 及以上 */
|
/* 2. 桌面端逻辑:当屏幕达到 1440px 及以上 */
|
||||||
@media (width >= 1440px) {
|
@media (width >= 1440px) {
|
||||||
|
|||||||