first commit

This commit is contained in:
Phoenix 2025-04-10 15:47:40 +08:00
commit 6e9ffc5096
168 changed files with 26848 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
.history/
.history/**
**/.history/**

1
.npmrc Normal file
View File

@ -0,0 +1 @@
shamefully-hoist=true

10
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"recommendations": [
"nuxtr.nuxtr-vscode",
"vue.vscode-typescript-vue-plugin",
"vue.volar",
"dbaeumer.vscode-eslint",
"antfu.iconify",
"lokalise.i18n-ally"
]
}

51
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,51 @@
{
"files.associations": {
"*.css": "postcss"
},
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": [
"i18n/locales"
],
"i18n-ally.sourceLanguage": "zh-CN",
"i18n-ally.displayLanguage": "zh-CN",
"typescript.tsdk": "node_modules/typescript/lib",
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off" },
{ "rule": "*-indent", "severity": "off" },
{ "rule": "*-spacing", "severity": "off" },
{ "rule": "*-spaces", "severity": "off" },
{ "rule": "*-order", "severity": "off" },
{ "rule": "*-dangle", "severity": "off" },
{ "rule": "*-newline", "severity": "off" },
{ "rule": "*quotes", "severity": "off" },
{ "rule": "*semi", "severity": "off" }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml"
]
}

98
README.md Normal file
View File

@ -0,0 +1,98 @@
<!-- markdownlint-disable MD033 MD041 -->
<div id="top" align="center">
<img src="https://cdn.jsdelivr.net/gh/easy-temps/easy-static/cover.png" alt="cover" />
<h1 align="center">nuxt-vant-mobile</h1>
一个基于 Nuxt _⁴_ 生态系统的移动端 Web 应用模板。
一个基于 Nuxt _⁴_ 生态系统的移动端 Web 应用模板,帮助你快速完成业务开发。
<p>
<img src="https://img.shields.io/github/license/easy-temps/nuxt-vant-mobile" alt="license" />
<img src="https://img.shields.io/github/package-json/v/easy-temps/nuxt-vant-mobile" alt="version" />
<img src="https://img.shields.io/github/repo-size/easy-temps/nuxt-vant-mobile" alt="repo-size" />
<img src="https://img.shields.io/github/languages/top/easy-temps/nuxt-vant-mobile" alt="languages" />
<img src="https://img.shields.io/github/issues-closed/easy-temps/nuxt-vant-mobile" alt="issues" />
</p>
[文档](https://easy-temps.github.io/easy-docs/nuxt3-vant-mobile/) / [交流](https://github.com/easy-temps/vue3-vant-mobile/issues/56) / [反馈](https://github.com/easy-temps/nuxt-vant-mobile/issues)
🖥 <a href="https://nuxt-vant-mobile.netlify.app">在线预览</a>
[![Netlify Status](https://api.netlify.com/api/v1/badges/1eb0d3f7-69a1-4972-a2b7-9e317ffa5c63/deploy-status)](https://app.netlify.com/sites/nuxt-vant-mobile/deploys)
</div>
## 特性
- 💚 [Nuxt](https://nuxt.com/) - SSR、ESR、基于文件的路由、组件自动导入、模块等
- ⚡️ Vite - 即时热更新
- 🎨 [UnoCSS](https://github.com/unocss/unocss) - 即时按需原子化 CSS 引擎
- 😃 使用纯 CSS 的任意图标集,由 [UnoCSS](https://github.com/unocss/unocss) 提供支持
- 🔥 `<script setup>` 语法
- 🌍 [国际化支持](./i18n/locales)
- 📑 [布局系统](./app/layouts)
- 📥 API 自动导入 - 用于 Composition API 和自定义组合式函数
- 🦾 当然支持 TypeScript
- ☁️ 零配置部署在 [Netlify](https://www.netlify.com) 上
## Nuxt 模块
- [Vant](https://github.com/youzan/vant) - 移动端 Vue UI 库
- [Nuxt ESLint](https://github.com/nuxt/eslint) - Nuxt 的 ESLint 相关包集合
- [i18n](https://github.com/nuxt-modules/i18n) - Nuxt 的国际化模块
- [ColorMode](https://github.com/nuxt-modules/color-mode) - 支持自动检测的深色和浅色模式
- [UnoCSS](https://github.com/unocss/unocss) - 即时按需原子化 CSS 引擎
- [DevTools](https://github.com/nuxt/devtools) - 释放 Nuxt 开发者体验
## IDE
我们推荐使用 [VS Code](https://code.visualstudio.com/) 搭配 [Volar](https://github.com/johnsoncodehk/volar) 以获得最佳体验(如果你安装了 [Vetur](https://vuejs.github.io/vetur/),建议禁用它)
## 立即尝试
### GitHub 模板
[从 GitHub 上使用此模板创建仓库](https://github.com/easy-temps/nuxt-vant-mobile/generate)
### 克隆到本地
如果你更喜欢手动操作并保持更清晰的 git 历史
```bash
npx tiged easy-temps/nuxt-vant-mobile my-nuxt-app
cd my-nuxt-app
pnpm i # 如果你没有安装 pnpm请运行npm install -g pnpm
```
## 使用方法
### 开发
只需运行并访问 <http://localhost:3000>
```bash
pnpm dev
```
### 构建
要构建应用,运行
```bash
pnpm build
```
你将在 `.output` 目录中看到生成的可以部署的文件。

View File

@ -0,0 +1,30 @@
import { request } from '@/api-collect-code/http.js'
export async function checkPhone(data) {
return await request({
url:'/api/v1/common/check/phone',
method: 'POST',
data
})
}
export async function userSend(data) {
return await request( {
url:'/api/v1/m/user/mobile/send',
method: 'POST',
data
})
}
export async function mobileLogin(data) {
return await request( {
url:'/api/v1/m/user/mobile/login',
method: 'POST',
data
})
}
export async function sessionUserNo(data) {
return await request( {
url:'/api/v1/auction/sessionUserNo/check',
method: 'POST',
data
})
}

View File

@ -0,0 +1,63 @@
import { request } from '@/api-collect-code/http.js'
export async function offlineQrcodeList(data) {
return await request( {
url:'/api/v1/offlineQrcode/query',
method: 'POST',
data
})
}
export async function offlineQrcodeCreate(data) {
return await request ({
url:'/api/v1/offlineQrcode/create',
method: 'POST',
data
})
}
export async function offlineQrcodeDelete(data) {
return await request ({
url:'/api/v1/offlineQrcode/delete',
method: 'POST',
data
})
}
export async function userArtworks(data) {
return await request( {
url:'/api/v1/m/user/artworks',
method: 'POST',
data
})
}
export async function fddInfo(data) {
return await request( {
url:'/api/v1/contract/fdd-info',
method: 'POST',
data
})
}
export async function sessionUserNoCreate(data) {
return await request( {
url:'/api/v1/auction/sessionUserNo/create',
method: 'POST',
data
})
}
export async function offlineQrcode(data) {
return await request( {
url:'/api/v1/offlineQrcode/info',
method: 'POST',
data
})
}
export async function createOrder(data) {
return await request( {
url:'/api/v1/offlineQrcode/createOrder/V2',
method: 'POST',
data
})
}

View File

@ -0,0 +1,130 @@
import {useRuntimeConfig} from '#app'
import {ofetch} from 'ofetch'
import {message} from '@/components/x-message/useMessage.js'
import {codeAuthStore} from "@/stores-collect-code/auth/index.js"
let httpStatusErrorHandler
let http
// HTTP 状态码映射 - 使用i18n国际化
export function setupHttp() {
if (http) return http
const {codeToken}= codeAuthStore()
const config = useRuntimeConfig()
const baseURL = config.public.NUXT_PUBLIC_API_BASE
const router = useRouter()
const i18n = useNuxtApp().$i18n
// 国际化的HTTP状态码映射
const HTTP_STATUS_MAP = {
400: i18n.t('http.error.badRequest'),
401: i18n.t('http.error.unauthorized'),
403: i18n.t('http.error.forbidden'),
404: i18n.t('http.error.notFound'),
500: i18n.t('http.error.serverError'),
502: i18n.t('http.error.badGateway'),
503: i18n.t('http.error.serviceUnavailable'),
504: i18n.t('http.error.gatewayTimeout')
}
const defaultOptions = {
baseURL,
headers: { 'Content-Type': 'application/json' },
timeout: 15000, // 15秒超时
retry: 3,
retryDelay: 1000,
}
http = ofetch.create({
...defaultOptions,
// 请求拦截
async onRequest({ options, request }) {
// 添加 token
options.headers = {
...options.headers,
Authorization: codeToken.value,
'accept-language': i18n.locale.value
}
// GET 请求添加时间戳防止缓存
if (request.toLowerCase().includes('get')) {
options.params = {
...options.params,
_t: Date.now()
}
}
},
// 响应拦截
async onResponse({ response }) {
const data = response._data
// 处理业务错误
if (data.status === 1) {
message.error(data.msg || i18n.t('http.error.operationFailed'))
}
console.log('拦截响应',data)
// 处理登录失效
if (data.status === 401) {
message.error(i18n.t('http.error.loginExpired'))
codeToken.value = '' // 清除 token
router.replace('/collectCode/login')
}
return response
},
// 响应错误处理
async onResponseError({ response, request }) {
// 网络错误
if (!response) {
message.error(i18n.t('http.error.networkError'))
return Promise.reject(new Error(i18n.t('http.error.networkError')))
}
const status = response.status
const data = response._data
// 处理 HTTP 状态错误
const errorMessage = data.msg || HTTP_STATUS_MAP[status] || i18n.t('http.error.requestFailed')
if (Array.isArray(data.msg)) {
data.msg.forEach(item => {
httpStatusErrorHandler?.(item, status)
})
} else {
httpStatusErrorHandler?.(errorMessage, status)
}
message.error(errorMessage)
return Promise.reject(data)
},
})
return http
}
export function createAbortController() {
return new AbortController()
}
export function injectHttpStatusErrorHandler(handler) {
httpStatusErrorHandler = handler
}
export function getHttp() {
if (!http) {
throw new Error(useNuxtApp().$i18n.t('http.error.httpNotInitialized'))
}
return http
}
// 导出请求工具函数
export async function request({url,...options}) {
const http = getHttp()
try {
return await http(url, {...options,body:options.data})
} catch (error) {
throw error
}
}

125
app/api-public/http.js Normal file
View File

@ -0,0 +1,125 @@
import {useRuntimeConfig} from '#app'
import {ofetch} from 'ofetch'
import {message} from '@/components/x-message/useMessage.js'
import { getFingerprint } from '@/utils/fingerprint'
let httpStatusErrorHandler
let http
// HTTP 状态码映射 - 使用i18n国际化
export function setupHttp() {
if (http) return http
const config = useRuntimeConfig()
const baseURL = config.public.NUXT_PUBLIC_API_BASE
const router = useRouter()
const i18n = useNuxtApp().$i18n
// 国际化的HTTP状态码映射
const HTTP_STATUS_MAP = {
400: i18n.t('http.error.badRequest'),
401: i18n.t('http.error.unauthorized'),
403: i18n.t('http.error.forbidden'),
404: i18n.t('http.error.notFound'),
500: i18n.t('http.error.serverError'),
502: i18n.t('http.error.badGateway'),
503: i18n.t('http.error.serviceUnavailable'),
504: i18n.t('http.error.gatewayTimeout')
}
const defaultOptions = {
baseURL,
headers: { 'Content-Type': 'application/json' },
timeout: 15000, // 15秒超时
retry: 3,
retryDelay: 1000,
}
http = ofetch.create({
...defaultOptions,
// 请求拦截
async onRequest({ options, request }) {
const fingerprint = await getFingerprint()
console.log('fingerprint',fingerprint)
// 添加 token
options.headers = {
'Authorization': '12312',
...options.headers,
'fingerprint':fingerprint,
'accept-language': i18n.locale.value
}
// GET 请求添加时间戳防止缓存
if (request.toLowerCase().includes('get')) {
options.params = {
...options.params,
_t: Date.now()
}
}
},
// 响应拦截
async onResponse({ response }) {
const data = response._data
// 处理业务错误
if (data.status === 1) {
message.error(data.msg || i18n.t('http.error.operationFailed'))
}
return response
},
// 响应错误处理
async onResponseError({ response, request }) {
// 网络错误
if (!response) {
message.error(i18n.t('http.error.networkError'))
return Promise.reject(new Error(i18n.t('http.error.networkError')))
}
const status = response.status
const data = response._data
// 处理 HTTP 状态错误
const errorMessage = data.msg || HTTP_STATUS_MAP[status] || i18n.t('http.error.requestFailed')
if (Array.isArray(data.msg)) {
data.msg.forEach(item => {
httpStatusErrorHandler?.(item, status)
})
} else {
httpStatusErrorHandler?.(errorMessage, status)
}
message.error(errorMessage)
return Promise.reject(data)
},
})
return http
}
export function createAbortController() {
return new AbortController()
}
export function injectHttpStatusErrorHandler(handler) {
httpStatusErrorHandler = handler
}
export function getHttp() {
if (!http) {
throw new Error(useNuxtApp().$i18n.t('http.error.httpNotInitialized'))
}
return http
}
// 导出请求工具函数
export async function request({url,...options}) {
const http = getHttp()
try {
return await http(url, {...options,body:options.data})
} catch (error) {
throw error
}
}

View File

@ -0,0 +1,25 @@
import { request } from "../http";
export async function defaultDetail(data) {
return await request( {
url:'/api/v1/m/auction/out/default/detail',
method: 'POST',
data
})
}
export async function getLink(data) {
return await request( {
url:'/api/v1/m/auction/out/log/sendlog/aljdfoqueoirhkjsadhfiu',
method: 'POST',
data
})
}
export async function outBuyList(data) {
return await request( {
url:'/api/v1/m/auction/out/buy/list',
method: 'POST',
data
})
}

46
app/api/auth/index.js Normal file
View File

@ -0,0 +1,46 @@
import { request } from '@/api/http.js'
export async function senCode(data) {
return await request({
url:'/api/v1/m/user/send',
method: 'POST',
data
})
}
export async function userLogin(data) {
return await request( {
url:'/api/v1/m/user/login',
method: 'POST',
data
})
}
export async function userUpdate(data) {
return await request( {
url:'/api/v1/m/user/update',
method: 'POST',
data
})
}
export async function userCaptcha(data) {
return await request( {
url:'/api/v1/m/user/captcha',
method: 'POST',
data
})
}
export async function userCaptchaValidate(data) {
return await request( {
url:'/mall/user/validate/captcha',
method: 'POST',
data
})
}
export async function contractUserinfo(data) {
return await request( {
url:'/api/v1/contract/userinfo',
method: 'POST',
data
})
}

104
app/api/goods/index.js Normal file
View File

@ -0,0 +1,104 @@
import { request } from '@/api/http.js'
export async function artworkList(data) {
return await request( {
url:'/api/v1/m/auction/default/artwork/list',
method: 'POST',
data
})
}
export async function defaultDetail(data) {
return await request ({
url:'/api/v1/m/auction/default/detail',
method: 'POST',
data
})
}
export async function artworkDetail(data) {
return await request( {
url:'/api/v1/m/artwork/detail',
method: 'POST',
data,
})
}
export async function userArtworks(data) {
return await request( {
url:'/api/v1/m/user/artworks',
method: 'POST',
data
})
}
export async function userArtwork(data) {
return await request( {
url:'/api/v1/m/user/artwork',
method: 'POST',
data
})
}
export async function artworkBuy(data) {
return await request( {
url:'/api/v1/m/artwork/buy',
method: 'POST',
data
})
}
export async function logSendlog(data) {
return await request( {
url:'/api/v1/m/auction/log/sendlog',
method: 'POST',
data
})
}
export async function signOnline(data) {
return await request( {
url:'/api/v1/contract/sign-online',
method: 'POST',
data
})
}
export async function signOffline(data) {
return await request( {
url:'/api/v1/contract/sign-offline',
method: 'POST',
data
})
}
export async function fddCheck(data) {
return await request( {
url:'/api/v1/m/user/fdd/check',
method: 'POST',
data
})
}
export async function createBuyOrder(data) {
return await request( {
url:'/api/v1/m/auction/createBuyOrder/v2',
method: 'POST',
data
})
}
export async function orderQuery(data) {
return await request( {
url:'/api/v1/payment/order/query',
method: 'POST',
data
})
}
export async function contractView(data) {
return await request( {
url:'/api/v1/contract/contract-view',
method: 'POST',
data
})
}

130
app/api/http.js Normal file
View File

@ -0,0 +1,130 @@
import {useRuntimeConfig} from '#app'
import {ofetch} from 'ofetch'
import {message} from '@/components/x-message/useMessage.js'
import {authStore} from "@/stores/auth/index.js"
let httpStatusErrorHandler
let http
// HTTP 状态码映射 - 使用i18n国际化
export function setupHttp() {
if (http) return http
const config = useRuntimeConfig()
const baseURL = config.public.NUXT_PUBLIC_API_BASE
const { token } = authStore()
const router = useRouter()
const i18n = useNuxtApp().$i18n
// 国际化的HTTP状态码映射
const HTTP_STATUS_MAP = {
400: i18n.t('http.error.badRequest'),
401: i18n.t('http.error.unauthorized'),
403: i18n.t('http.error.forbidden'),
404: i18n.t('http.error.notFound'),
500: i18n.t('http.error.serverError'),
502: i18n.t('http.error.badGateway'),
503: i18n.t('http.error.serviceUnavailable'),
504: i18n.t('http.error.gatewayTimeout')
}
const defaultOptions = {
baseURL,
headers: { 'Content-Type': 'application/json' },
timeout: 15000, // 15秒超时
retry: 3,
retryDelay: 1000,
}
http = ofetch.create({
...defaultOptions,
// 请求拦截
async onRequest({ options, request }) {
// 添加 token
options.headers = {
...options.headers,
Authorization: token.value,
'accept-language':i18n.locale.value
}
// GET 请求添加时间戳防止缓存
if (request.toLowerCase().includes('get')) {
options.params = {
...options.params,
_t: Date.now()
}
}
},
// 响应拦截
async onResponse({ response }) {
const data = response._data
// 处理业务错误
if (data.status === 1) {
message.error(data.msg || i18n.t('http.error.operationFailed'))
}
// 处理登录失效
if (data.status === 401) {
message.error(i18n.t('http.error.loginExpired'))
token.value = '' // 清除 token
router.replace('/login')
}
return response
},
// 响应错误处理
async onResponseError({ response, request }) {
// 网络错误
if (!response) {
message.error(i18n.t('http.error.networkError'))
return Promise.reject(new Error(i18n.t('http.error.networkError')))
}
const status = response.status
const data = response._data
// 处理 HTTP 状态错误
const errorMessage = data.msg || HTTP_STATUS_MAP[status] || i18n.t('http.error.requestFailed')
if (Array.isArray(data.msg)) {
data.msg.forEach(item => {
httpStatusErrorHandler?.(item, status)
})
} else {
httpStatusErrorHandler?.(errorMessage, status)
}
message.error(errorMessage)
return Promise.reject(data)
},
})
return http
}
export function createAbortController() {
return new AbortController()
}
export function injectHttpStatusErrorHandler(handler) {
httpStatusErrorHandler = handler
}
export function getHttp() {
if (!http) {
throw new Error(useNuxtApp().$i18n.t('http.error.httpNotInitialized'))
}
return http
}
// 导出请求工具函数
export async function request({url,...options}) {
const http = getHttp()
try {
return await http(url, {...options,body:options.data})
} catch (error) {
throw error
}
}

28
app/api/public/index.js Normal file
View File

@ -0,0 +1,28 @@
import { request } from "../http";
export async function defaultDetail(data) {
return await request( {
url:'/api/v1/m/auction/out/default/detail',
headers:{
'fingerprint':'12312'
},
method: 'POST',
data
})
}
export async function getLink(data) {
return await request( {
url:'/api/v1/m/auction/out/log/sendlog/aljdfoqueoirhkjsadhfiu',
method: 'POST',
data
})
}
export async function outBuyList(data) {
return await request( {
url:'/api/v1/m/auction/out/buy/list',
method: 'POST',
data
})
}

100
app/app.vue Normal file
View File

@ -0,0 +1,100 @@
<script setup>
import {useI18n} from 'vue-i18n'
import {hideMinWindow1} from "@/components/floatingBubble/floating.js";
import AppSkeleton from '@/components/app-skeleton/index.vue'
const {t} = useI18n()
useHead({
title: t('appSetting.appName'),
meta: [
{name: 'description', content: t('appSetting.appDescription')},
{name: 'keywords', content: t('appSetting.appKeyWords')},
],
})
const route = useRoute()
//
const router = useRouter()
const slideDirection = ref('slide-left')
//
const routeHistory = ref([])
router.beforeEach((to, from) => {
//
routeHistory.value.push(from.path)
if (to.path==='/'){
hideMinWindow1()
}
//
if (routeHistory.value.includes(to.path)) {
slideDirection.value = 'slide-right'
//
const index = routeHistory.value.indexOf(to.path)
routeHistory.value = routeHistory.value.slice(0, index)
} else {
slideDirection.value = 'slide-left'
}
})
//
provide('slideDirection', slideDirection)
</script>
<template>
<client-only>
<!-- 骨架屏组件 -->
<VanConfigProvider>
<NuxtLoadingIndicator
color="repeating-linear-gradient(to right,var(--c-primary) 0%,var(--c-primary-active) 100%)"/>
<NuxtLayout>
<NuxtPage :transition="{
name: slideDirection
}" />
</NuxtLayout>
</VanConfigProvider>
</client-only>
</template>
<style>
.van-popup.van-toast{
background: var(--van-toast-background)!important;
}
.van-popup.van-popup--center.van-image-preview{
background-color: transparent!important;
max-width: 100%!important;
}
:root:root {
--van-dialog-radius: 8px
}
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: absolute;
width: 100%;
}
.slide-left-enter-from {
transform: translateX(100%);
}
.slide-left-leave-to {
transform: translateX(-100%);
}
.slide-right-enter-from {
transform: translateX(-100%);
}
.slide-right-leave-to {
transform: translateX(100%);
}
:root {
--safe-area-inset-bottom: env(safe-area-inset-bottom);
}
.van-cell.van-field .van-cell__title, .van-cell__value{
flex: initial;
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
import { useAppFooterRouteNames as names } from '@/config/index.js'
import MyIcon from "@/components/icons/MyIcon.vue";
import HomeIcon from "@/components/icons/HomeIcon.vue";
const route = useRoute()
const active = ref(0)
const show = computed(() => {
if (route.name && names.includes(route.name))
return true
return false
})
const initData=()=>{
active.value=route.path==='/profile'?1:0
}
watchEffect(initData)
onMounted(()=>{
initData()
})
</script>
<template>
<div v-if="show" v-memo="[active]" >
<van-tabbar v-model="active" route placeholder fixed>
<van-tabbar-item replace to="/">
<span>{{ $t('tabbar.home') }}</span>
<template #icon>
<HomeIcon :active="active===0"></HomeIcon>
</template>
</van-tabbar-item>
<van-tabbar-item replace to="/profile">
<span>{{ $t('tabbar.profile') }}</span>
<template #icon>
<MyIcon :active="active===1"></MyIcon>
</template>
</van-tabbar-item>
</van-tabbar>
</div>
</template>

View File

@ -0,0 +1,51 @@
<script setup>
import { useAppHeaderRouteNames as routeWhiteList } from '@/config'
import { liveStore } from "@/stores/live/index.js";
import {goodStore} from "~/stores/goods/index.js";
const { fullLive } = liveStore()
const route = useRoute()
const router = useRouter()
const {auctionDetail} = goodStore();
function onBack() {
if (fullLive.value&&route.name==='index'){
fullLive.value=false
return
}
if (window.history.state.back)
history.back()
else
router.replace('/')
}
const { t } = useI18n()
const title = computed(() => {
if (!route.meta)
return ''
return route.meta.i18n ? t(route.meta.i18n) : (route.meta.title || '')
})
const subTitle = computed(() => {
if (!route.meta)
return ''
return route.meta.subTitle ? t(route.meta.subTitle) : ''
})
const showLeftArrow = computed(() => route.name && routeWhiteList.includes(route.name))
</script>
<template>
<VanNavBar
v-memo="[title,fullLive,showLeftArrow,subTitle]"
:title="title"
:left-arrow="!showLeftArrow||fullLive"
placeholder clickable fixed
@click-left="onBack"
>
<template #title v-if="route.meta.i18n==='menu.home'">
<div class="flex flex-col items-center justify-center">
<div class="text-#000000 text-17px mb-5px font-600">{{ auctionDetail.title }}</div>
</div>
</template>
</VanNavBar>
</template>

View File

@ -0,0 +1,194 @@
<script setup>
//
</script>
<template>
<transition name="fade">
<div class="app-skeleton">
<!-- 顶部导航栏骨架 -->
<div class="skeleton-header">
<div class="skeleton-avatar"></div>
<div class="skeleton-title"></div>
</div>
<!-- 内容区域骨架 -->
<div class="skeleton-content">
<!-- 轮播图骨架 -->
<div class="skeleton-banner"></div>
<!-- 菜单项骨架 -->
<div class="skeleton-menu">
<div class="skeleton-menu-item" v-for="i in 4" :key="i"></div>
</div>
<!-- 列表项骨架 -->
<div class="skeleton-list">
<div class="skeleton-list-item" v-for="i in 3" :key="i">
<div class="skeleton-list-image"></div>
<div class="skeleton-list-content">
<div class="skeleton-list-title"></div>
<div class="skeleton-list-desc"></div>
<div class="skeleton-list-price"></div>
</div>
</div>
</div>
</div>
<!-- 底部导航栏骨架 -->
<div class="skeleton-tabbar">
<div class="skeleton-tab-item" v-for="i in 4" :key="i"></div>
</div>
</div>
</transition>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.app-skeleton {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #fff;
z-index: 9999;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 骨架屏动画 */
@keyframes skeleton-loading {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.skeleton-header, .skeleton-avatar, .skeleton-title, .skeleton-banner,
.skeleton-menu-item, .skeleton-list-image, .skeleton-list-title,
.skeleton-list-desc, .skeleton-list-price, .skeleton-tab-item {
background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
}
/* 顶部导航栏 */
.skeleton-header {
height: 44px;
padding: 0 16px;
display: flex;
align-items: center;
border-bottom: 1px solid #f5f5f5;
}
.skeleton-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.skeleton-title {
width: 120px;
height: 16px;
margin-left: 12px;
border-radius: 4px;
}
/* 内容区域 */
.skeleton-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.skeleton-banner {
width: 100%;
height: 150px;
border-radius: 8px;
margin-bottom: 20px;
}
/* 菜单项 */
.skeleton-menu {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.skeleton-menu-item {
width: 60px;
height: 60px;
border-radius: 8px;
}
/* 列表项 */
.skeleton-list-item {
display: flex;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #f5f5f5;
}
.skeleton-list-image {
width: 80px;
height: 80px;
border-radius: 4px;
flex-shrink: 0;
}
.skeleton-list-content {
flex: 1;
margin-left: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.skeleton-list-title {
height: 16px;
width: 80%;
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-list-desc {
height: 12px;
width: 60%;
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-list-price {
height: 16px;
width: 40%;
border-radius: 4px;
}
/* 底部导航栏 */
.skeleton-tabbar {
height: 50px;
display: flex;
justify-content: space-around;
align-items: center;
border-top: 1px solid #f5f5f5;
padding: 0 16px;
}
.skeleton-tab-item {
width: 24px;
height: 24px;
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,238 @@
<template>
<div ref="floatPanel" :style="panelStyle" class="float-panel"
@mousedown="handleMouseDown"
@touchstart="handleTouchStart">
<slot></slot>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
position: {
type: Object,
default: () => ({})
},
fixedX: Boolean,
fixedY: Boolean,
snapEdge: {
type: Boolean,
default: false
}
});
const floatPanel = ref(null);
const panelPos = ref({ x: 0, y: 0 });
const panelSize = ref({ width: 0, height: 0 });
const windowSize = ref({ width: 0, height: 0 });
const dragOffset = ref({ x: 0, y: 0 });
const wasDragged = ref(false);
//
const updateSizes = () => {
if (floatPanel.value) {
panelSize.value = {
width: floatPanel.value.offsetWidth,
height: floatPanel.value.offsetHeight
};
}
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
};
};
//
const initPosition = () => {
updateSizes();
const { left, right, top, bottom } = props.position;
if (right !== undefined) {
panelPos.value.x = windowSize.value.width - parseInt(right || 0) - panelSize.value.width;
} else if (left !== undefined) {
panelPos.value.x = parseInt(left || 0);
}
if (bottom !== undefined) {
panelPos.value.y = windowSize.value.height - parseInt(bottom || 0) - panelSize.value.height;
} else if (top !== undefined) {
panelPos.value.y = parseInt(top || 0);
}
};
//
const panelStyle = computed(() => {
return {
left: `${panelPos.value.x}px`,
top: `${panelPos.value.y}px`
};
});
//
const handleMouseDown = (event) => {
//
if (event.target === floatPanel.value || floatPanel.value.contains(event.target)) {
//
if (event.target === floatPanel.value) {
event.preventDefault();
}
wasDragged.value = false;
const startX = event.clientX;
const startY = event.clientY;
//
dragOffset.value = {
x: startX - panelPos.value.x,
y: startY - panelPos.value.y
};
const handleMouseMove = (e) => {
wasDragged.value = true;
const newX = e.clientX - dragOffset.value.x;
const newY = e.clientY - dragOffset.value.y;
//
if (!props.fixedX) {
panelPos.value.x = Math.max(0, Math.min(newX, windowSize.value.width - panelSize.value.width));
}
if (!props.fixedY) {
panelPos.value.y = Math.max(0, Math.min(newY, windowSize.value.height - panelSize.value.height));
}
};
const handleMouseUp = (e) => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (wasDragged.value && props.snapEdge) {
snapToEdge();
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
};
//
const handleTouchStart = (event) => {
//
if (event.target === floatPanel.value || floatPanel.value.contains(event.target)) {
//
if (event.target === floatPanel.value) {
event.preventDefault();
}
wasDragged.value = false;
const touch = event.touches[0];
const startX = touch.clientX;
const startY = touch.clientY;
//
dragOffset.value = {
x: startX - panelPos.value.x,
y: startY - panelPos.value.y
};
const handleTouchMove = (e) => {
e.preventDefault();
wasDragged.value = true;
const touch = e.touches[0];
const newX = touch.clientX - dragOffset.value.x;
const newY = touch.clientY - dragOffset.value.y;
//
if (!props.fixedX) {
panelPos.value.x = Math.max(0, Math.min(newX, windowSize.value.width - panelSize.value.width));
}
if (!props.fixedY) {
panelPos.value.y = Math.max(0, Math.min(newY, windowSize.value.height - panelSize.value.height));
}
};
const handleTouchEnd = (e) => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
if (wasDragged.value && props.snapEdge) {
snapToEdge();
}
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
}
};
//
const snapToEdge = () => {
const centerX = windowSize.value.width / 2;
const panelCenterX = panelPos.value.x + panelSize.value.width / 2;
if (panelCenterX < centerX) {
panelPos.value.x = 0; //
console.log('吸附到左边边缘');
} else {
panelPos.value.x = windowSize.value.width - panelSize.value.width; //
console.log('吸附到右边边缘');
}
};
//
const handleResize = () => {
const oldWidth = windowSize.value.width;
const oldHeight = windowSize.value.height;
updateSizes();
//
if (props.position.right !== undefined) {
const rightDistance = oldWidth - (panelPos.value.x + panelSize.value.width);
panelPos.value.x = windowSize.value.width - rightDistance - panelSize.value.width;
}
if (props.position.bottom !== undefined) {
const bottomDistance = oldHeight - (panelPos.value.y + panelSize.value.height);
panelPos.value.y = windowSize.value.height - bottomDistance - panelSize.value.height;
}
//
panelPos.value.x = Math.max(0, Math.min(panelPos.value.x, windowSize.value.width - panelSize.value.width));
panelPos.value.y = Math.max(0, Math.min(panelPos.value.y, windowSize.value.height - panelSize.value.height));
};
onMounted(() => {
console.log('组件挂载, snapEdge =', props.snapEdge);
updateSizes();
initPosition();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
//
defineExpose({
snapToEdge
});
</script>
<style scoped>
.float-panel {
position: fixed;
z-index: 1000;
cursor: move;
user-select: none;
touch-action: none;
transition: left 0.2s ease-out;
}
</style>

View File

@ -0,0 +1,100 @@
/**
* 浮动气泡窗口管理模块
* 提供创建和销毁浮动气泡窗口的功能
*/
import { createApp } from 'vue'
import MinWindow from '@/components/floatingBubble/index.vue'
import { createI18n } from 'vue-i18n'
// 全局单例状态管理
let minWindowInstance = null // 组件实例引用
let minWindowApp = null // Vue应用实例
let container = null // DOM容器元素
/**
* 创建并显示浮动气泡窗口
* @param {Object} props - 传递给浮动气泡组件的属性
* @returns {Object|null} 返回组件实例或null服务端渲染时
*/
export const showMinWindow1 = (props = {}) => {
// 服务端渲染时直接返回
if (!process.client) return null
// 如果实例已存在,避免重复创建
if (minWindowInstance) {
return minWindowInstance
}
try {
// 清理可能存在的残留容器
const existingContainer = document.querySelector('.floating-bubble-container')
if (existingContainer) {
document.body.removeChild(existingContainer)
}
// 创建新的容器元素
container = document.createElement('div')
container.className = 'floating-bubble-container'
document.body.appendChild(container)
// 创建Vue应用实例
const app = createApp(MinWindow, props)
// 获取当前 Nuxt 应用的 i18n 配置
const nuxtApp = window?.__nuxt
const i18nConfig = nuxtApp?.$i18n?.options || {
legacy: false,
locale: 'en',
messages: {}
}
// 为独立组件创建 i18n 实例
const i18n = createI18n(i18nConfig)
// 安装 i18n
app.use(i18n)
minWindowApp = app
minWindowInstance = app.mount(container)
return minWindowInstance
} catch (error) {
// 发生错误时确保清理资源
hideMinWindow1()
return null
}
}
/**
* 销毁浮动气泡窗口
* 清理所有相关资源和DOM元素
*/
export const hideMinWindow1 = () => {
if (!minWindowApp && !container) return
try {
// 卸载Vue应用
if (minWindowApp) {
minWindowApp.unmount()
}
// 移除DOM容器
if (container && document.body.contains(container)) {
document.body.removeChild(container)
}
// 清理可能残留的其他容器
const existingContainer = document.querySelector('.floating-bubble-container')
if (existingContainer) {
document.body.removeChild(existingContainer)
}
} catch (error) {
} finally {
// 重置所有状态
minWindowApp = null
minWindowInstance = null
container = null
}
}

View File

@ -0,0 +1,55 @@
<!--
浮动气泡组件
提供一个可拖拽的浮动按钮支持自定义点击事件和自动销毁功能
-->
<script setup>
import { watch, onUnmounted } from 'vue'
import { hideMinWindow1 } from './floating'
// nuxt/app useNuxtApp
const { $i18n } = useNuxtApp()
// useI18n
const t = (key) => $i18n.t(key)
const props = defineProps({
/** 点击气泡时的回调函数 */
onClick: {
type: Function,
default: () => {}
},
/** 气泡文本内容(可选,默认使用国际化文本) */
text: {
type: String,
default: null
}
})
const route = useRoute()
//
//
onUnmounted(() => {
hideMinWindow1()
})
</script>
<template>
<van-floating-bubble
axis="xy"
magnetic="x"
:offset="{ x: 300, y: 50 }"
@click="onClick"
>
{{ text || t('floatingBubble.backToLive') }}
</van-floating-bubble>
</template>
<style>
.van-floating-bubble {
width: 70px;
height: 70px;
border-radius: 5px!important;
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<div>
<img v-if="active" src="./images/home1.png" alt="" class="w-[18px] h-[20px]">
<img v-else src="./images/home2.png" alt="" class="w-[18px] h-[20px]">
</div>
</template>
<script setup>
defineProps({
active: {
type: Boolean,
default: false
}
})
</script>

View File

@ -0,0 +1,15 @@
<template>
<div>
<img v-if="active" src="./images/my1.png" alt="" class="w-[18px] h-[20px]">
<img v-else src="./images/my2.png" alt="" class="w-[18px] h-[20px]">
</div>
</template>
<script setup>
defineProps({
active: {
type: Boolean,
default: false
}
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,147 @@
<script setup>
import { showImagePreview } from 'vant';
import {authStore} from "@/stores/auth/index.js";
const {userInfo}= authStore()
import xImage from '@/components/x-image/index.vue'
const props = defineProps({
detailInfo: {
type: Object,
default: null
}
})
const filteredPriceRules = computed(() => {
if (!props.detailInfo?.priceRules) return []
// price
const emptyIndex = props.detailInfo.priceRules.findIndex(item => !item.price)
if (emptyIndex === -1) {
//
return props.detailInfo.priceRules
} else {
//
return props.detailInfo.priceRules.slice(0, emptyIndex)
}
})
const position = ref({x: window?.innerWidth - 200 || 0, y: 240})
const startPosition = ref({x: 0, y: 0})
const isDragging = ref(false)
const startDrag = (e) => {
isDragging.value = true
const clientX = e.touches ? e.touches[0].clientX : e.clientX
const clientY = e.touches ? e.touches[0].clientY : e.clientY
startPosition.value = {
x: clientX - position.value.x,
y: clientY - position.value.y
}
}
const onDrag = (e) => {
if (isDragging.value) {
const clientX = e.touches ? e.touches[0].clientX : e.clientX
const clientY = e.touches ? e.touches[0].clientY : e.clientY
const maxX = window.innerWidth - 108
const maxY = window.innerHeight - 137
const x = Math.min(Math.max(0, clientX - startPosition.value.x), maxX)
const y = Math.min(Math.max(0, clientY - startPosition.value.y), maxY)
position.value = {x, y}
}
}
const stopDrag = () => {
isDragging.value = false
}
onMounted(() => {
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag)
document.addEventListener('touchend', stopDrag)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
})
</script>
<template>
<div class="relative">
<div class="flex justify-center">
<xImage class="h-188px" :src="detailInfo?.artwork?.hdPic"></xImage>
</div>
<div class="px-[16px] bg-[#fff] pt-[11px] mb-6px">
<div class="text-[#000] text-[16px] mb-[12px]">{{detailInfo?.artworkTitle}}</div>
<div class="text-#575757 text-[14px] pb-8px">
<div class="flex mb-[4px]">
<div class="w-[70px]">{{$t('detail.text1')}}</div>
<div>{{detailInfo?.artwork?.artistName??'-'}}</div>
</div>
<div class="flex mb-[4px]">
<div class="w-[70px] flex-shrink-0">{{$t('detail.text2')}}</div>
<div>{{detailInfo?.artwork?.ruler??'-'}}</div>
</div>
<div class="flex mb-[4px]">
<div class="w-[70px] flex-shrink-0">{{$t('detail.text3')}}*{{$t('detail.text4')}}</div>
<div>{{detailInfo?.artwork?.length}}*{{detailInfo?.artwork?.width}}cm</div>
</div>
<div class="flex mb-[4px]">
<div class="w-[70px] flex-shrink-0">{{$t('detail.text5')}}</div>
<div>{{detailInfo?.artwork?.abstract??'-'}}</div>
</div>
</div>
</div>
<div class="flex px-[16px] bg-#fff h-[36px] items-center mb-6px">
<div class="text-[#575757] text-[14px]">{{$t('detail.text6')}}</div>
<div class="text-#575757 text-14px font-bold">{{detailInfo?.startPriceCurrency}} {{detailInfo?.startPrice}}</div>
</div>
<div class="flex px-[16px] bg-#fff h-[36px] items-center mb-6px" v-if="detailInfo?.soldPrice">
<div class="text-[#B58047] text-[14px]">{{$t('home.close_price')}}</div>
<div class="text-#B58047 text-14px font-bold">{{detailInfo?.soldPriceCurrency}} {{detailInfo?.soldPrice}}</div>
</div>
<div class="px-[16px] bg-#fff pt-12px pb-18px">
<div class="text-[#575757] text-[14px] mb-4px">{{$t('detail.text7')}}</div>
<div v-if="detailInfo?.priceRuleType!=='diy'">
<xImage :src="detailInfo?.priceRuleImage+'?x-oss-process=image/resize,w_580,h_580/format,webp/quality,q_80'" alt=""/>
</div>
<div v-else class="mt-20px">
<div class="flex text-#575757 text-12px">
<div class="grow-1 text-center">{{ $t('detail.text11') }}</div>
<div class="grow-1 text-center">{{ $t('detail.text10') }}</div>
</div>
<div v-for="(item,index) of filteredPriceRules" :key="item.index" class="flex text-#575757 text-12px mt-10px">
<div class="grow-1 text-center">{{item.index}}</div>
<div class="grow-1 text-center">{{item.price}}</div>
</div>
</div>
</div>
<div class="flex text-[#575757] text-[14px] justify-center">
<div>{{$t('detail.text9')}}:</div>
<div>{{ detailInfo?.priceRuleAdd }}</div>
</div>
<div
v-if="userInfo.ID==detailInfo?.userId"
class="w-108px h-137px absolute cursor-move"
:style="{
left: position.x + 'px',
top: position.y + 'px'
}"
@mousedown="startDrag"
@touchstart.prevent="startDrag"
>
<img src="@/static/images/zd5530@2x.png" class="w-full h-full" alt="">
<div
class="flex flex-col items-center absolute bottom-25px text-14px text-#B58047 left-1/2 transform translate-x--1/2 whitespace-nowrap shrink-0">
<div>{{ $t('art_detail_page.prompt_title')}}</div>
<div>{{ $t('art_detail_page.prompt_desc')}}</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,106 @@
<template>
<div class="custom-loading-container" v-if="loading">
<div class="loadingio-spinner-pulse-2by998twmg8">
<div class="ldio-yzaezf3dcmj">
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
loading: {
type: Boolean,
default: false
}
})
</script>
<style scoped>
.custom-loading-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
}
.loading-text {
color: #fff;
margin-top: 20px;
font-size: 14px;
}
/* loading 动画样式 */
@keyframes ldio-yzaezf3dcmj-1 {
0% { top: 36px; height: 128px }
50% { top: 60px; height: 80px }
100% { top: 60px; height: 80px }
}
@keyframes ldio-yzaezf3dcmj-2 {
0% { top: 41.99999999999999px; height: 116.00000000000001px }
50% { top: 60px; height: 80px }
100% { top: 60px; height: 80px }
}
@keyframes ldio-yzaezf3dcmj-3 {
0% { top: 48px; height: 104px }
50% { top: 60px; height: 80px }
100% { top: 60px; height: 80px }
}
.ldio-yzaezf3dcmj div {
position: absolute;
width: 30px;
}
.ldio-yzaezf3dcmj div:nth-child(1) {
left: 35px;
background: #e15b64;
animation: ldio-yzaezf3dcmj-1 1s cubic-bezier(0,0.5,0.5,1) infinite;
animation-delay: -0.2s
}
.ldio-yzaezf3dcmj div:nth-child(2) {
left: 85px;
background: #f8b26a;
animation: ldio-yzaezf3dcmj-2 1s cubic-bezier(0,0.5,0.5,1) infinite;
animation-delay: -0.1s
}
.ldio-yzaezf3dcmj div:nth-child(3) {
left: 135px;
background: #abbd81;
animation: ldio-yzaezf3dcmj-3 1s cubic-bezier(0,0.5,0.5,1) infinite;
}
.loadingio-spinner-pulse-2by998twmg8 {
width: 200px;
height: 200px;
display: inline-block;
overflow: hidden;
background: none;
}
.ldio-yzaezf3dcmj {
width: 100%;
height: 100%;
position: relative;
transform: translateZ(0) scale(1);
backface-visibility: hidden;
transform-origin: 0 0;
}
.ldio-yzaezf3dcmj div {
box-sizing: content-box;
}
</style>

View File

@ -0,0 +1,55 @@
import { createApp } from 'vue'
import MinWindow from '@/components/liveMinWindow/index.vue'
let minWindowInstance = null
// 创建悬浮窗
export const showMinWindow = (snapshot, props = {}) => {
if (minWindowInstance) {
hideMinWindow()
}
const container = document.createElement('div')
container.id = 'live-min-window-container'
document.body.appendChild(container)
const defaultProps = {
snapshot,
onClose: () => hideMinWindow(),
initialPosition: {
top: '80px',
right: '16px'
}
}
const app = createApp(MinWindow, {
...defaultProps,
...props
})
app.config.errorHandler = (err) => {
hideMinWindow()
}
minWindowInstance = app.mount(container)
return minWindowInstance
}
export const hideMinWindow = () => {
if (!minWindowInstance) return
const el = minWindowInstance.$el
el.style.transform = 'translateY(100%)'
el.style.opacity = '0'
const cleanup = () => {
el.parentNode?.remove()
minWindowInstance = null
}
if (document.startViewTransition) {
document.startViewTransition(() => cleanup())
} else {
setTimeout(cleanup, 300)
}
}

View File

@ -0,0 +1,183 @@
<template>
<div ref="dragRef"
:style="style"
class="fixed rounded-5px overflow-hidden shadow-lg cursor-move z-50"
@mousedown.stop="handleDragStart"
@touchstart.stop="handleDragStart">
<div class="relative" @click.stop="handleClick">
<img :src="props.snapshot"
class="w-80px object-cover"
alt="直播画面">
<div class="absolute inset-0 bg-black/40 flex items-center justify-center"
>
<span class="text-white text-12px">点击回到直播</span>
</div>
<button @click.stop="handleClose"
class="absolute top-10px right-10px text-white bg-black/40 rounded-full w-5 h-5 flex items-center justify-center hover:bg-black/60 cursor-pointer">
<van-icon name="cross" />
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onBeforeUnmount, shallowRef, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useThrottleFn } from '@vueuse/core'
const props = defineProps({
snapshot: {
type: String,
required: true
},
onClose: {
type: Function,
default: () => {}
},
onClick: {
type: Function,
default: () => {}
},
initialPosition: {
type: Object,
default: () => ({
top: '80px',
right: '16px'
})
}
})
const router = useRouter()
const dragRef = shallowRef(null)
const route = useRoute()
const isDragging = ref(false)
const startX = ref(0)
const startY = ref(0)
const left = ref(0)
const top = ref(0)
onMounted(() => {
const rect = dragRef.value.getBoundingClientRect()
left.value = window.innerWidth - rect.width - parseInt(props.initialPosition.right)
top.value = parseInt(props.initialPosition.top)
})
const style = computed(() => ({
left: left.value ? `${left.value}px` : 'auto',
top: top.value ? `${top.value}px` : 'auto',
right: !left.value ? props.initialPosition.right : 'auto',
transition: isDragging.value ? 'none' : 'all 0.3s ease',
}))
const handleDragStart = (event) => {
event.stopPropagation()
isDragging.value = true
const point = event.touches ? event.touches[0] : event
const rect = dragRef.value.getBoundingClientRect()
left.value = rect.left
top.value = rect.top
startX.value = point.clientX - left.value
startY.value = point.clientY - top.value
if (event.type === 'mousedown') {
document.addEventListener('mousemove', handleDragMove)
document.addEventListener('mouseup', handleDragEnd)
} else {
document.addEventListener('touchmove', handleDragMove, { passive: false })
document.addEventListener('touchend', handleDragEnd)
}
}
const handleDragMove = useThrottleFn((event) => {
if (!isDragging.value) return
event.preventDefault()
event.stopPropagation()
const point = event.touches ? event.touches[0] : event
const rect = dragRef.value.getBoundingClientRect()
const maxX = window.innerWidth - rect.width
const maxY = window.innerHeight - rect.height
left.value = Math.min(Math.max(0, point.clientX - startX.value), maxX)
top.value = Math.min(Math.max(0, point.clientY - startY.value), maxY)
}, 16)
const handleEdgeSnap = useThrottleFn(() => {
const rect = dragRef.value.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
left.value = centerX > window.innerWidth / 2
? window.innerWidth - rect.width - 16
: 16
}, 100)
const handleDragEnd = () => {
if (!isDragging.value) return
isDragging.value = false
handleEdgeSnap()
document.removeEventListener('mousemove', handleDragMove)
document.removeEventListener('mouseup', handleDragEnd)
document.removeEventListener('touchmove', handleDragMove)
document.removeEventListener('touchend', handleDragEnd)
}
const handleClick = (event) => {
event.stopPropagation()
if (!isDragging.value) {
handleReturnLive()
}
}
const handleReturnLive = () => {
props.onClick()
}
const handleClose = (event) => {
event?.stopPropagation()
props.onClose()
}
watch(() => route.path, (newVal) => {
if (['/','/home'].includes(newVal)){
handleClose()
}
})
onBeforeUnmount(() => {
document.removeEventListener('mousemove', handleDragMove)
document.removeEventListener('mouseup', handleDragEnd)
document.removeEventListener('touchmove', handleDragMove)
document.removeEventListener('touchend', handleDragEnd)
})
</script>
<style scoped>
.fixed {
will-change: transform, opacity;
touch-action: none;
-webkit-user-select: none;
user-select: none;
transform: translateZ(0);
}
.min-window-enter-active,
.min-window-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.min-window-enter-from,
.min-window-leave-to {
transform: translateY(100%);
opacity: 0;
}
img {
pointer-events: none;
-webkit-user-drag: none;
user-drag: none;
}
</style>

View File

@ -0,0 +1,264 @@
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, watch } from 'vue'
//i18n
import { useI18n } from 'vue-i18n'
const {t} =useI18n()
const props = defineProps({
options:Object,
loading: Boolean,
})
const emit = defineEmits(['leave'])
const moveX = ref(0)
const loaded = ref(false)
const isDragging = ref(false)
const isVerifying = ref(false)
const maxMoveX = ref(0)
const bgImage = ref(null)
const verifyStatus = reactive({
show: false,
type: '',
message: ''
})
const dragState = reactive({
startX: 0,
oldMoveX: 0
})
const onImageLoad = () => {
if (!bgImage.value?.complete) return
const img = bgImage.value
const scale = img.width / img.naturalWidth
const blockSize = Math.round(50 * scale)
maxMoveX.value = img.width - blockSize
loaded.value = true
}
watch(()=>{
return props.loading
},(newVal)=>{
if(!newVal){
verifyStatus.show = false
verifyStatus.message = ''
moveX.value = 0
}
})
const onImageError = () => {
console.error('Image failed to load')
maxMoveX.value = 270
loaded.value = true
}
//
const startDrag = (e) => {
isDragging.value = true
dragState.startX = e.touches?.[0].clientX ?? e.clientX
dragState.oldMoveX = moveX.value
}
const onDrag = (e) => {
if (!isDragging.value) return
const clientX = e.touches?.[0].clientX ?? e.clientX
let newMoveX = dragState.oldMoveX + (clientX - dragState.startX)
moveX.value = Math.max(0, Math.min(newMoveX, maxMoveX.value))
}
const endDrag = async () => {
if (!isDragging.value) return
isDragging.value = false
isVerifying.value = true
try {
emit('leave', moveX.value, (success) => {
showVerifyResult(success)
})
} catch (error) {
showVerifyResult(false)
}
}
//
const showVerifyResult = (success) => {
verifyStatus.show = true
verifyStatus.type = success ? 'success' : 'error'
verifyStatus.message = success ? t('components.form.verifySuccess') : t('components.form.verifyFailed')
isVerifying.value = false
}
//
onMounted(() => {
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', endDrag)
window.addEventListener('touchmove', onDrag)
window.addEventListener('touchend', endDrag)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', endDrag)
window.removeEventListener('touchmove', onDrag)
window.removeEventListener('touchend', endDrag)
})
</script>
<template>
<div class="m-auto bg-white p-15px rd-10px touch-none select-none">
<div class="relative w-full overflow-hidden bg-#f8f8f8 rd-10px" :style="{ width: `${options?.canvasWidth}px`, height: `${options?.canvasHeight}px` }">
<!-- 加载状态 -->
<div v-if="loading" class="absolute inset-0 flex flex-col items-center justify-center bg-#f8f8f8">
<div class="fancy-loader">
<div class="fancy-loader-bar"></div>
<div class="fancy-loader-bar"></div>
<div class="fancy-loader-bar"></div>
<div class="fancy-loader-bar"></div>
</div>
</div>
<!-- 背景图 -->
<img
v-else
:src="options?.canvasSrc"
class="pointer-events-none w-full h-full"
ref="bgImage"
@load="onImageLoad"
@error="onImageError"
>
<!-- 滑块 -->
<img
:src="options?.blockSrc"
class="absolute cursor-pointer will-change-transform transform-gpu"
v-if="!loading"
:class="{ 'transition-all duration-300 ease-out': !isDragging }"
:style="{
top: `${options?.blockY}px`,
left: `${moveX}px`,
visibility: loaded ? 'visible' : 'hidden',
width: `${options?.blockWidth}px`, height: `${options?.blockHeight}px`
}"
>
<transition name="fade-slide">
<div
v-if="verifyStatus.show || isVerifying"
:class="`absolute left-0 bottom-0 w-full h-25px leading-25px text-center text-14px text-white ${
isVerifying ? 'bg-#3B91FF' :
verifyStatus.type === 'success' ? 'bg-#52c41a' : 'bg-#ff4d4f'
}`"
>
{{ isVerifying ? '验证中...' : verifyStatus.message }}
</div>
</transition>
</div>
<!-- 滑动条 -->
<div class="relative mt-15px h-40px">
<div class="relative h-40px bg-#f5f5f5 rd-20px">
<div
class="absolute h-full bg-#91d5ff rd-20px"
:class="{ 'transition-all duration-300 ease-out': !isDragging }"
:style="{ width: `${moveX}px` }"
></div>
<div
class="absolute top-0 w-40px h-40px bg-white rd-full shadow-[0_2px_6px_rgba(0,0,0,0.15)] cursor-pointer will-change-transform"
:class="{ 'transition-all duration-300 ease-out': !isDragging }"
:style="{ left: `${moveX}px` }"
@mousedown.prevent="startDrag"
@touchstart.prevent="startDrag"
>
<div
class="absolute top-50% left-50% translate--50% w-20px h-20px bg-#1890ff rd-full"
:class="{ 'animate-loading': isVerifying }"
></div>
</div>
</div>
</div>
</div>
</template>
<style>
@keyframes loading {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
.animate-loading {
animation: loading 1s linear infinite;
}
/* 添加过渡动画样式 */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from,
.fade-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
/* 加载动画样式 */
.fancy-loader {
display: flex;
align-items: flex-end;
justify-content: center;
width: 60px;
height: 40px;
}
.fancy-loader-bar {
width: 6px;
height: 15px;
margin: 0 3px;
background-color: #1890ff;
border-radius: 3px;
animation: fancy-loading 1s ease-in-out infinite;
}
.fancy-loader-bar:nth-child(1) {
animation-delay: 0s;
}
.fancy-loader-bar:nth-child(2) {
animation-delay: 0.2s;
}
.fancy-loader-bar:nth-child(3) {
animation-delay: 0.4s;
}
.fancy-loader-bar:nth-child(4) {
animation-delay: 0.6s;
}
@keyframes fancy-loading {
0% {
transform: scaleY(0.5);
opacity: 0.5;
}
50% {
transform: scaleY(1.2);
opacity: 1;
}
100% {
transform: scaleY(0.5);
opacity: 0.5;
}
}
/* 保留原有的spin动画用于其他地方 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@ -0,0 +1,331 @@
<script setup>
import { onMounted, ref } from 'vue'
const stripe = Stripe("pk_test_51QfbSAAB1Vm8VfJq3AWsR4k2mZjnlF7XFrmlbc6XVXrtwXquAUfwzZmOFDbxMIAwqJBgqao8KLt2wmPc4vNOCTeo00WB78KtfV")
const items = [{ id: "xl-tshirt", amount: 1000 }]
const elements = ref(null)
const paymentMessage = ref('')
const isLoading = ref(false)
const showSpinner = ref(false)
async function initialize() {
const clientSecret = 'pi_3QxII1AB1Vm8VfJq1OyR3bkz_secret_d8fgL53X6T3MQpYfi2lRH3V1F'
const appearance = {
theme: 'stripe',
}
elements.value = stripe.elements({ appearance, clientSecret })
const paymentElementOptions = {
layout: "accordion",
}
const paymentElement = elements.value.create("payment", paymentElementOptions)
paymentElement.mount("#payment-element")
}
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
const { error } = await stripe.confirmPayment({
elements: elements.value,
confirmParams: {
return_url: "http://localhost:4242/complete",
},
})
if (error.type === "card_error" || error.type === "validation_error") {
showMessage(error.message)
} else {
showMessage("An unexpected error occurred.")
}
setLoading(false)
}
function showMessage(messageText) {
paymentMessage.value = messageText
setTimeout(() => {
paymentMessage.value = ''
}, 4000)
}
function setLoading(loading) {
isLoading.value = loading
showSpinner.value = loading
}
onMounted(() => {
initialize()
})
</script>
<template>
<form id="payment-form" @submit="handleSubmit">
<div id="payment-element">
</div>
<button id="submit">
<div class="spinner" :class="{ hidden: !showSpinner }" id="spinner"></div>
<span id="button-text" :class="{ hidden: showSpinner }">Pay now</span>
</button>
<div id="payment-message" :class="{ hidden: !paymentMessage }">{{ paymentMessage }}</div>
</form>
</template>
<style scoped>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 16px;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
height: 100vh;
width: 100vw;
}
form {
align-self: center;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
padding: 40px;
margin-top: auto;
margin-bottom: auto;
}
.hidden {
display: none;
}
#payment-message {
color: rgb(105, 115, 134);
font-size: 16px;
line-height: 20px;
padding-top: 12px;
text-align: center;
}
#payment-element {
margin-bottom: 24px;
}
/* Buttons and links */
button {
background: #0055DE;
font-family: Arial, sans-serif;
color: #ffffff;
border-radius: 4px;
border: 0;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: block;
transition: all 0.2s ease;
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
width: 100%;
}
button:hover {
filter: contrast(115%);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
/* spinner/processing state, errors */
.spinner,
.spinner:before,
.spinner:after {
border-radius: 50%;
}
.spinner {
color: #ffffff;
font-size: 22px;
text-indent: -99999px;
margin: 0px auto;
position: relative;
width: 20px;
height: 20px;
box-shadow: inset 0 0 0 2px;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
.spinner:before,
.spinner:after {
position: absolute;
content: "";
}
.spinner:before {
width: 10.4px;
height: 20.4px;
background: #0055DE;
border-radius: 20.4px 0 0 20.4px;
top: -0.2px;
left: -0.2px;
-webkit-transform-origin: 10.4px 10.2px;
transform-origin: 10.4px 10.2px;
-webkit-animation: loading 2s infinite ease 1.5s;
animation: loading 2s infinite ease 1.5s;
}
.spinner:after {
width: 10.4px;
height: 10.2px;
background: #0055DE;
border-radius: 0 10.2px 10.2px 0;
top: -0.1px;
left: 10.2px;
-webkit-transform-origin: 0px 10.2px;
transform-origin: 0px 10.2px;
-webkit-animation: loading 2s infinite ease;
animation: loading 2s infinite ease;
}
/* Payment status page */
#payment-status {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
row-gap: 30px;
width: 30vw;
min-width: 500px;
min-height: 380px;
align-self: center;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
padding: 40px;
opacity: 0;
animation: fadeInAnimation 1s ease forwards;
}
#status-icon {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 40px;
border-radius: 50%;
}
h2 {
margin: 0;
color: #30313D;
text-align: center;
}
a {
text-decoration: none;
font-size: 16px;
font-weight: 600;
font-family: Arial, sans-serif;
display: block;
}
a:hover {
filter: contrast(120%);
}
#details-table {
overflow-x: auto;
width: 100%;
}
table {
width: 100%;
font-size: 14px;
border-collapse: collapse;
}
table tbody tr:first-child td {
border-top: 1px solid #E6E6E6; /* Top border */
padding-top: 10px;
}
table tbody tr:last-child td {
border-bottom: 1px solid #E6E6E6; /* Bottom border */
}
td {
padding-bottom: 10px;
}
.TableContent {
text-align: right;
color: #6D6E78;
}
.TableLabel {
font-weight: 600;
color: #30313D;
}
#view-details {
color: #0055DE;
}
#retry-button {
text-align: center;
background: #0055DE;
color: #ffffff;
border-radius: 4px;
border: 0;
padding: 12px 16px;
transition: all 0.2s ease;
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
width: 100%;
}
@-webkit-keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes fadeInAnimation {
to {
opacity: 1;
}
}
@media only screen and (max-width: 600px) {
form, #payment-status{
width: 80vw;
min-width: initial;
}
}
.hidden {
display: none;
}
form {
width:100vw;
align-self: center;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
padding: 40px;
margin-top: auto;
margin-bottom: auto;
}
/* 其他样式保持不变... */
</style>

View File

@ -0,0 +1,115 @@
<script setup>
import { onMounted, ref } from 'vue'
const stripe = Stripe("pk_test_51QfbSAAB1Vm8VfJq3AWsR4k2mZjnlF7XFrmlbc6XVXrtwXquAUfwzZmOFDbxMIAwqJBgqao8KLt2wmPc4vNOCTeo00WB78KtfV")
const statusIcon = ref('')
const statusText = ref('')
const intentId = ref('')
const intentStatus = ref('')
const viewDetailsUrl = ref('')
const iconColor = ref('')
const SuccessIcon = `<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.4695 0.232963C15.8241 0.561287 15.8454 1.1149 15.5171 1.46949L6.14206 11.5945C5.97228 11.7778 5.73221 11.8799 5.48237 11.8748C5.23253 11.8698 4.99677 11.7582 4.83452 11.5681L0.459523 6.44311C0.145767 6.07557 0.18937 5.52327 0.556912 5.20951C0.924454 4.89575 1.47676 4.93936 1.79051 5.3069L5.52658 9.68343L14.233 0.280522C14.5613 -0.0740672 15.1149 -0.0953599 15.4695 0.232963Z" fill="white"/>
</svg>`
const ErrorIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.25628 1.25628C1.59799 0.914573 2.15201 0.914573 2.49372 1.25628L8 6.76256L13.5063 1.25628C13.848 0.914573 14.402 0.914573 14.7437 1.25628C15.0854 1.59799 15.0854 2.15201 14.7437 2.49372L9.23744 8L14.7437 13.5063C15.0854 13.848 15.0854 14.402 14.7437 14.7437C14.402 15.0854 13.848 15.0854 13.5063 14.7437L8 9.23744L2.49372 14.7437C2.15201 15.0854 1.59799 15.0854 1.25628 14.7437C0.914573 14.402 0.914573 13.848 1.25628 13.5063L6.76256 8L1.25628 2.49372C0.914573 2.15201 0.914573 1.59799 1.25628 1.25628Z" fill="white"/>
</svg>`
const InfoIcon = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 1.5H4C2.61929 1.5 1.5 2.61929 1.5 4V10C1.5 11.3807 2.61929 12.5 4 12.5H10C11.3807 12.5 12.5 11.3807 12.5 10V4C12.5 2.61929 11.3807 1.5 10 1.5ZM4 0C1.79086 0 0 1.79086 0 4V10C0 12.2091 1.79086 14 4 14H10C12.2091 14 14 12.2091 14 10V4C14 1.79086 12.2091 0 10 0H4Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.25 7C5.25 6.58579 5.58579 6.25 6 6.25H7.25C7.66421 6.25 8 6.58579 8 7V10.5C8 10.9142 7.66421 11.25 7.25 11.25C6.83579 11.25 6.5 10.9142 6.5 10.5V7.75H6C5.58579 7.75 5.25 7.41421 5.25 7Z" fill="white"/>
<path d="M5.75 4C5.75 3.31075 6.31075 2.75 7 2.75C7.68925 2.75 8.25 3.31075 8.25 4C8.25 4.68925 7.68925 5.25 7 5.25C6.31075 5.25 5.75 4.68925 5.75 4Z" fill="white"/>
</svg>`
function setPaymentDetails(intent) {
let status = "Something went wrong, please try again."
let color = "#DF1B41"
let icon = ErrorIcon
if (!intent) {
setErrorState()
return
}
switch (intent.status) {
case "succeeded":
status = "Payment succeeded"
color = "#30B130"
icon = SuccessIcon
break
case "processing":
status = "Your payment is processing."
color = "#6D6E78"
icon = InfoIcon
break
case "requires_payment_method":
status = "Your payment was not successful, please try again."
break
}
iconColor.value = color
statusIcon.value = icon
statusText.value = status
intentId.value = intent.id
intentStatus.value = intent.status
viewDetailsUrl.value = `https://dashboard.stripe.com/payments/${intent.id}`
}
function setErrorState() {
iconColor.value = "#DF1B41"
statusIcon.value = ErrorIcon
statusText.value = "Something went wrong, please try again."
}
async function checkStatus() {
const urlParams = new URLSearchParams(window.location.search)
const clientSecret = urlParams.get("payment_intent_client_secret")
if (!clientSecret) {
setErrorState()
return
}
const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret)
setPaymentDetails(paymentIntent)
}
onMounted(() => {
checkStatus()
})
</script>
<template>
<div id="payment-status">
<div id="status-icon" :style="{ backgroundColor: iconColor }" v-html="statusIcon"></div>
<h2 id="status-text">{{ statusText }}</h2>
<div id="details-table">
<table>
<tbody>
<tr>
<td class="TableLabel">id</td>
<td id="intent-id" class="TableContent">{{ intentId }}</td>
</tr>
<tr>
<td class="TableLabel">status</td>
<td id="intent-status" class="TableContent">{{ intentStatus }}</td>
</tr>
</tbody>
</table>
</div>
<a :href="viewDetailsUrl" id="view-details" rel="noopener noreferrer" target="_blank">
View details
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.125 3.49998C2.64175 3.49998 2.25 3.89173 2.25 4.37498V11.375C2.25 11.8582 2.64175 12.25 3.125 12.25H10.125C10.6082 12.25 11 11.8582 11 11.375V9.62498C11 9.14173 11.3918 8.74998 11.875 8.74998C12.3582 8.74998 12.75 9.14173 12.75 9.62498V11.375C12.75 12.8247 11.5747 14 10.125 14H3.125C1.67525 14 0.5 12.8247 0.5 11.375V4.37498C0.5 2.92524 1.67525 1.74998 3.125 1.74998H4.875C5.35825 1.74998 5.75 2.14173 5.75 2.62498C5.75 3.10823 5.35825 3.49998 4.875 3.49998H3.125Z" fill="#0055DE"/>
<path d="M8.66672 0C8.18347 0 7.79172 0.391751 7.79172 0.875C7.79172 1.35825 8.18347 1.75 8.66672 1.75H11.5126L4.83967 8.42295C4.49796 8.76466 4.49796 9.31868 4.83967 9.66039C5.18138 10.0021 5.7354 10.0021 6.07711 9.66039L12.7501 2.98744V5.83333C12.7501 6.31658 13.1418 6.70833 13.6251 6.70833C14.1083 6.70833 14.5001 6.31658 14.5001 5.83333V0.875C14.5001 0.391751 14.1083 0 13.6251 0H8.66672Z" fill="#0055DE"/>
</svg>
</a>
<NuxtLink id="retry-button" to="/checkout">Test another</NuxtLink>
</div>
</template>
<style scoped>
/* 将原来 checkout.css 中的样式复制到这里 */
</style>

View File

@ -0,0 +1,72 @@
<template>
<div class="waterfall-container" ref="container">
<div
v-for="(column, columnIndex) in columns"
:key="columnIndex"
class="waterfall-column"
:style="{ width: `${100 / columnCount}%` }"
>
<div
v-for="item in column"
:key="item.id"
class="waterfall-item"
>
<slot :item="item">
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
const props = defineProps({
items: {
type: Array,
required: true
},
columnCount: {
type: Number,
default: 2
}
})
const columns = ref([])
const calculateColumns = () => {
const cols = Array.from({ length: props.columnCount }, () => [])
props.items.forEach((item, index) => {
const columnIndex = index % props.columnCount
cols[columnIndex].push(item)
})
columns.value = cols
}
watch(() => props.items, () => {
calculateColumns()
}, { deep: true })
onMounted(() => {
calculateColumns()
})
</script>
<style scoped>
.waterfall-container {
display: flex;
width: 100%;
gap: 16px;
}
.waterfall-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.waterfall-item {
break-inside: avoid;
}
</style>

View File

@ -0,0 +1,28 @@
<script setup>
import { ref } from "vue";
const isButtonActive = ref(false);
const handleButtonPress = (event) => {
event.stopPropagation();
isButtonActive.value = true;
};
const handleButtonRelease = (event) => {
event.stopPropagation();
isButtonActive.value = false;
};
</script>
<template>
<div
:class="[
'transition-all duration-200',
isButtonActive ? 'scale-95' : ''
]"
@touchstart.stop="handleButtonPress"
@touchend.stop="handleButtonRelease"
>
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,34 @@
<script setup>
import { showImagePreview } from 'vant';
const props = defineProps({
src: {
type: String,
default: ''
},
preview: {
type: Boolean,
default: true
},
})
const showImage = () => {
if (props.preview) {
showImagePreview([props.src]);
}
}
</script>
<template>
<img
loading="lazy"
v-bind="{ ...$props, ...$attrs }"
style="object-fit: cover"
@click="showImage"
:src="src"
/>
</template>
<style scoped>
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,93 @@
<script setup>
import { ref, computed } from 'vue'
import MessageContent from './message/index.vue'
const visible = ref(false)
const messageType = ref('success')
const messageText = ref('')
const showIcon = ref(true)
const customStyle = ref({})
const title = ref({})
const subTitle = ref({})
const containerStyle = computed(() => {
const { top, bottom, left, right, transform, ...otherStyles } = customStyle.value || {}
const baseStyle = {
position: 'fixed',
zIndex: 9999
}
const horizontalPosition = left || right
? { left, right }
: { left: '50%', transform: 'translateX(-50%)' }
const verticalPosition = {}
if (bottom !== undefined) {
verticalPosition.bottom = bottom
} else {
verticalPosition.top = top || '50px'
}
return {
...baseStyle,
...horizontalPosition,
...verticalPosition,
...otherStyles
}
})
const emit = defineEmits(['after-leave'])
const showMessage = (options) => {
if (typeof options === 'string') {
messageText.value = options
title.value = {}
subTitle.value = {}
} else {
messageText.value = options.message || ''
title.value = options.title || {}
subTitle.value = options.subTitle || {}
}
messageType.value = options.type || 'success'
showIcon.value = options.icon !== false
customStyle.value = options.style || {}
visible.value = true
setTimeout(() => {
visible.value = false
}, options.duration || 2000)
}
defineExpose({ showMessage })
</script>
<template>
<transition
name="fade"
@after-leave="$emit('after-leave')"
>
<MessageContent
v-if="visible"
:message="messageText"
:type="messageType"
:title="title"
:sub-title="subTitle"
:show-icon="showIcon"
:style="containerStyle"
/>
</transition>
</template>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,121 @@
<script setup>
import error from '../images/error.png'
import success from '../images/success.png'
import warning from '../images/warning.png'
import info from '../images/info.png'
const props = defineProps({
type: {
type: String,
default: 'success'
},
message: {
type: String,
default: ''
},
title: {
type: Object,
default: () => ({
text: '',
color: '',
align: 'left'
})
},
subTitle: {
type: Object,
default: () => ({
text: '',
color: '',
align: 'left'
})
},
showIcon: {
type: Boolean,
default: true
},
style: {
type: Object,
default: () => ({})
}
})
const typeConfig = {
info: {
imgSrc: info,
borderColor: '#C6DFFB',
bgColor: '#ECF5FE',
},
success: {
imgSrc: success,
borderColor: '#C5E7D5',
bgColor: '#EDF7F2',
},
error: {
imgSrc: error,
borderColor: '#FFD4D4',
bgColor: '#FFF0F0',
},
warning: {
imgSrc: warning,
borderColor: '#FFE2BA',
bgColor: '#FFF7EC',
}
}
// 使 props.style
const finalStyle = computed(() => {
return {
borderColor: props.style?.borderColor || typeConfig[props.type].borderColor,
backgroundColor: props.style?.backgroundColor || typeConfig[props.type].bgColor,
width: props.style?.width || '343px',
height: props.style?.height || 'auto',
minHeight: '46px',
...props.style
}
})
</script>
<template>
<div
:class="`box-border flex items-center border rounded-[4px] px-[15px] shadow-sm py-8px ${!message?'justify-center':''}`"
:style="finalStyle"
>
<div v-if="showIcon" class="mr-[12px]">
<img
:src="typeConfig[type].imgSrc"
class="w-20px h-20px"
style="object-fit: contain"
alt=""
>
</div>
<div class="flex flex-col justify-center">
<!-- 如果是简单文本模式 -->
<div v-if="message" class="text-[14px] line-height-none">
{{ message }}
</div>
<!-- 如果是标题+副标题模式 -->
<template v-else>
<div
v-if="title.text"
class="text-[14px] line-height-20px"
:style="{
color: title.color || 'inherit',
textAlign: title.align || 'left'
}"
>
{{ title.text }}
</div>
<div
v-if="subTitle.text"
class="text-[12px] leading-normal mt-1 line-height-17px"
:style="{
color: subTitle.color || '#939393',
textAlign: subTitle.align || 'left'
}"
>
{{ subTitle.text }}
</div>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,78 @@
import { createApp, nextTick } from 'vue'
import MessagePopup from './index.vue'
const message = {
success(options, duration = 2000) {
if (process.client) {
if (typeof options === 'string') {
this.show({ type: 'success', message: options, duration })
} else {
this.show({
type: 'success',
...options,
duration
})
}
}
},
error(options, duration = 2000) {
if (process.client) {
if (typeof options === 'string') {
this.show({ type: 'error', message: options, duration })
} else {
this.show({
type: 'error',
...options,
duration
})
}
}
},
info(options, duration = 2000) {
if (process.client) {
if (typeof options === 'string') {
this.show({ type: 'info', message: options, duration })
} else {
this.show({
type: 'error',
...options,
duration
})
}
}
},
warning(options, duration = 2000) {
if (process.client) {
if (typeof options === 'string') {
this.show({ type: 'warning', message: options, duration })
} else {
this.show({
type: 'warning',
...options,
duration
})
}
}
},
show(options) {
if (!process.client) return
const container = document.createElement('div')
document.body.appendChild(container)
const app = createApp(MessagePopup, {
onAfterLeave: () => {
app.unmount()
document.body.removeChild(container)
}
})
const instance = app.mount(container)
nextTick(() => {
instance.showMessage?.(options)
})
}
}
export { message }

View File

@ -0,0 +1,57 @@
<script setup>
/*
* 封装一个带标题栏的弹窗
* */
const props = defineProps({
show: {
type: Boolean,
default: false
},
title:''
})
const emit = defineEmits(['update:show'])
const close=()=>{
emit('update:show',false)
}
</script>
<template>
<van-popup
:show="show"
:transition-appear="true"
teleport="#__nuxt"
position="bottom"
@click-overlay="close"
:style="{ height: '74%' }"
v-bind="{...$attrs,...$props}"
:safe-area-inset-bottom="true"
>
<div class="flex flex-col h-full">
<!-- 标题栏 -->
<div class="flex items-center pl-16px pr-19px h-40px border-b-1px border-gray-300 shrink-0 relative w-full">
<slot v-if="$slots.title" name="title">
</slot>
<div v-else class="text-black text-16px text-center flex-grow-1">{{ title }}</div>
<van-icon
style="position: absolute"
class="right-19px"
size="20"
name="cross"
color="#939393"
@click="close"
/>
</div>
<!-- 内容区域 -->
<div class="flex-1 px-16px py-18px overflow-hidden relative">
<div class="h-full overflow-y-auto relative list-container">
<slot/>
</div>
</div>
</div>
</van-popup>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,114 @@
<script setup>
import {ref, computed} from 'vue'
import dayjs from 'dayjs'
const props = defineProps({
modelValue: {
type: [Date, String, Number]
},
label: {
type: String,
default: '日期'
},
required: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: '请选择日期'
},
disabled: {
type: Boolean,
default: false
},
minDate: {
type: Date,
default: () => dayjs('1900-01-01').toDate()
},
maxDate: {
type: Date,
default: () => dayjs('2100-12-31').toDate()
},
format: {
type: String,
default: 'YYYY-MM-DD'
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const show = ref(false)
const defaultValue = computed(() => {
let date
if (props.modelValue) {
date = dayjs(props.modelValue)
} else {
date = dayjs()
}
if (!date.isValid()) {
date = dayjs()
}
return [
date.year(),
date.month() + 1,
date.date()
]
})
const formatDate = (dateArr) => {
const [year, month, day] = dateArr
return dayjs(`${year}-${month}-${day}`).format(props.format)
}
const displayValue = computed(() => {
if (!props.modelValue) return ''
const date = dayjs(props.modelValue)
return date.isValid() ? date.format(props.format) : ''
})
const onConfirm = ({selectedValues}) => {
show.value = false
const formattedDate = formatDate(selectedValues)
emit('update:modelValue', formattedDate)
emit('change', formattedDate)
}
const onCancel = () => {
show.value = false
}
</script>
<template>
<div>
<van-field
:model-value="displayValue"
@click="show = true"
readonly
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:label="label"
class="mb-10px"
is-link
/>
<van-popup
v-model:show="show"
position="bottom"
teleport="#__nuxt"
destroy-on-close
safe-area-inset-bottom
>
<van-date-picker
:min-date="minDate"
:max-date="maxDate"
:model-value="defaultValue"
@confirm="onConfirm"
@cancel="onCancel"
title="选择日期"
/>
</van-popup>
</div>
</template>

View File

@ -0,0 +1,86 @@
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const {t} =useI18n()
const props = defineProps({
modelValue: {
type: [Number, String]
},
columns: {
type: Array,
default: () => []
},
label: {
type: String,
default: ''
},
required: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: () => useI18n().t('components.form.pleaseSelect')
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const show = ref(false)
const onConfirm = (value) => {
show.value = false
emit('update:modelValue', value.selectedValues?.[0])
emit('change', value.selectedValues?.[0])
}
const displayText = computed(() => {
const selected = props.columns.find(x => x.value === props.modelValue)
return selected?.text || ''
})
const openPopup=()=>{
show.value=true
}
</script>
<template>
<div>
<van-field
:model-value="displayText"
@click="openPopup"
readonly
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:label="label"
class="mb-10px"
is-link
/>
<van-popup
v-model:show="show"
destroy-on-close
position="bottom"
teleport="body"
safe-area-inset-bottom
>
<van-picker
:columns="columns"
@confirm="onConfirm"
@cancel="show = false"
:default-index="columns.findIndex(x => x.value === modelValue)"
:title="t('components.form.pleaseSelect')"
:confirm-button-text="t('components.dialog.confirm')"
:cancel-button-text="t('components.dialog.cancel')"
/>
</van-popup>
</div>
</template>

View File

@ -0,0 +1,21 @@
export function useWebSocket() {
const { $ws } = useNuxtApp()
const messages = ref<any[]>([])
// 监听消息
const onMessage = (callback: (data: any) => void) => {
const handler = (event: CustomEvent) => callback(event.detail)
window.addEventListener('ws-message', handler as EventListener)
// 返回清理函数
return () => {
window.removeEventListener('ws-message', handler as EventListener)
}
}
return {
ws: $ws,
messages,
onMessage
}
}

2
app/config/index.js Normal file
View File

@ -0,0 +1,2 @@
export const useAppFooterRouteNames = ['index', 'profile']
export const useAppHeaderRouteNames = ['index', 'profile', 'login', 'collectCode-login', 'collectCode-mine', 'payment-result', 'collectCode-signature-personal-Info', 'collectCode-signature-result','collectCode-payment-result']

0
app/config/live/index.js Normal file
View File

2
app/constants/index.js Normal file
View File

@ -0,0 +1,2 @@
export const appName = '豐和'
export const appDescription = '泰丰国际京都拍卖会'

23
app/layouts/404.vue Normal file
View File

@ -0,0 +1,23 @@
<script setup lang="ts">
const router = useRouter()
function onBack() {
if (window.history.state.back)
history.back()
else
router.replace('/')
}
</script>
<template>
<main text="center gray-300 dark:gray-200 18" py="20">
<van-icon name="warn-o" size="3em" />
<slot />
<div class="mt-10">
<button van-haptics-feedback btn m="3 t8" @click="onBack">
{{ $t('error_page.back_btn') }}
</button>
</div>
</main>
</template>

11
app/layouts/default.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<main class="flex flex-col min-h-svh">
<AppHeader class="h-[var(--van-nav-bar-height)]" />
<div class="flex-1 flex flex-col">
<slot />
</div>
<AppFooter />
</main>
</template>
<script setup >
</script>

View File

@ -0,0 +1,60 @@
<script setup>
import itemDetail from '@/components/itemDetail/index.vue'
import {userArtwork} from "~/api/goods/index.js";
import {useRouter} from "#vue-router";
import {authStore} from "~/stores/auth/index.js";
definePageMeta({
i18n: 'detail.text8'
})
const { userInfo, payment } = authStore()
const route = useRoute()
const detail = ref({})
const uuid = route.query.uuid
const initData = async () => {
const res = await userArtwork({uuid})
if (res.status === 0) {
detail.value = res.data
detail.value.auctionArtworkInfo.userId=detail.value.userID
}
}
const router = useRouter();
const goPay=()=>{
payment.value.leftPrice=detail.value.leftCnyPrice
payment.value.leftCurrency=detail.value.leftCurrency
payment.value.buyUid=detail.value.uuid
payment.value.auctionArtworkUuid=detail.value?.auctionArtworkUuid
if (detail.value.status===1){
router.push('/signature/protocol')
}else if (detail.value.status===4){
router.push('/payment')
}
//router.push('/payment')
}
function formatThousands(num) {
return Number(num).toLocaleString();
}
initData()
</script>
<template>
<div class="relative h-screen-nav flex flex-col">
<itemDetail class="grow-1" :detail-info="detail.auctionArtworkInfo"/>
<div v-if="[1,3,4].includes(detail.status)" class="h-81px bg-#fff flex justify-center pt-7px shrink-0">
<van-button class="w-213px !h-38px" type="primary" @click="goPay">
<span class="text-#fff text-14px">{{ $t('art_detail_page.button') }} {{detail.leftCurrency}}{{formatThousands(detail.leftPrice)}}</span>
</van-button>
</div>
</div>
</template>
<style scoped>
.cursor-move {
touch-action: none;
user-select: none;
}
</style>

View File

@ -0,0 +1,402 @@
<script setup>
import { onMounted, ref } from 'vue'
import {authStore} from "~/stores/auth/index.js";
import {orderQuery} from "~/api/goods/index.js";
import { WebSocketClient } from '@/utils/websocket'
const config = useRuntimeConfig()
definePageMeta({
layout: 'default',
title: 'Stripe支付'
})
const stripe = Stripe(config.public.NUXT_PUBLIC_PKEY)
const route = useRoute()
const baseURL = config.public.NUXT_PUBLIC_API_BASE
const items = [{ id: "xl-tshirt", amount: 1000 }]
const elements = ref(null)
const paymentMessage = ref('')
const isLoading = ref(false)
const showSpinner = ref(false)
let pollTimer = null
let timeoutTimer = null
const router = useRouter()
const startPolling = () => {
pollTimer = setInterval(async () => {
const res = await orderQuery({
orderNo: route.query.payUid
})
if (res.status === 0) {
if (res.data.status !== 3) {
clearInterval(pollTimer)
clearTimeout(timeoutTimer)
router.replace({
path: route.query.returnUrl,
query: {
orderNo: route.query.payUid
}
})
}
}
}, 1000)
/* timeoutTimer = setTimeout(() => {
clearInterval(pollTimer)
setLoading(false)
}, 180000)*/
}
let wsClient=null
const watchWebSocket = () => {
wsClient = new WebSocketClient(
config.public.NUXT_PUBLIC_SOCKET_URL
)
const ws = wsClient.connect('/api/v1/order/ws/v2', {
PayUid: route.query.payUid,
})
ws.onOpen(() => {
})
ws.onMessage((event) => {
router.replace({
path: route.query.returnUrl,
query: {
orderNo: route.query.payUid
}
})
})
ws.onClose(() => {
})
}
async function initialize() {
const clientSecret = route.query.stripeKey
const appearance = {
theme: 'stripe',
}
elements.value = stripe.elements({ appearance, clientSecret })
const paymentElementOptions = {
layout: "accordion",
}
const paymentElement = elements.value.create("payment", paymentElementOptions)
paymentElement.mount("#payment-element")
}
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
const { error } = await stripe.confirmPayment({
elements: elements.value,
confirmParams: {
return_url: `${baseURL}${route.query.returnUrl}?orderNo=${route.query.payUid}`,
},
})
if (error) {
/* clearInterval(pollTimer)
clearTimeout(timeoutTimer)*/
if (error.type === "card_error" || error.type === "validation_error") {
showMessage(error.message)
} else {
showMessage("An unexpected error occurred.")
}
setLoading(false)
}
}
function showMessage(messageText) {
paymentMessage.value = messageText
setTimeout(() => {
paymentMessage.value = ''
}, 4000)
}
function setLoading(loading) {
isLoading.value = loading
showSpinner.value = loading
}
onUnmounted(()=>{
wsClient.disconnect()
clearTimeout(timeoutTimer)
clearInterval(pollTimer)
})
onMounted(() => {
watchWebSocket()
initialize()
startPolling()
})
</script>
<template>
<form id="payment-form" @submit="handleSubmit">
<div id="payment-element">
</div>
<button id="submit">
<div class="spinner" :class="{ hidden: !showSpinner }" id="spinner"></div>
<span id="button-text" :class="{ hidden: showSpinner }">Pay now</span>
</button>
<div id="payment-message" :class="{ hidden: !paymentMessage }">{{ paymentMessage }}</div>
</form>
</template>
<style scoped>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 16px;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
height: 100vh;
width: 100vw;
}
form {
align-self: center;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
padding: 40px;
margin-top: auto;
margin-bottom: auto;
}
.hidden {
display: none;
}
#payment-message {
color: rgb(105, 115, 134);
font-size: 16px;
line-height: 20px;
padding-top: 12px;
text-align: center;
}
#payment-element {
margin-bottom: 24px;
}
/* Buttons and links */
button {
background: #0055DE;
font-family: Arial, sans-serif;
color: #ffffff;
border-radius: 4px;
border: 0;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: block;
transition: all 0.2s ease;
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
width: 100%;
}
button:hover {
filter: contrast(115%);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
/* spinner/processing state, errors */
.spinner,
.spinner:before,
.spinner:after {
border-radius: 50%;
}
.spinner {
color: #ffffff;
font-size: 22px;
text-indent: -99999px;
margin: 0px auto;
position: relative;
width: 20px;
height: 20px;
box-shadow: inset 0 0 0 2px;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
.spinner:before,
.spinner:after {
position: absolute;
content: "";
}
.spinner:before {
width: 10.4px;
height: 20.4px;
background: #0055DE;
border-radius: 20.4px 0 0 20.4px;
top: -0.2px;
left: -0.2px;
-webkit-transform-origin: 10.4px 10.2px;
transform-origin: 10.4px 10.2px;
-webkit-animation: loading 2s infinite ease 1.5s;
animation: loading 2s infinite ease 1.5s;
}
.spinner:after {
width: 10.4px;
height: 10.2px;
background: #0055DE;
border-radius: 0 10.2px 10.2px 0;
top: -0.1px;
left: 10.2px;
-webkit-transform-origin: 0px 10.2px;
transform-origin: 0px 10.2px;
-webkit-animation: loading 2s infinite ease;
animation: loading 2s infinite ease;
}
/* Payment status page */
#payment-status {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
row-gap: 30px;
width: 30vw;
min-width: 500px;
min-height: 380px;
align-self: center;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
padding: 40px;
opacity: 0;
animation: fadeInAnimation 1s ease forwards;
}
#status-icon {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 40px;
border-radius: 50%;
}
h2 {
margin: 0;
color: #30313D;
text-align: center;
}
a {
text-decoration: none;
font-size: 16px;
font-weight: 600;
font-family: Arial, sans-serif;
display: block;
}
a:hover {
filter: contrast(120%);
}
#details-table {
overflow-x: auto;
width: 100%;
}
table {
width: 100%;
font-size: 14px;
border-collapse: collapse;
}
table tbody tr:first-child td {
border-top: 1px solid #E6E6E6; /* Top border */
padding-top: 10px;
}
table tbody tr:last-child td {
border-bottom: 1px solid #E6E6E6; /* Bottom border */
}
td {
padding-bottom: 10px;
}
.TableContent {
text-align: right;
color: #6D6E78;
}
.TableLabel {
font-weight: 600;
color: #30313D;
}
#view-details {
color: #0055DE;
}
#retry-button {
text-align: center;
background: #0055DE;
color: #ffffff;
border-radius: 4px;
border: 0;
padding: 12px 16px;
transition: all 0.2s ease;
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
width: 100%;
}
@-webkit-keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes fadeInAnimation {
to {
opacity: 1;
}
}
@media only screen and (max-width: 600px) {
form, #payment-status{
width: 80vw;
min-width: initial;
}
}
.hidden {
display: none;
}
form {
width:100vw;
align-self: center;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
padding: 40px;
margin-top: auto;
margin-bottom: auto;
}
/* 其他样式保持不变... */
</style>

View File

@ -0,0 +1,278 @@
<script setup>
import {useRouter, useRoute} from 'vue-router';
import {useI18n} from 'vue-i18n'
import {senCode, userLogin} from "@/api/auth/index.js";
import {codeAuthStore} from "@/stores-collect-code/auth/index.js";
import {message} from '@/components/x-message/useMessage.js'
// ... ...
import FingerprintJS from '@fingerprintjs/fingerprintjs'
import {checkPhone, mobileLogin, userSend} from "@/api-collect-code/auth/index.js";
import countryCode from '@/pages/countryRegion/data/index.js'
const {userInfo, codeToken, fingerprint} = codeAuthStore()
const router = useRouter();
const route = useRoute();
const {locale} = useI18n()
const loadingRef = ref({
loading1: false,
loading2: false,
})
const password = ref('')
const loginType = ref(0)
const interval = ref(null)
const startCountdown = () => {
if (interval.value) {
clearInterval(interval.value);
}
countdown.value = 60;
interval.value = setInterval(() => {
if (countdown.value > 0) {
countdown.value--;
} else {
clearInterval(interval.value);
}
}, 1000);
}
const countdown = ref(0);
const phoneNum = ref('')
const code = ref('')
const pane = ref(0)
const getFingerprint = async () => {
const fp = await FingerprintJS.load()
const result = await fp.get()
return result.visitorId //
}
//
const checkFingerprint = async () => {
const tempFingerprint = await getFingerprint()
if (fingerprint && fingerprint === tempFingerprint) {
await router.push('/collectCode/mine')
}
}
const codeInput = ref(null)
const isFocused = ref(false)
checkFingerprint()
const vanSwipeRef = ref(null)
const getCode = async () => {
loadingRef.value.loading1 = true
try {
const res = await checkPhone({
tel: phoneNum.value,
})
if (res.status === 0) {
const sendRes = await userSend({telNum: phoneNum.value, zone: '+86'})
startCountdown()
pane.value = 1
await nextTick()
vanSwipeRef.value?.swipeTo(pane.value)
}
} catch (error) {
console.error('获取验证码失败:', error)
} finally {
loadingRef.value.loading1 = false
}
}
const changeToPwd = async () => {
loginType.value = loginType.value === 0 ? 1 : 0
}
const goBack = () => {
code.value = ''
pane.value = 0
vanSwipeRef.value?.swipeTo(pane.value)
}
const goLogin = async () => {
loadingRef.value.loading2 = true
const res = await mobileLogin({
TelNum: phoneNum.value,
Password: loginType.value === 1 ? password.value : '',
Code: loginType.value === 0 ? code.value : ''
})
if (res.status === 0) {
userInfo.value = res.data.accountInfo
codeToken.value = res.data.token
fingerprint.value = await getFingerprint()
await router.push('/collectCode/mine');
}
loadingRef.value.loading2 = false
}
const showPassword = ref(false)
const togglePasswordVisibility = () => {
showPassword.value = !showPassword.value
}
const getDefaultCountry = () => {
let defaultCode = 'CN' //
switch (locale.value) {
case 'zh-CN':
defaultCode = 'CN'
break
case 'zh-TW':
defaultCode = 'TW'
break
case 'ja-JP':
defaultCode = 'JP'
break
case 'en-US':
defaultCode = 'US'
break
}
const country = countryCode.find(c => c.code === defaultCode)
return {
zone: country.zone,
name: locale.value === 'zh-CN' ? country.cn :
locale.value === 'zh-TW' ? country.tw :
locale.value === 'ja-JP' ? country.ja :
country.en
}
}
const defaultCountry = getDefaultCountry()
const selectedCountry = ref('')
// onMounted(()=>{
// selectedZone.value=route.query.zone || defaultCountry.zone
// selectedCountry.value=route.query.countryName || defaultCountry.name
// })
</script>
<template>
<div class="grow-1 w-[100vw] bg-[url('@/static/images/asdfsdd.png')] bg-bottom bg-cover px-[31px] pt-[86px]">
<div class="w-full flex justify-center mb-[100px] flex-col items-center">
<img class="h-[105px] w-[189px]" src="@/static/images/ghfggff.png" alt="">
<img class="h-[29px] w-[108px]" src="@/static/images/qrcodetext.png" alt="">
</div>
<van-swipe ref="vanSwipeRef" :show-indicators="false" :touchable="false" :lazy-render="true" :loop="false">
<van-swipe-item>
<div v-if="pane === 0">
<div>
<div class="">
<div class="border-b-[1.7px] mt-[8px]">
<van-field v-model="phoneNum" clearable :placeholder="$t('collectCode.login.phoneNumberPlaceholder')">
<template #label>
<div class="text-[16px] text-[#1A1A1A] flex align-center justify-start">
{{ $t('collectCode.login.phoneNumber') }}
</div>
</template>
</van-field>
</div>
<div class="border-b-[1.7px] mt-[8px]" v-if="loginType === 1">
<van-field
v-model="password"
:type="showPassword ? 'text' : 'password'"
clearable
:placeholder="$t('collectCode.login.passwordPlaceholder')"
>
<template #label>
<div class="text-[16px] text-[#1A1A1A] flex align-center justify-start">
{{ $t('collectCode.login.password') }}
</div>
</template>
<template #button>
<div class="flex justify-center items-center">
<van-icon
size="20"
:name="showPassword ? 'eye-o' : 'closed-eye'"
@click="togglePasswordVisibility"
/>
</div>
</template>
</van-field>
</div>
<div class="flex justify-end mt-[10px]">
<div class="text-[14px] text-[#2B53AC]" @click="changeToPwd">
{{ loginType === 0 ? $t('collectCode.login.passwordLogin') : $t('collectCode.login.codeLogin') }}
</div>
</div>
</div>
<div class="mt-[55px]">
<div v-if="loginType === 0">
<van-button :loading="loadingRef.loading1" v-if="phoneNum" :loading-text="$t('collectCode.login.getCode')"
type="primary" block style="height: 48px" @click="getCode">{{
$t('collectCode.login.getCode')
}}
</van-button>
<van-button v-else type="primary" color="#D3D3D3" block style="height: 48px">
{{ $t('collectCode.login.getCode') }}
</van-button>
</div>
<div v-else>
<van-button type="primary" v-if="password" block :loading="loadingRef.loading2"
:loading-text="$t('collectCode.login.login')"
style="height: 48px;margin-top:10px" @click="goLogin">{{ $t('collectCode.login.login') }}
</van-button>
<van-button v-else type="primary" color="#D3D3D3" block style="height: 48px">
{{ $t('collectCode.login.login') }}
</van-button>
</div>
</div>
</div>
</div>
</van-swipe-item>
<van-swipe-item>
<div v-if="pane == 1">
<div>
<div class="flex mb-[16px]">
<div class="text-[16px] text-[#BDBDBD] mr-[10px]">{{ $t('collectCode.login.hasSendTo') }}</div>
<div class="text-[16px] text-[#000]">+86 {{ phoneNum }}</div>
</div>
<div class="relative">
<van-password-input
:value="code"
:gutter="10"
:mask="false"
:focused="isFocused"
/>
<input
v-model="code"
type="tel"
maxlength="6"
ref="codeInput"
class="opacity-0 absolute top-0 left-0 h-full w-full z-999"
@input="code = $event.target.value.replace(/\D/g, '').slice(0, 6)"
@focus="isFocused = true"
@blur="isFocused = false"
/>
</div>
<div class="flex justify-between">
<div :class="`${countdown>0?'text-#BDBDBD':'text-#2B53AC'} text-14px`">
{{ $t('collectCode.login.reSend') }}<span v-if="countdown>0">({{ countdown }})</span>
</div>
<div @click="goBack" class="text-#2B53AC text-14px">
{{ $t('collectCode.login.back') }}
</div>
</div>
<div class="mt-[17px]">
<van-button v-if="code.length === 6" type="primary" block :loading="loadingRef.loading2"
:loading-text="$t('collectCode.login.login')" style="height: 48px" @click="goLogin">
{{ $t('collectCode.login.login') }}
</van-button>
<van-button v-else type="primary" color="#D3D3D3" block style="height: 48px">
{{ $t('collectCode.login.login') }}
</van-button>
</div>
</div>
</div>
</van-swipe-item>
</van-swipe>
</div>
</template>
<style scoped lang="scss">
:deep(.van-cell.van-field) {
padding-left: 0;
}
:deep(.van-password-input) {
margin: 0;
}
:deep(.van-password-input__item) {
border: 1px solid #E5E5E5;
width: 41px;
height: 41px;
}
</style>

View File

@ -0,0 +1,103 @@
<script setup>
import XImage from "@/components/x-image/index.vue";
import {useRuntimeConfig} from "#app";
import QRCode from 'qrcode'
import { showImagePreview } from 'vant';
import { useI18n } from 'vue-i18n';
const t = useI18n().t;
const statusLabel=[
{label: t('collectCode.qrcode.status.paid'), value:2, color:'#18A058'},
{label: t('collectCode.qrcode.status.unpaid'), value:1, color:'#CF3050'},
{label: t('collectCode.qrcode.status.partialPaid'), value:4, color:'#F09F1F'}
]
const props = defineProps({
data: {
type: Object,
default: () => {
return {};
},
},
});
const itemLabel=(data)=>{
return statusLabel.find(x=>x.value===data.payStatus)
}
const getQRBase64 = async () => {
const url=`${window.location.origin}/collectCode/signature/personal-Info?number=2&qrUid=${props.data.qrUid}`
try {
return await QRCode.toDataURL(url, {
width: 200,
margin: 4,
errorCorrectionLevel: 'H'
})
} catch (err) {
return null
}
}
const QRUrl=ref('')
const show=ref(false)
const openQrCode=async ()=>{
const base64=await getQRBase64()
QRUrl.value=base64
show.value=true
}
/**
* 将数字格式化为"250XX"格式其中XX是两位数
* @param {number} num - 要格式化的数字
* @return {string} - 格式化后的字符串
*/
function formatNumber(num) {
//
if (typeof num !== 'number' && isNaN(Number(num))) {
throw new Error('输入必须是有效数字');
}
// ()
const number = Number(num);
// 0
const formattedNum = number.toString().padStart(2, '0');
// "250"
return `250${formattedNum}`;
}
</script>
<template>
<div class="flex flex-col h-120px bg-#F7F7F7 rounded-4px px-13px">
<div class="flex h-40px border-b border-b-#F0F0F0 items-center justify-between px-8px">
<div class="text-14px text-#000">¥ {{data.paidPrice}}/{{data.price}}</div>
<div :class="`text-12px text-${itemLabel(data).color}`">{{itemLabel(data).label}}</div>
</div>
<div class="flex flex-grow-1 px-8px py-11px">
<div class="mr-8px">
<XImage class="w-57px h-56px rounded-4px" :src="data.hdPic"></XImage>
</div>
<div class="text-12px text-#1E1E1E">
<div>{{ $t('collectCode.qrcode.card.lotNo') }}{{ formatNumber(data.lotNo) }}</div>
<div>{{ $t('collectCode.qrcode.card.creator') }}{{ data.userName }}</div>
<div>{{ $t('collectCode.qrcode.card.createTime') }}{{data.createdAt}}</div>
</div>
<div class="flex flex-col justify-end ml-auto ">
<div class="flex w-55px h-26px bg-#2B53AC rounded-4px justify-center items-center">
<div @click="openQrCode(data)" class="text-12px text-#fff line-height-none mt-0.5px mr-5px">{{ $t('collectCode.qrcode.card.view') }}</div>
<div>
<img class="w-12px h-12px" src="@/static/images/icon-design-42@3x.png" alt="">
</div>
</div>
</div>
<van-dialog teleport="body" v-model:show="show">
<div class="flex justify-center py-20px">
<img :src="QRUrl" />
</div>
</van-dialog>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,220 @@
<script setup>
import { userArtworks } from "@/api/goods/index.js";
import { codeAuthStore } from "@/stores-collect-code/auth/index.js";
import { showImagePreview } from 'vant';
import { useThrottleFn } from '@vueuse/core'
import XImage from '@/components/x-image/index.vue'
import {useRouter} from "#vue-router";
import {goodStore} from "~/stores-collect-code/goods/index.js";
import {ref} from "vue";
import {offlineQrcodeCreate, offlineQrcodeDelete, offlineQrcodeList} from "~/api-collect-code/goods/index.js";
import codeCard from './components/codeCard/index.vue'
import {message} from "~/components/x-message/useMessage.js";
definePageMeta({
layout: 'default',
i18n: 'menu.profile',
})
const router = useRouter();
const localState = ref({
finished: false,
refreshing: false,
showDetail: false,
showHeight: ''
})
const {t} =useI18n()
const { userInfo, } = codeAuthStore()
const {getOfflineQrcodeList,itemList, loading: storeLoading,pageRef}= goodStore()
const initData = async () => {
onRefresh()
}
const show=ref(false)
const close=()=>{
show.value=false
}
const logOut=()=>{
localStorage.clear()
router.push('/collectCode/login')
}
const createForm=ref({
lotNo:'',
price:'',
})
const confirm=useThrottleFn(async ()=>{
if (!createForm.value.price){
message.warning(t('collectCode.message.amountRequired'))
return false
}else if (!createForm.value.lotNo){
message.warning(t('collectCode.message.lotNoRequired'))
return false
}
function is25Format(num) {
return /^25\d{3}$/.test(String(num));
}
if (!is25Format(createForm.value.lotNo)){
message.warning(t('collectCode.message.lotNoType'))
return
}
const res=await offlineQrcodeCreate({...createForm.value,price:String(createForm.value.price),lotNo:createForm.value.lotNo-25000})
if (res.status===0){
show.value=false
onRefresh()
}
},2000)
const onRefresh = async () => {
try {
localState.value.refreshing = true
localState.value.finished = false
const { finished } = await getOfflineQrcodeList(true)
localState.value.finished = finished
} finally {
localState.value.refreshing = false
}
}
const loadMore = async () => {
pageRef.value.page++
const { finished } = await getOfflineQrcodeList()
localState.value.finished = finished
}
const abnormal=ref(false)
const abnormalRow=ref({})
const inputLotNo=async (data)=>{
if (createForm.value.lotNo<=25000){
return
}
const res=await offlineQrcodeList({
lotNo:createForm.value.lotNo-25000
})
if (res.status===0){
if (res.data.Data?.length>0){
abnormal.value=true
abnormalRow.value=res.data.Data?.[0]
}
}
}
const deleteData=async (qrUid)=>{
const res=await offlineQrcodeDelete({
qrUid:qrUid
})
if (res.status===0){
getOfflineQrcodeList()
message.success(t('collectCode.message.deleteSuccess'))
onRefresh()
}
}
initData()
</script>
<template>
<div class="w-[100vw] bg-[url('@/static/images/3532@2x.png')] h-screen-nav bg-cover pt-43px flex-grow-1 flex flex-col">
<div class="flex items-center px-16px mb-43px">
<div class="mr-23px">
<img class="w-57px h-57px" src="@/static/images/5514@2x.png" alt="">
</div>
<div class="flex flex-col">
<div class="text-18px text-#181818">{{ userInfo.realName }}</div>
<div class="text-#575757 text-14px">{{ userInfo.telNum }}</div>
</div>
<div class="grow-1 flex justify-end" @click="logOut">
<img class="w-40px h-40px" src="@/static/images/logout.png" alt="">
</div>
</div>
<div class="border-b-1px border-b-#D3D3D3 px-16px flex">
<div class="text-#000 text-16px border-b-3 border-b-#2B53AC h-36px">{{ $t('collectCode.mine.offlineQrcode') }}</div>
</div>
<div class="grow-1 flex flex-col overflow-hidden py-15px">
<div class="overflow-auto">
<van-pull-refresh v-model="localState.refreshing"
:success-text="$t('collectCode.mine.refreshSuccess')"
:success-duration="700"
@refresh="onRefresh">
<van-list v-model:loading="storeLoading"
:finished="localState.finished"
:finished-text="$t('collectCode.mine.noMore')"
@load="loadMore" class="px-14px">
<template v-for="(item,index) of itemList" :key="item.qrUid">
<template v-if="item.payStatus===1">
<van-swipe-cell class="mb-14px" >
<codeCard :data="item"></codeCard>
<template #right>
<div class="w-65px h-full bg-#CF3050 flex items-center justify-center" @click="deleteData(item.qrUid)">
<img class="w-22px h-24px" src="@/static/images/delete3@.png" alt="">
</div>
</template>
</van-swipe-cell>
</template>
<template v-else>
<div class="mb-14px">
<codeCard :data="item"></codeCard>
</div>
</template>
</template>
</van-list>
</van-pull-refresh>
</div>
</div>
<div class="h-81px w-full flex justify-center shrink-0 pt-10px">
<div class="w-213px h-38px bg-#2B53AC text-#fff flex justify-center items-center text-14px rounded-4px" @click="show=true">
{{ $t('collectCode.mine.add') }}</div>
</div>
<van-dialog v-model:show="show">
<div class="pt-18px pb-24px px-24px">
<div class="text-16px text-#000 font-bold text-center mb-26px">{{ $t('collectCode.mine.addQrcode.title') }}</div>
<div class="">
<div class="flex mb-6px items-center">
<div class="w-58px">
<div class="text-#1A1A1A text-16px">{{ $t('collectCode.mine.addQrcode.amount') }}</div>
<div class="text-#939393 text-12px">{{ $t('collectCode.mine.addQrcode.amountUnit') }}</div>
</div>
<div>
<input v-model="createForm.price" type="number"
class="w-214px h-48px bg-#F3F3F3 rounded-4px px-11px text-16px" :placeholder="$t('collectCode.mine.addQrcode.amountPlaceholder')">
</div>
</div>
<div class="flex items-center">
<div class="w-58px">
<div class="text-#1A1A1A text-16px">{{ $t('collectCode.mine.addQrcode.lotNo') }}</div>
</div>
<div>
<input type="number" v-model="createForm.lotNo" @input="inputLotNo" class="w-214px h-48px bg-#F3F3F3 rounded-4px px-11px text-16px" :placeholder="$t('collectCode.mine.addQrcode.lotNoPlaceholder')">
</div>
</div>
</div>
<div class="flex flex-col items-center" v-if="abnormal">
<div class="text-#CF3050 text-12px mb-8px mt-4px">{{ $t('collectCode.mine.addQrcode.existingWarning') }}</div>
<div>
<XImage class="w-116px h-116px rounded-4px mb-9px" :src="abnormalRow.hdPic"></XImage>
<div class="text-12px text-#575757 flex flex-col items-center">
<div>{{ abnormalRow.title }}</div>
<div>{{ abnormalRow.author }}</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="border-t flex">
<van-button class="w-50% h-56px" style="border: none;border-radius: 0;border-right: 1.5px solid #E7E7E7" @click="show=false">
<span class="text-#000 text-16px text-center">{{ $t('collectCode.mine.addQrcode.cancel') }}</span>
</van-button>
<van-button class="w-50% h-56px !rounded-0" style="border: none;border-radius: 0" @click="confirm">
<span class="text-#000 text-16px text-center text-#2B53AC">{{ $t('collectCode.mine.addQrcode.confirm') }}</span>
</van-button>
</div>
</template>
</van-dialog>
</div>
</template>
<style scoped lang="scss">
:deep(.van-hairline--top.van-dialog__footer){
&>.van-button{
border-top: 1px solid #E7E7E7;
&.van-dialog__cancel{
border-right: 1px solid #E7E7E7;
}
}
}
</style>

View File

@ -0,0 +1,102 @@
<script setup>
import {liveStore} from "~/stores/live/index.js";
import {createBuyOrder} from "~/api/goods/index.js";
import {goodStore} from "~/stores/goods/index.js";
import {showLoadingToast, closeToast} from 'vant';
import {authStore} from "~/stores/auth/index.js";
import {message} from "~/components/x-message/useMessage.js";
import {createOrder} from "~/api-collect-code/goods/index.js";
import {codeAuthStore} from "~/stores-collect-code/auth/index.js";
import {useI18n} from "vue-i18n";
const {t} = useI18n();
const {checkoutSessionUrl,qrUid,qrData,codePKey,codePayUid} = codeAuthStore()
const payStatus = ref(0)
definePageMeta({
i18n: 'payment.title'
})
const changePayStatus = () => {
payStatus.value = payStatus.value === 0 ? 1 : 0
}
const amount = ref('')
const router = useRouter()
const confirmPay = async () => {
if (payStatus.value === 1 && !amount.value) {
message.warning(t('collectCode.payment.enterAmount'))
return
}
if (Number(qrData.value.leftPrice) < Number(amount.value)) {
message.warning(t('collectCode.payment.exceedTotal'))
return
}
showLoadingToast({
message: t('common.loading'),
forbidClick: true,
});
const res = await createOrder({
price: payStatus.value === 0 ? qrData.value.leftPrice : amount.value,
currency: qrData.value.currency,
qrUid:qrUid.value,
testReturnHost:window.location.origin,
testReturnEndPoint: '/collectCode/payment/result'
})
if (res.status === 0) {
codePKey.value=res.data.checkoutSessionUrl
codePayUid.value=res.data.payUid
router.push({
path:'/checkoutPage',
query:{
payUid:res.data.payUid,
returnUrl:'/collectCode/payment/result',
stripeKey:res.data.checkoutSessionUrl
}
})
}
}
const handleInput = (e) => {
//
const value = e.target.value
//
let newValue = value.replace(/[^\d.]/g, '')
//
newValue = newValue.replace(/\.{2,}/g, '.')
//
newValue = newValue.replace(/^(\d*\.\d*)\./, '$1')
//
if (newValue.indexOf('.') > 0) {
newValue = newValue.slice(0, newValue.indexOf('.') + 3)
}
// 0
newValue = newValue.replace(/^0+(\d)/, '$1')
amount.value = newValue
}
</script>
<template>
<div
class="w-[100vw] h-screen-nav bg-[url('@/static/images/3532@2x.png')] bg-cover flex-grow-1 flex flex-col items-center pt-183px px-30px">
<div class="mb-30px">
<img class="w-126px h-126px" src="@/static/images/dddf34@2x.png" alt="">
</div>
<div class="text-#1A1A1A text-16px mb-25px font-bold">{{ payStatus === 0 ? $t('collectCode.payment.fullPayment') : $t('collectCode.payment.partialPayment') }}</div>
<div class="text-#999999 text-16px mb-24px font-bold" v-if="payStatus===0">{{ qrData.currency }}
{{ qrData?.leftPrice }}
</div>
<div class="mb-12px" v-else>
<input v-model="amount" class="w-272px h-48px bg-#F3F3F3 px-11px text-16px" type="text"
:placeholder="`${$t('collectCode.payment.maxAmount')} ${qrData.currency} ${qrData?.leftPrice}`" @input="handleInput">
</div>
<div class="text-#2B53AC text-14px" @click="changePayStatus">{{ payStatus === 1 ? $t('collectCode.payment.fullPayment') : $t('collectCode.payment.partialPayment') }}</div>
<div class="w-full mt-auto mb-40px">
<van-button type="primary" block @click="confirmPay">
{{ $t('collectCode.payment.confirmPayment') }}
</van-button>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,87 @@
<script setup>
import {orderQuery} from "~/api/goods/index.js";
import { showLoadingToast, closeToast } from 'vant';
definePageMeta({
i18n: 'payment.text1',
})
const router = useRouter()
const {t} = useI18n();
const route = useRoute();
const resData = ref({})
let timer = null
let startTime = Date.now()
const queryOrder = async () => {
// 5
if (Date.now() - startTime > 5000) {
clearInterval(timer)
closeToast()
return
}
showLoadingToast({
message:t('common.loading'),
forbidClick: true,
});
try {
const res = await orderQuery({
orderNo: route.query.orderNo
})
if (res.status === 0) {
resData.value = res.data
//
if (resData.value.status === 1) {
clearInterval(timer)
closeToast()
}
}
} catch (error) {
clearInterval(timer)
closeToast()
}
}
//
await queryOrder()
//
timer = setInterval(async () => {
await queryOrder()
}, 1000)
//
onUnmounted(() => {
if (timer) {
clearInterval(timer)
closeToast()
}
})
const statusLabel = {
1: t('payment.text2'),
2: t('payment.text3'),
3: t('payment.text4'),
4: t('payment.text5'),
}
</script>
<template>
<div
class="w-[100vw] h-screen-nav bg-[url('@/static/images/3532@2x.png')] bg-cover grow-1 flex flex-col items-center px-30px">
<div class="flex flex-col items-center mt-150px">
<img v-if="resData.status===1" class="w-119px h-120px mb-36px" src="@/static/images/5554@2x1.png" alt="">
<img v-else class="w-119px h-120px mb-36px" src="@/static/images/zu6021@2x.png" alt="">
<div class="text-#000 text-16px mb-25px">{{ statusLabel[resData.status] }}!</div>
<div class="text-#999 text-16px">{{ resData.currency }}{{ resData.money }}</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,85 @@
<script setup>
import {VueSignaturePad} from 'vue-signature-pad';
import { showToast } from 'vant';
import {codeAuthStore} from "~/stores-collect-code/auth/index.js";
import {signOffline} from "~/api/goods/index.js";
import {useI18n} from "vue-i18n";
import {showLoadingToast} from 'vant'
const {t} = useI18n();
const {formData,number,qrData}=codeAuthStore()
const signaturePad = ref(null);
definePageMeta({
layout: ''
});
const router = useRouter();
const imgUrl = ref('');
const goBack = () => {
router.back()
};
const submitSignature = () => {
if (signaturePad.value?.isEmpty()) {
showToast(t('collectCode.signature.pleaseSign'));
return;
}
const { data } = signaturePad.value?.saveSignature();
imgUrl.value = data;
confirm()
};
const clearSignature = () => {
signaturePad.value?.clearSignature();
};
const confirm=async ()=>{
const toast= showLoadingToast({
message:t('collectCode.signature.loading'),
forbidClick:true,
})
try {
const res=await signOffline({
auctionArtworkUuid:qrData.value.auctionArtworkUuid,
userInfo:formData.value,
signOrder:Number(number.value),
signImgFileData:imgUrl.value,
})
if (res.status===0){
if(Number(number.value)===1){
router.push('/collectCode/signature/result')
}else if(Number(number.value)===2){
router.push('/collectCode/payment')
}
toast.close()
}
} catch (error) {
toast.close()
}
}
</script>
<template>
<div class="signature-container">
<div class="flex flex-col h-100vh px-20px py-20px bg-gray w-100vw">
<client-only>
<VueSignaturePad
width="100%"
height="93%"
class="signature bg-#fff rounded-10px mb-10px"
ref="signaturePad"
/>
</client-only>
<div class="flex justify-evenly">
<van-button class="!h-40px mr-15px" type="primary" @click="goBack">
{{ $t('collectCode.signature.back') }}
</van-button>
<van-button class="!h-40px" type="warning" @click="clearSignature">
{{ $t('collectCode.signature.clear') }}
</van-button>
<van-button class="!h-40px" type="primary" @click="submitSignature">
{{ $t('collectCode.signature.confirm') }}
</van-button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,232 @@
<script setup>
import {useI18n} from "vue-i18n";
import XVanSelect from '@/components/x-van-select/index.vue'
import XVanDate from '@/components/x-van-date/index.vue'
import {codeAuthStore} from "@/stores-collect-code/auth/index.js";
import {message} from "@/components/x-message/useMessage.js";
import countryCode from '@/pages/countryRegion/data/index.js'
import {fddInfo, offlineQrcode} from "~/api-collect-code/goods/index.js";
import {sessionUserNo} from "@/api-collect-code/auth/index.js";
import {signOffline} from "~/api/goods/index.js";
const {formData,number,auctionArtworkUuid,qrUid,qrData}=codeAuthStore()
definePageMeta({
layout: 'default',
i18n: 'menu.profile',
})
const {t,locale} = useI18n()
const router = useRouter()
const route = useRoute()
const columns = ref([
{text: t('realAuth.male'), value: 1},
{text: t('realAuth.female'), value: 2},
])
const columns1 = ref([
{text: t('realAuth.idTypeString'), value: 1},
{text: t('realAuth.passport'), value: 2},
{text: t('realAuth.other'), value: 3},
])
/**
* 根据当前语言获取默认国家/地区信息
* @returns {{zone: string, name: string}} 返回国家区号和名称
*/
const getDefaultCountry = () => {
let defaultCode = 'CN' //
switch (locale.value) {
case 'zh-CN':
defaultCode = 'CN'
break
case 'zh-TW':
defaultCode = 'TW'
break
case 'ja-JP':
defaultCode = 'JP'
break
case 'en-US':
defaultCode = 'US'
break
}
const country = countryCode.find(c => c.code === defaultCode)
return {
zone: country.zone,
name: locale.value === 'zh-CN' ? country.cn :
locale.value === 'zh-TW' ? country.tw :
locale.value === 'ja-JP' ? country.ja :
country.en
}
}
/**
* 跳转到国家/地区选择页面
*/
const goCountryRegion = () => {
router.push({
path: '/countryRegion'
})
}
/**
* 检查表单是否完整填写
* @param {Object} obj - 要检查的表单对象
* @returns {boolean} 是否完整
*/
function isFormComplete(obj) {
for (const key in obj) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
if (!isFormComplete(obj[key])) {
return false;
}
} else if (obj[key] === "") {
return false;
}
}
return true;
}
/**
* 获取线下二维码支付状态
*/
const getData = async () => {
const res = await offlineQrcode({
qrUid: qrUid.value
})
if (res.status === 0) {
qrData.value = res.data
}
}
/**
* 初始化页面数据
* 1. 处理URL参数
* 2. 检查支付状态(扫付款码场景)
* 3. 设置默认国家/地区信息
*/
const initData = async () => {
if (route.query.number){
number.value=Number(route.query.number)
}
if (route.query.qrUid){
qrUid.value=route.query.qrUid
}
//
if (number.value==2){
await getData()
if (qrData.value.payStatus===4){
router.push('/collectCode/payment')
}
}
if(!formData.value.countryCode){
const defaultCountry = getDefaultCountry()
formData.value.countryCode= defaultCountry.zone
}
if (route.query.zone){
formData.value.countryCode=route.query.zone
}
}
/**
* 处理下一步按钮点击
* 场景1(number=1): 扫号牌进入
* - 验证表单完整性
* - 检查用户号牌是否存在
* - 根据国家区号判断签署方式(国内用法大大,国外直接签字)
*
* 场景2(number=2): 扫付款码进入
* - 验证必填信息(手机区号用户名)
*/
const nextClick = async () => {
//
if (number.value==1){
if (!isFormComplete(formData.value)){
message.warning(t('signature.error.incompleteForm'))
return
}
const res2=await sessionUserNo({
phone:formData.value.phone
})
if (res2.status===0){
if(res2.data.exist){
message.warning(`您的号牌是${res2.data.userInfo.auction_user_no}`)
return
}
}
//
if (formData.value.countryCode==='86'&&formData.value.cardType===1){
const res=await fddInfo({
phone:formData.value.phone
})
if (res.status===0){
if (res.data.status===2){
router.push('/collectCode/signature/protocol')
}else {
const res1=await signOffline({
userInfo:formData.value,
signOrder:Number(number.value),
testReturnHost:window.location.origin,
testReturnEndPoint:'/collectCode/signature/protocol',
})
if (res1.status===0){
window.location.href=res1.data.fddVerifyUrl
}
}
}
} else {
//
router.push('/collectCode/signature/protocol')
}
} else if(number.value==2) {
if (!formData.value.phone || !formData.value.countryCode || !formData.value.userName){
message.warning('请填写完整信息')
return
}
router.push('/collectCode/signature/protocol')
}
}
initData()
</script>
<template>
<div
class="w-[100vw] bg-[url('@/static/images/asdfsdd.png')] h-screen-nav bg-cover pt-77px flex-grow-1 flex flex-col ">
<div class="text-16px text-#191919 font-bold mb-40px px-34px">
{{ $t('personal.title') }}
</div>
<div class="grow-1 px-34px">
<van-field v-model="formData.phone" type="tel" :label-width="161" :label="$t('personal.text')" class="mb-10px" :placeholder="$t('realAuth.phonePlaceholder')">
<template #label>
<div class="flex">
<div class="mr-41px whitespace-nowrap">{{ $t('profile.phone') }}</div>
<div @click="goCountryRegion">
<span class="mr-13px">+ {{ formData.countryCode }}</span>
<van-icon name="arrow-down" class="text-#777777"/>
</div>
</div>
</template>
</van-field>
<van-field :label="$t('profile.name')" v-model="formData.userName" class="mb-10px" :placeholder="$t('realAuth.namePlaceholder')"/>
<template v-if="number===1">
<x-van-select v-model="formData.gender" :label="$t('realAuth.gender')" :columns="columns"/>
<x-van-date :label="$t('realAuth.birthday')" v-model="formData.birthday" />
<van-field :label="$t('realAuth.adress')" v-model="formData.address" class="mb-10px" :placeholder="$t('realAuth.adressPlaceholder')"/>
<van-field :label="$t('realAuth.bank')" v-model="formData.bankName" class="mb-10px" :placeholder="$t('realAuth.bankPlaceholder')"/>
<van-field :label="$t('realAuth.bankCard')" v-model="formData.bankNo" class="mb-10px" :placeholder="$t('realAuth.bankCardPlaceholder')"/>
<x-van-select v-model="formData.cardType" :label="$t('realAuth.idTye')" :columns="columns1"/>
<van-field :label="$t('realAuth.idCard')" v-model="formData.cardId" class="mb-10px" :placeholder="$t('realAuth.idCardPlaceholder')"/>
</template>
</div>
<div class="h-81px bg-#fff flex justify-center pt-7px border-t shrink-0">
<van-button color="#2B53AC" class="w-213px van-btn-h-38px" @click="nextClick">{{ $t('personal.next') }}</van-button>
</div>
</div>
</template>
<style scoped lang="scss">
:deep(.van-cell.van-field){
padding-left: 0;
}
</style>

View File

@ -0,0 +1,171 @@
<script setup>
import pdfView from './pdfView/index.vue'
import { contractView, signOffline } from "~/api/goods/index.js"
import { codeAuthStore } from "~/stores-collect-code/auth/index.js"
import { useI18n } from "vue-i18n"
import { fddInfo } from "@/api-collect-code/goods/index.js"
import { showLoadingToast } from 'vant';
definePageMeta({
i18n: 'signature.protocol.title'
})
const { t } = useI18n()
const { formData, number, qrData } = codeAuthStore()
const activeNames = ref('')
const router = useRouter()
const pmblUrl = ref('')
/**
* 根据签署顺序(number)返回不同的协议列表
* number = 1: 买家签署阶段展示竞买协议竞买须知拍卖公告拍卖规则
* number = 2: 卖家签署阶段展示拍卖成交确认书拍卖笔录
*/
const protocolList = computed(() => {
if (number.value === 1) {
return [
{ id: '4', title: t('signature.agreement.buyerAgreement'), pdfName: 'jmxy', type: 'local' },
{ id: '3', title: t('signature.agreement.buyerGuide'), pdfName: 'jmxz', type: 'local' },
{ id: '1', title: t('signature.agreement.notice'), pdfName: 'pmgg', type: 'local' },
{ id: '2', title: t('signature.agreement.rules'), pdfName: 'pmgz', type: 'local' },
]
} else if (number.value === 2) {
return [
{ id: '6', title: t('signature.agreement.transfer'), pdfName: 'pmyjqrs', type: 'local' },
{ id: '5', title: t('signature.agreement.record'), pdfName: pmblUrl.value, type: 'remote' }
]
}
return []
})
/**
* 获取拍卖笔录PDF
* 通过拍品UUID获取拍卖笔录的查看地址
*/
const fetchPmblPdf = async () => {
try {
const res = await contractView({
auctionArtworkUuid: qrData.value.auctionArtworkUuid,
})
pmblUrl.value = res.data?.viewUrl
} catch (error) {
console.error('获取拍卖笔录失败:', error)
}
}
/**
* 折叠面板变化处理
* 当打开拍卖笔录面板时获取PDF地址
*/
const handleCollapseChange = (name) => {
activeNames.value = name
if (name === '5' && !pmblUrl.value) {
fetchPmblPdf()
}
}
/**
* 确认签署处理
* 1. 获取用户法大大认证信息
* 2. 根据用户类型和地区判断签署流程:
* - 特殊用户且isMainland=1: 走大陆签署流程
* - 特殊用户且isMainland=0: 走非大陆签署流程
* - 普通用户:
* - 大陆用户(countryCode=86且身份证): 走大陆签署流程
* - 其他用户: 走非大陆签署流程
*/
const confirm = async () => {
const toast= showLoadingToast({
message: '加载中...',
forbidClick: true,
});
try {
const fddResponse = await fddInfo({ phone: formData.value.phone })
if (fddResponse.status === 0) {
const { userId, isMainland } = fddResponse.data
//
if (userId) {
if (isMainland === 1) {
await handleMainlandSign()
} else {
router.push('/collectCode/signature/panel')
}
return
}
//
const isMainlandUser = formData.value.countryCode === '86' && formData.value.cardType === 1
if (isMainlandUser) {
await handleMainlandSign()
} else {
router.push('/collectCode/signature/panel')
}
}
} catch (error) {
console.error('签署确认失败:', error)
}finally{
toast.close();
}
}
/**
* 处理大陆用户签署流程
*/
const handleMainlandSign = async () => {
const res = await signOffline({
userInfo: formData.value,
auctionArtworkUuid: qrData.value.auctionArtworkUuid,
signOrder: Number(number.value),
})
if (res.status === 0) {
window.location.href = res.data.fddVerifyUrl
}
}
</script>
<template>
<div class="bg-#EBEBEB h-screen-nav flex flex-col">
<!-- 顶部提示信息 -->
<div class="h-50px text-14px text-#191919 bg-#fff flex items-center px-21px mb-6px shrink-0">
{{ t('signature.tips.prePayment') }}
</div>
<!-- 协议列表折叠面板 -->
<van-collapse
accordion
v-model="activeNames"
class="grow-1"
@change="handleCollapseChange"
>
<van-collapse-item
v-for="item in protocolList"
:key="item.id"
:name="item.id"
class="mb-6px"
>
<template #title>
<div class="text-#2B53AC text-14px">{{ item.title }}</div>
</template>
<pdfView
:pdf-name="item.pdfName"
:type="item.type"
:is-active="activeNames === item.id"
/>
</van-collapse-item>
</van-collapse>
<!-- 底部确认按钮 -->
<div class="h-81px bg-#fff flex justify-center pt-7px border-t">
<van-button
color="#2B53AC"
class="w-213px van-btn-h-38px"
@click="confirm"
>
{{ t('signature.action.agree') }}
</van-button>
</div>
</div>
</template>

View File

@ -0,0 +1,68 @@
<template>
<div class="pdf-container">
<client-only>
<div v-if="loading" class="loading-container">
<van-loading type="spinner" size="24px">{{ $t('common.loading') }}</van-loading>
</div>
<VuePdfEmbed
v-if="pdfUrl"
:source="pdfUrl"
@rendered="handleRendered"
/>
</client-only>
</div>
</template>
<script setup>
import VuePdfEmbed from 'vue-pdf-embed'
const props = defineProps({
pdfName: {
type: String,
required: true
},
type: {
type: String,
default: 'local', // 'local' 'remote'
},
isActive: {
type: Boolean,
default: false
}
})
const loading = ref(true)
const pdfUrl = computed(() => {
if (!props.pdfName) return ''
return props.type === 'local' ? `/pdfs/${props.pdfName}.pdf` : props.pdfName
})
watch(() => props.isActive, (newVal) => {
if (newVal) {
loading.value = true
}
})
const handleRendered = () => {
loading.value = false
}
</script>
<style scoped>
.pdf-container {
position: relative;
min-height: 200px;
width: 100%;
}
.loading-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
:deep(embed) {
width: 100% !important;
}
</style>

View File

@ -0,0 +1,34 @@
<script setup>
import {sessionUserNoCreate} from "~/api-collect-code/goods/index.js";
import {codeAuthStore} from "~/stores-collect-code/auth/index.js";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const {formData,number}=codeAuthStore()
const auctionUserNo=ref('')
definePageMeta({
i18n: 'collectCode.signature.title'
})
const res=await sessionUserNoCreate({
phone:formData.value.phone
})
if (res.status===0){
auctionUserNo.value=res.data.auctionUserNo
}
</script>
<template>
<div class="w-[100vw] h-screen-nav bg-[url('@/static/images/3532@2x.png')] bg-cover flex-grow-1 flex flex-col items-center pt-183px px-30px">
<div class="flex flex-col items-center pt-18px px-31px">
<div class="text-#000 text-16px mb-4px">{{ t('signature.protocol.success') }}</div>
<div class="text-#939393 text-12px mb-31px">&nbsp&nbsp&nbsp{{ t('collectCode.signature.resultText') }}&nbsp&nbsp&nbsp</div>
<div class="relative">
<img class="w-258px h-144px" src="@/static/images/zu6020@2x.png" alt="">
<div class="absolute text-#FDD68D text-68px bottom-1px left-1/2 transform translate-x--1/2">{{auctionUserNo}}</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,225 @@
<script setup>
import {ref, computed, watch} from 'vue';
import pinyin from 'pinyin';
import countryCode from './data/index.js';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
definePageMeta({
i18n: 'countryRegion.title',
})
const router = useRouter()
const { t, locale } = useI18n()
const value = ref('');
const alphabet = computed(() => {
if (!groupedCountries.value) return ['#'];
//
const letters = Object.keys(groupedCountries.value)
.filter(key => key !== '#')
.sort();
// #
return ['#', ...letters];
});
//
const frequentCountryCodes = ['CN', 'TW', 'JP', 'US'];
function groupByPinyinInitial(data) {
const grouped = {};
//
grouped['#'] = [];
data.forEach(country => {
if (frequentCountryCodes.includes(country.code)) {
const countryName = locale.value === 'zh-CN' ? country.cn :
locale.value === 'zh-TW' ? country.tw :
locale.value === 'ja-JP' ? country.ja :
country.en;
grouped['#'].push({
...country,
displayName: countryName
});
}
});
//
data.forEach(country => {
if (!frequentCountryCodes.includes(country.code)) {
const countryName = locale.value === 'zh-CN' ? country.cn :
locale.value === 'zh-TW' ? country.tw :
locale.value === 'ja-JP' ? country.ja :
country.en;
// 使
let initial;
if (locale.value === 'zh-CN' || locale.value === 'zh-TW') {
// 使
const pinyinName = locale.value === 'zh-CN' ? country.cn : country.tw;
initial = pinyin(pinyinName, {style: pinyin.STYLE_FIRST_LETTER})[0][0].toUpperCase();
} else if (locale.value === 'ja-JP') {
initial = '';
} else {
// 使 en
initial = country.en.charAt(0).toUpperCase();
}
if (!grouped[initial]) {
grouped[initial] = [];
}
grouped[initial].push({
...country,
displayName: countryName
});
}
});
//
Object.keys(grouped).forEach(key => {
grouped[key].sort((a, b) => {
if (locale.value === 'zh-CN' || locale.value === 'zh-TW') {
// 使
const pinyinA = pinyin(locale.value === 'zh-CN' ? a.cn : a.tw, {style: pinyin.STYLE_NORMAL}).join('');
const pinyinB = pinyin(locale.value === 'zh-CN' ? b.cn : b.tw, {style: pinyin.STYLE_NORMAL}).join('');
return pinyinA.localeCompare(pinyinB);
} else if (locale.value === 'ja-JP') {
return a.displayName.localeCompare(b.displayName, 'ja-JP');
} else {
return a.en.localeCompare(b.en);
}
});
});
if (locale.value === 'ja-JP') {
return grouped;
} else {
//
const sortedGrouped = {};
// #
sortedGrouped['#'] = grouped['#'];
//
Object.keys(grouped)
.filter(key => key !== '#')
.sort()
.forEach(key => {
sortedGrouped[key] = grouped[key];
});
return sortedGrouped;
}
}
const groupedCountries = ref([])
const initData = () => {
groupedCountries.value = groupByPinyinInitial(countryCode);
}
const searchCountry = computed(() => {
if (!value.value) {
return groupedCountries.value;
}
return Object.keys(groupedCountries.value).reduce((filtered, initial) => {
const countries = groupedCountries.value[initial].filter(country =>
country.displayName.toLowerCase().includes(value.value.toLowerCase())
);
if (countries.length > 0) {
filtered[initial] = countries;
}
return filtered;
}, {});
});
const showIndexBar = computed(() => locale.value !== 'ja-JP')
const route = useRoute()
const handleCountrySelect = (country) => {
router.replace({
path: window.history.state.back,
query: {
zone: country.zone,
countryName: country.displayName
}
})
}
initData()
//
watch(locale, () => {
initData()
})
</script>
<template>
<div>
<van-sticky>
<van-search v-model="value" :placeholder="t('countryRegion.searchPlaceholder')"/>
</van-sticky>
<van-index-bar
v-if="showIndexBar"
sticky
:sticky-offset-top="55"
:index-list="alphabet"
>
<!-- 常用国家分类 -->
<van-index-anchor index="#">{{ t('countryRegion.frequentCountry') }}</van-index-anchor>
<van-cell
v-for="country in searchCountry['#']"
:key="country.code"
:title="country.displayName"
@click="handleCountrySelect(country)"
clickable
>
<div class="pr-[25px]"> +{{ country.zone }}</div>
</van-cell>
<!-- 其他国家按字母分类 -->
<template v-for="(countries, index) in searchCountry" :key="index">
<template v-if="index !== '#'">
<van-index-anchor
:index="index"
></van-index-anchor>
<van-cell
v-for="country in countries"
:key="country.code"
:title="country.displayName"
@click="handleCountrySelect(country)"
clickable
>
<div class="pr-[25px]"> +{{ country.zone }}</div>
</van-cell>
</template>
</template>
</van-index-bar>
<div v-else>
<div class="mb-4">
<div class="px-4 py-2 text-gray-600">{{ t('countryRegion.frequentCountry') }}</div>
<van-cell
v-for="country in searchCountry['#']"
:key="country.code"
:title="country.displayName"
@click="handleCountrySelect(country)"
clickable
>
<div class="pr-[25px]"> +{{ country.zone }}</div>
</van-cell>
</div>
<van-cell
v-for="country in Object.values(searchCountry).flat().filter(c => !frequentCountryCodes.includes(c.code))"
:key="country.code"
:title="country.displayName"
@click="handleCountrySelect(country)"
clickable
>
<div class="pr-[25px]"> +{{ country.zone }}</div>
</van-cell>
</div>
<van-back-top v-if="showIndexBar" right="15vw" bottom="10vh"/>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,18 @@
<script setup>
import {goodStore} from "@/stores/goods/index.js";
import xImage from '@/components/x-image/index.vue'
const {
auctionDetail
} = goodStore()
</script>
<template>
<div class="px-16px pt-14px">
<div class="text-#575757 text-14px" v-html="auctionDetail.info">
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,28 @@
<script setup>
import xPopup from '@/components/x-popup/index.vue'
import ItemDetail from "@/components/itemDetail/index.vue";
import {goodStore} from "@/stores/goods/index.js";
const {
artWorkDetail
} = goodStore()
const props = defineProps({
show: {
type: Boolean,
default: false
},
detailInfo: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:show'])
const handleClose = () => {
emit('update:show', false)
}
</script>
<template>
<xPopup :show="show" :title="$t('home.lot_detail')" @update:show="handleClose">
<ItemDetail :detailInfo="detailInfo" />
</xPopup>
</template>

View File

@ -0,0 +1,112 @@
<script setup>
import {ref} from 'vue'
import {goodStore} from "@/stores/goods"
import DetailPopup from '../DetailPopup/index.vue'
import WaterfallFlow from '@/components/waterfallFlow/index.vue'
const {
itemList,
pageRef,
currentItem,
loading: storeLoading,
getArtworkList,
} = goodStore()
const localState = ref({
finished: false,
refreshing: false,
showDetail: false,
showHeight: ''
})
//
const loadMore = async () => {
pageRef.value.page++
const {finished} = await getArtworkList()
localState.value.finished = finished
}
//
const onRefresh = async () => {
try {
localState.value.refreshing = true
localState.value.finished = false
const {finished} = await getArtworkList(true)
localState.value.finished = finished
} finally {
localState.value.refreshing = false
}
}
//
const openShow = async (item) => {
localState.value.showDetail = true
currentItem.value = item
}
</script>
<template>
<div class="px-[16px] pt-[16px]">
<van-pull-refresh
v-model="localState.refreshing"
:success-duration="700"
@refresh="onRefresh"
>
<template #success>
<van-icon name="success"/>
<span>{{ $t('home.refresh_show') }}</span>
</template>
<van-list
v-model:loading="storeLoading"
:finished="localState.finished"
:finished-text="$t('home.finished_text')"
@load="loadMore"
>
<div class="w-full flex gap-[16px]">
<WaterfallFlow :items="itemList" :column-count="2">
<template #default="{ item, index }">
<div
@click="openShow(item)"
class="w-full"
>
<div class="relative w-full">
<img
:src="item.artwork?.hdPic + '?x-oss-process=image/resize,w_500,h_500/format,webp/quality,q_80'"
class="w-full object-cover rounded-4px min-h-[200px]"
loading="lazy"
/>
<div
class="absolute rounded-2px overflow-hidden line-height-12px left-[8px] top-[8px] h-[17px] w-[60px] flex items-center justify-center bg-[#2b53ac] text-[12px] text-[#fff]"
>
Lot{{ item.index+25000 }}
</div>
</div>
<div class="pt-[8px]">
<div class="text-[14px] text-[#000000] leading-[20px]">
{{ item?.artwork?.name }} | {{ item?.artwork?.artistName }}
</div>
<div class="mt-[4px] text-[12px] text-[#575757]">
{{ $t('home.start_price') }}{{ item?.startPrice ?? 0 }}
</div>
<div
v-if="item.soldPrice"
class="mt-[4px] text-[12px] text-[#b58047]"
>
{{ $t('home.close_price') }}{{ item?.soldPrice ?? 0 }}
</div>
</div>
</div>
</template>
</WaterfallFlow>
</div>
</van-list>
</van-pull-refresh>
<DetailPopup v-model:show="localState.showDetail" :detailInfo="currentItem"></DetailPopup>
</div>
</template>
<style scoped>
.content {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
</style>

108
app/pages/home/index.vue Normal file
View File

@ -0,0 +1,108 @@
<script setup>
import liveRoom from '@/pages/liveRoom/index.client.vue';
import {goodStore} from "@/stores/goods/index.js";
import ItemList from './components/ItemList/index.vue'
import Cescribe from './components/Cescribe/index.vue'
import FloatingBubble from '~/components/floating2/index.vue'
import {liveStore} from "~/stores/live/index.js";
const {auctionDetail,getArtworkList,getAuctionDetail} = goodStore();
const {fullLive} = liveStore()
const changeLive = () => {
if (!fullLive.value){
if (auctionDetail.value.isLiving===1){
fullLive.value = true;
getArtworkList(true)
}
}
}
await getAuctionDetail()
</script>
<template>
<div class="grow-1 flex flex-col">
<client-only>
<div class="relative bg-#000" @click="changeLive">
<liveRoom :class="['changeLive', fullLive ? 'expanded' : 'collapsed']"/>
<div v-if="auctionDetail.isLiving===1" class="absolute h-188px w-screen pt-36px flex flex-col text-#fff top-0 left-0 items-center">
<div class="text-18px mb-5px">{{ auctionDetail.title }}</div>
<div class="text-12px mb-54px">{{ $t('home.text1') }}<van-icon name="arrow" /></div>
<div><span>-</span> <span class="text-12px mx-5px">{{auctionDetail.totalNum}}{{ $t('common.items') }}{{ $t('common.auction') }}</span> <span>-</span></div>
<div class="text-12px">{{auctionDetail.startDate}} {{auctionDetail.startTitle}}</div>
</div>
<div v-else class="absolute h-188px w-screen pt-36px flex flex-col text-#fff top-0 left-0 items-center bg-[url('@/static/images/z6022@2x.png')]">
<div class="text-18px mb-5px">{{ auctionDetail.title }}</div>
<div class="text-12px mb-54px">{{$t('home.text3')}}{{auctionDetail.isLiving===2?$t('home.text4'):$t('home.text5')}}</div>
<div><span>-</span> <span class="text-12px mx-5px">{{auctionDetail.totalNum}}{{ $t('common.items') }}{{ $t('common.auction') }}</span> <span>-</span></div>
<div class="text-12px">{{auctionDetail.startDate}} {{auctionDetail.startTitle}}</div>
</div>
</div>
</client-only>
<div v-if="!fullLive" class="bg-#fff grow-1 flex flex-col">
<van-tabs sticky animated>
<van-tab :title="$t('home.tab1')">
<div class="min-h-[600px] overflow-hidden">
<ItemList></ItemList>
</div>
</van-tab>
<van-tab :title="$t('home.tab2')">
<Cescribe></Cescribe>
</van-tab>
</van-tabs>
<van-back-top right="15vw" bottom="10vh"/>
</div>
</div>
</template>
<style scoped lang="scss">
// :deep(.van-tabs.van-tabs--line){
// flex-grow: 1;
// display: flex;
// flex-direction: column;
// }
// :deep(.van-tabs__content.van-tabs__content--animated){
// flex-grow: 1;
// display: flex;
// flex-direction: column;
// }
.ellipsis {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.van-swipe__indicator) {
width: 8px;
height: 8px;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 1s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
:deep(.van-swipe__indicator:not(.van-swipe__indicator--active)) {
background: rgba(0, 0, 0, 0.8);
}
.changeLive {
width: 100%;
overflow: hidden;
transition: height 0.4s ease, transform 0.4s ease;
}
.changeLive.collapsed {
height: 188px;
}
.changeLive.expanded {
position: absolute;
z-index: 10;
height: calc(100vh - var(--van-nav-bar-height));
}
</style>

10
app/pages/index.vue Normal file
View File

@ -0,0 +1,10 @@
<script setup>
import Home from './home/index.vue'
definePageMeta({
layout: 'default',
i18n: 'menu.home',
})
</script>
<template>
<Home/>
</template>

View File

@ -0,0 +1,69 @@
<script setup>
import {liveStore} from "@/stores/live/index.js";
import {authStore} from "~/stores/auth/index.js";
import {useI18n} from 'vue-i18n'
const {auctionData} = liveStore()
const {userInfo}= authStore()
function formatThousands(num) {
return Number(num).toLocaleString();
}
const headList=[
{
label:useI18n().t('live_room.head'),
color:'#D03050',
value:'head'
},
{
label:useI18n().t('live_room.out'),
color:'#939393',
value:'out'
},
{
label:useI18n().t('live_room.success'),
color:'#34B633',
value:'success'
}
]
const headItem=(statusCode)=>{
return headList.find(x=>x.value===statusCode)
}
</script>
<template>
<div
id="list-container"
class="w-344px h-86px overflow-y-auto bg-#fff rounded-4px text-14px text-#939393 pt-7px pb-7px px-11px flex flex-col justify-between"
>
<transition-group name="list" tag="div">
<template v-if="auctionData.wsType==='stopArtwork'">
<div class="text-#939393 text-14px">{{ $t('live_room.next_lot') }}</div>
</template>
<template v-else-if="auctionData.auctionPriceList?.buys?.length>0">
<div v-for="(item, index) in auctionData.auctionPriceList?.buys" :key="index" class="flex flex-shrink-0 break-words whitespace-normal">
<div class="text-start shrink-0 w-60px" :style="`color: ${headItem(item.statusCode).color}`" >{{ headItem(item.statusCode).label }}</div>
<div class="text-start shrink-0 w-80px">{{ item.auctionType==='local'? $t('live_room.spot'):$t('live_room.network') }}</div>
<div class="text-start shrink-0 w-80px">{{ item.createdAt }}</div>
<div class="text-start shrink-0 w-80px ">
{{item.baseCurrency}}{{ formatThousands(item.baseMoney) }}
</div>
<div class="text-end text-#2B53AC shrink-0 w-20px">{{ item.userId===userInfo.ID?$t('live_room.me'):'' }}</div>
</div>
</template>
<template v-if="auctionData.wsType==='newArtwork'">
<div class="text-#939393 text-14px">{{ $t('live_room.start') }}</div>
</template>
</transition-group>
</div>
</template>
<style scoped>
.list-enter-active, .list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from, .list-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style>

View File

@ -0,0 +1,89 @@
<script setup>
import {liveStore} from "~/stores/live/index.js";
import { showMinWindow, hideMinWindow } from '@/components/liveMinWindow/createMinWindow.js'
const {lastSnapshot,fullLive} = liveStore()
const props = defineProps({
show: {
type: Boolean,
default: false
},
price: {
type: Number,
default: 0
}
})
const router = useRouter()
const emit = defineEmits(['update:show'])
const payStatus=ref(0)
const changePayStatus=()=>{
payStatus.value=payStatus.value===0?1:0
}
const close=()=>{
emit('update:show',false)
}
const confirm=()=>{
router.push('/signature/protocol')
handleCapture()
emit('update:show',false)
}
const captureVideoFrame = () => {
try {
const video = document.querySelector('#J_prismPlayer video')
if (!video) {
return null
}
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/jpeg', 0.9)
} catch (error) {
return null
}
}
const handleCapture = () => {
const imageUrl = captureVideoFrame()
if (imageUrl) {
lastSnapshot.value=imageUrl
showMinWindow(lastSnapshot.value,{
onClick:()=>{
router.replace('/')
fullLive.value=true
}
})
}
}
</script>
<template>
<div>
<van-dialog :show="show" show-cancel-button @cancel="close" @confirm="confirm">
<div class="flex flex-col pt-18px pb-13px justify-between items-center h-144px">
<template v-if="payStatus===0">
<div class="text-#000 text-16px font-600 ">{{ $t('live_room.all_pay') }}</div>
<div class="text-#000 text-16px ">RMB 5,000</div>
</template>
<template v-if="payStatus===1">
<div class="text-#000 text-16px font-600 ">{{ $t('live_room.part_pay') }}</div>
<input class="w-272px h-48px bg-#F3F3F3 px-11px text-16px" type="text" placeholder="最多RMB5,000">
</template>
<div class="text-#2B53AC text-14px" @click="changePayStatus">{{payStatus===0 ? $t('live_room.part_pay') : $t('live_room.all_pay')}}</div>
</div>
</van-dialog>
</div>
</template>
<style scoped lang="scss">
:deep(.van-hairline--top.van-dialog__footer){
&>.van-button{
border-top: 1px solid #E7E7E7;
&.van-dialog__cancel{
border-right: 1px solid #E7E7E7;
}
}
}
</style>

View File

@ -0,0 +1,42 @@
<script setup>
import successImg from '@/static/images/zu5554@2x.png'
import errorImg from '@/static/images/zu5561@2x.png'
const props = defineProps({
show: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'success'
},
price: {
type: Number,
default: 1000
}
})
const emit = defineEmits(['cancel','update:show'])
const cancel= () => {
emit('update:show', false)
}
</script>
<template>
<div>
<van-dialog style="overflow: visible" :show="show" show-cancel-button :show-confirm-button="false" :cancelButtonText="$t('login.back')" cancelButtonColor="#2B53AC" @cancel="cancel">
<div class="h-145px relative flex justify-center">
<img :src="type==='success' ? successImg : errorImg" class="w-119px h-120px absolute top--74px z-9999 left-1/2 transform translate-x--1/2" alt="">
<div class="mt-94px text-#A9A9A9 text-16px">{{price}}</div>
</div>
</van-dialog>
</div>
</template>
<style scoped>
:deep(.van-hairline--top.van-dialog__footer){
border-top: 1px solid #E7E7E7;
border-bottom-left-radius:8px ;
border-bottom-right-radius:8px ;
}
</style>

View File

@ -0,0 +1,123 @@
<script setup>
import { ref, computed } from "vue"
import lockClosed from "@/static/images/lockdfd@2x.png"
import lockOpen from "@/static/images/lock4@2x.png"
import { liveStore } from "@/stores/live/index.js"
import xButton from '@/components/x-button/index.vue'
import tangPopup from './tangPopup.vue'
import { goodStore } from "@/stores/goods/index.js"
import { authStore } from "~/stores/auth/index.js"
import {showMinWindow} from "~/components/liveMinWindow/createMinWindow.js";
import {hideMinWindow1, showMinWindow1} from "~/components/floatingBubble/floating.js";
const { quoteStatus, changeStatus, show, auctionData, getSocketData ,lastSnapshot,fullLive} = liveStore()
const { pageRef } = goodStore()
const { userInfo ,payment} = authStore()
const showTang = ref(false)
const router = useRouter()
const captureVideoFrame = () => {
try {
const video = document.querySelector('#J_prismPlayer video')
if (!video) {
return null
}
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/jpeg', 0.9)
} catch (error) {
return null
}
}
const handleCapture = () => {
showMinWindow1({
onClick:()=>{
router.replace('/')
fullLive.value=true
hideMinWindow1()
}
})
}
const openOne = () => {
showTang.value = true
}
const paySide = computed(() => {
//
return auctionData.value.needPayBuys?.length>0
})
const goPay = () => {
payment.value.leftCurrency=auctionData.value.needPayBuys?.[0]?.leftCurrency
payment.value.leftPrice=auctionData.value.needPayBuys?.[0]?.leftCnyPrice
payment.value.buyUid=auctionData.value.needPayBuys?.[0]?.uuid
payment.value.auctionArtworkUuid=auctionData.value.needPayBuys?.[0]?.auctionArtworkUuid
handleCapture()
router.push('/signature/protocol')
}
</script>
<template>
<div class="bg-white w-60px rounded-4px overflow-hidden">
<!-- 拍品信息 -->
<van-button
class="w-60px !h-60px"
@click.stop="openOne"
style="border: none;border-radius: 0"
>
<div class="text-center flex flex-col justify-center items-center text-#7D7D7F text-12px">
<div>{{ $t('live_room.lots') }}</div>
<div>({{ auctionData?.artwork?.index }}/{{ pageRef.itemCount ?? 0 }})</div>
</div>
</van-button>
<tangPopup v-model:show="showTang"></tangPopup>
<!-- 出价开关 -->
<van-button
class="w-60px !h-60px"
@click.stop="changeStatus"
style="border-right: none;border-left: none;border-radius: 0;padding: 0"
>
<div class="text-center flex flex-col justify-center items-center">
<div class="mb-4px">
<img
:src="quoteStatus ? lockClosed : lockOpen"
class="w-16px h-21px"
alt="锁图标"
/>
</div>
<div
:class="quoteStatus ? 'text-gray-500' : 'text-blue-600'"
class="text-10px transition-colors duration-200"
>
{{ quoteStatus ? $t('live_room.colse_bid') : $t('live_room.start_bid') }}
</div>
</div>
</van-button>
<!-- 支付 -->
<van-button
v-if="paySide"
class="w-60px !h-60px"
style="border: none;border-radius: 0"
@click.stop="goPay"
>
<div class="text-center flex flex-col justify-center items-center text-yellow-600">
<div class="text-10px">{{auctionData.needPayBuys?.[0]?.leftCurrency}}</div>
<div class="text-12px">{{auctionData.needPayBuys?.[0]?.leftPrice}}</div>
<div class="text-10px">{{ $t('art_detail_page.button') }}</div>
</div>
</van-button>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,171 @@
<script setup>
import xPopup from '@/components/x-popup/index.vue'
import {goodStore} from "@/stores/goods/index.js";
import xImage from '@/components/x-image/index.vue'
import DetailPopup from '@/pages/home/components/DetailPopup/index.vue'
import {liveStore} from "~/stores/live/index.js";
import {ref} from "vue";
const {pageRef,itemList,getArtworkList, loading: storeLoading,} = goodStore();
const {auctionData} = liveStore()
const {t:$t} = useI18n()
const showDetail=ref(false)
const localState = ref({
finished: false,
refreshing: false,
showDetail: false,
showHeight: ''
})
const onRefresh = async () => {
try {
localState.value.refreshing = true
localState.value.finished = false
const { finished } = await getArtworkList(true)
localState.value.finished = finished
} finally {
localState.value.refreshing = false
}
}
const props = defineProps({
show: Boolean,
title: {
type: String,
default: ''
}
})
const scrollToCurrentItem = () => {
if (!itemList.value?.length) return
const currentIndex = itemList.value.findIndex(
item => auctionData.value.artwork.index === item?.index
)
if (currentIndex > -1) {
const container = document.querySelector('.list-container')
const targetElement = document.querySelectorAll('.item-wrapper')[currentIndex]
if (targetElement && container) {
const containerTop = container.getBoundingClientRect().top
const elementTop = targetElement.getBoundingClientRect().top
const scrollTop = elementTop - containerTop + container.scrollTop
container.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
}
}
}
const emit = defineEmits(['update:show'])
const showDetailInfo=ref(null)
const close = () => emit('update:show', false);
const openShow=(item)=>{
showDetailInfo.value=item
showDetail.value=true
}
const loadMore = async () => {
pageRef.value.page++
const { finished } = await getArtworkList()
localState.value.finished = finished
}
watch(()=>{
return auctionData.value?.artwork?.index
},(newValue)=>{
})
watch(()=>props.show,async (newValue)=>{
if (newValue){
if(auctionData.value?.artwork?.index>itemList.value?.length+1){
pageRef.value.pageSize = 5;
const targetIndex = auctionData.value?.artwork?.index;
const currentLength = itemList.value?.length || 0;
const needLoadTimes = Math.ceil((targetIndex - currentLength) / pageRef.value.pageSize);
for(let i = 0; i < needLoadTimes; i++) {
pageRef.value.page++;
await getArtworkList();
}
await nextTick();
scrollToCurrentItem();
} else {
nextTick(()=>{
scrollToCurrentItem();
});
}
}
})
</script>
<template>
<div>
<x-popup :show="show" @update:show="close">
<template #title>
<div class="text-#000 text-16px">{{ $t('home.tab1')}}</div>
<div class="text-#939393 text-16px ml-14px">{{ $t('live_room.total') }}{{ pageRef.itemCount }}{{ $t('live_room.lots_num') }}</div>
</template>
<div>
<van-pull-refresh
v-model="localState.refreshing"
:success-text="$t('home.refresh_show')"
:success-duration="700"
@refresh="onRefresh"
>
<template #success>
<van-icon name="success" /> <span>{{ $t('home.refresh_show') }}</span>
</template>
<van-list
v-model:loading="storeLoading"
:finished="localState.finished"
:finished-text="$t('home.finished_text')"
@load="loadMore"
>
<div
v-for="(item,index) of itemList"
:key="item.uuid"
class="flex mb-21px item-wrapper"
@click="openShow(item)"
>
<div
class="mr-10px flex-shrink-0 rounded-4px overflow-hidden cursor-pointer relative"
>
<xImage
:preview="false"
class="w-80px h-80px"
:src="item.artwork?.hdPic+'?x-oss-process=image/resize,w_280,h_280/format,webp/quality,q_80'"
:alt="item?.artworkTitle"
loading="lazy"
/>
<div class="w-65px h-17px bg-#2B53AC text-12px line-height-none flex justify-center items-center absolute top-2px left-2px text-#fff">Lot{{item.index+25000}}</div>
<div v-show="auctionData?.artwork?.index===item?.index" class="w-80px h-20px bg-#B58047 flex line-height-none justify-center items-center text-#fff text-12px bottom-0 absolute blink">{{ $t('live_room.cast') }}</div>
</div>
<div>
<div class="ellipsis line-height-20px text-16px font-600 min-h-40px">
{{ item.artworkTitle }}
</div>
<div class="text-14px text-#575757">{{ $t('home.start_price') }}{{item?.startPriceCurrency}} {{item?.startPrice}}</div>
<div class="text-14px text-#B58047" v-if="item.soldPrice">{{ $t('home.close_price') }}{{item.soldPrice}}</div>
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</x-popup>
<DetailPopup v-model:show="showDetail" :detail-info="showDetailInfo"></DetailPopup>
</div>
</template>
<style scoped>
.ellipsis {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
.blink {
animation: fade 1s linear infinite;
}
@keyframes fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>

View File

@ -0,0 +1,247 @@
<script setup>
import {ref, onMounted, onBeforeUnmount, watch,useTemplateRef } from 'vue'
import AliyunPlayer from 'aliyun-aliplayer'
import 'aliyun-aliplayer/build/skins/default/aliplayer-min.css'
import sideButton from '@/pages/liveRoom/components/SideButton/index.vue'
import broadcast from '@/pages/liveRoom/components/Broadcast/index.vue'
import {liveStore} from "@/stores/live/index.js"
import liveLoading from '@/components/liveLoading/index.vue'
import paymentResults from '@/pages/liveRoom/components/PaymentResults/index.vue'
import paymentInput from '@/pages/liveRoom/components/PaymentInput/index.vue'
import {goodStore} from "@/stores/goods/index.js"
import {message} from "~/components/x-message/useMessage.js"
import {showConfirmDialog} from 'vant';
import {artworkBuy} from "@/api/goods/index.js"
import {useI18n} from 'vue-i18n'
import floating2 from '@/components/floating2/index.vue'
import { useThrottleFn } from '@vueuse/core'
const { t } = useI18n()
const { auctionDetail,getAuctionDetail} = goodStore();
const player = ref(null)
const {quoteStatus, show, playerId, show1, auctionData, getSocketData, getLiveLink, fullLive,wsClient} = liveStore()
const pullLink = ref('')
const handlePlayerError = (error) => {
showConfirmDialog({
message: t('live_room.error_mess'),
showCancelButton: true
}).then(() => {
initializePlayer()
}).catch(() => {
})
}
const loading1=ref(false)
const initializePlayer = async () => {
try {
if (player.value) {
player.value.dispose()
}
//
const isWechat = /MicroMessenger/i.test(navigator.userAgent)
const playerConfig = {
id: playerId.value,
source: pullLink.value,
isLive: true,
preload: true,
autoplay: true, // true
muted: true, //
diagnosisButtonVisible:false,
// vodRetry:10,
// liveRetry:10,
autoplayPolicy: {
fallbackToMute: true
},
width: '100%', //
height: '100%', //
skinLayout: false,
controlBarVisibility: 'never',
license: {
domain: "szjixun.cn",
key: "OProxmWaOZ2XVHXLtf4030126521c43429403194970aa8af9"
}
}
player.value = new AliyunPlayer(playerConfig, (playerInstance) => {
//
if (isWechat) {
const startPlay = () => {
playerInstance?.play()
document.removeEventListener('WeixinJSBridgeReady', startPlay)
document.removeEventListener('touchstart', startPlay)
}
document.addEventListener('WeixinJSBridgeReady', startPlay)
document.addEventListener('touchstart', startPlay)
}
loading1.value = true
playerInstance?.play()
})
player.value.on('playing', () => {
loading1.value = false
})
player.value.on('loading', () => {
})
player.value.on('error', handlePlayerError)
} catch (error) {
showConfirmDialog({
message: t('live_room.error_mess'),
showCancelButton: true
}).then(() => {
initializePlayer()
}).catch(() => {
})
}
}
onMounted(async () => {
await getAuctionDetail()
pullLink.value = await getLiveLink()
if (auctionDetail.value.isLiving===1){
initializePlayer()
}
})
onActivated(()=>{
if (auctionDetail.value.isLiving===1){
initializePlayer()
}
})
onBeforeUnmount(() => {
player.value?.dispose()
player.value = null
})
watch(() => fullLive.value, async (newVal) => {
if (!newVal) {
wsClient.value?.disconnect()
return
}
await getSocketData()
})
const goBuy = useThrottleFn(async () => {
const res = await artworkBuy({
auctionArtworkUuid: auctionData.value?.artwork?.uuid,
buyMoney: String(auctionData?.value.nowAuctionPrice?.nowPrice??0)
})
if (res.status === 0) {
message.success(t('live_room.success_mess'))
}
}, 2000)
const sideButtonRef = useTemplateRef('sideButtonRef')
const tipOpen = () => {
message.warning(t('live_room.warn_mess'))
}
</script>
<template>
<div class="relative h-full">
<div :id="playerId" class="w-full h-full"></div>
<div v-if="loading1" class="absolute left-1/2 transform translate-x--1/2 top-1/2 translate-y--1/2">
<van-loading type="spinner" >{{ t('liveRoom.loading') }}...</van-loading>
</div>
<transition name="fade">
<div v-if="fullLive">
<floating2
:snap-edge="true"
:position="{right:0,top:300}"
>
<sideButton></sideButton>
</floating2>
<div class="absolute left-1/2 transform -translate-x-1/2 flex flex-col items-center"
style="bottom:calc(var(--safe-area-inset-bottom) + 26px)">
<div class="text-16px text-#FFB25F font-600 flex">
<div class="mr-5px">{{ t('live_room.now_price') }}{{ auctionData?.nowAuctionPrice?.currency }}</div>
<div class="min-w-50px">{{auctionData?.nowAuctionPrice?.nowPrice||0}}</div>
</div>
<div class="text-16px text-#fff font-600 flex">
<div class="mr-5px">{{ t('live_room.lower_price') }}{{ auctionData?.nowAuctionPrice?.currency }}</div>
<div class="min-w-50px">{{auctionData?.nowAuctionPrice?.nextPrice||0}}</div>
</div>
<div v-if="quoteStatus&&auctionData?.nowAuctionPrice?.nowPrice&&auctionData?.nowAuctionPrice?.nowPrice!=='0'" class="mt-10px mb-10px">
<van-button @click.stop="goBuy" color="#FFB25F" class="w-344px !h-[40px]">
<div>{{
`${t('live_room.confirm')} ${auctionData?.nowAuctionPrice?.currency} ${auctionData?.nowAuctionPrice?.nowPrice ?? 0}`
}}
</div>
</van-button>
</div>
<div v-else class="mt-10px mb-10px">
<van-button @click="tipOpen" color="#D6D6D8" class="w-344px !h-[40px]" >
<div class="text-#7D7D7F text-14px">{{ t('live_room.button') }}</div>
</van-button>
</div>
<broadcast></broadcast>
</div>
<paymentInput v-model:show="show"/>
<div>
</div>
<paymentResults v-model:show="show1" type="error"/>
<div v-if="auctionData?.wsType==='newArtwork'"
class="w-344px h-31px rounded-4px absolute top-9px bg-[#151824]/45 backdrop-blur-[10px] backdrop-saturate-[180%] left-1/2 transform translate-x--1/2 flex text-#fff text-14px items-center px-12px line-height-none">
<div class="mr-11px whitespace-nowrap">Lot{{ Number(auctionData.artwork.index+25000) }}</div>
<div class="mr-10px truncate">{{ auctionData.artwork.name }}</div>
<div class="whitespace-nowrap">{{ t('live_room.start') }}</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped lang="scss">
.draggable-card {
z-index: 999999;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: absolute;
cursor: move;
user-select: none;
transition: box-shadow 0.3s;
}
.draggable-card.is-dragging {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
/* 定义过渡动画 */
.fade-enter-active {
transition: opacity 1s ease;
}
.fade-leave-active {
transition: opacity 0.2s ease;
}
/* 定义进入和离开的状态 */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
.my-rolling-text {
--van-rolling-text-item-width: 10px;
--van-rolling-text-font-size: 16px;
--van-rolling-text-color: #FFB25F;
}
.my-rolling-text1 {
--van-rolling-text-item-width: 10px;
--van-rolling-text-font-size: 16px;
--van-rolling-text-color: #FFF;
}
:deep(.prism-license-watermark) {
display: none !important;
}
</style>

321
app/pages/login/index.vue Normal file
View File

@ -0,0 +1,321 @@
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'
import countryCode from '../countryRegion/data/index.js'
import {senCode, userLogin,userCaptcha,userCaptchaValidate,} from "@/api/auth/index.js";
import {authStore} from "@/stores/auth/index.js";
import {message} from '@/components/x-message/useMessage.js'
import {fddCheck} from "~/api/goods/index.js";
import zu6020 from '@/static/images/zu6020@2x.png'
import PuzzleComponent from '@/components/puzzleComponent/index.vue'
import { ref } from 'vue';
const {userInfo,token,selectedZone}= authStore()
const router = useRouter();
const route = useRoute();
const { locale } = useI18n()
const imgs=ref([zu6020])
definePageMeta({
name: 'login',
i18n: 'login.title'
})
const loadingRef=ref({
loading1:false,
loading2:false,
loading3:false,
})
const isExist=ref(false)// true
const isReal=ref(false) //isReal
const codeInput=ref(null)
function goToPage() {
router.push('/countryRegion');
}
const interval=ref(null)
const startCountdown=()=> {
if (interval.value){
clearInterval(interval.value);
}
countdown.value = 60;
interval.value = setInterval(() => {
if (countdown.value > 0) {
countdown.value--;
} else {
clearInterval(interval.value);
}
}, 1000);
}
const countdown = ref(0);
const phoneNum = ref('')
const code = ref('')
const pane = ref(0)
//
const getDefaultCountry = () => {
let defaultCode = 'CN' //
switch (locale.value) {
case 'zh-CN':
defaultCode = 'CN'
break
case 'zh-TW':
defaultCode = 'TW'
break
case 'ja-JP':
defaultCode = 'JP'
break
case 'en-US':
defaultCode = 'US'
break
}
const country = countryCode.find(c => c.code === defaultCode)
return {
zone: country.zone,
name: locale.value === 'zh-CN' ? country.cn :
locale.value === 'zh-TW' ? country.tw :
locale.value === 'ja-JP' ? country.ja :
country.en
}
}
const defaultCountry = getDefaultCountry()
const selectedCountry = ref('')
onMounted(()=>{
selectedZone.value=route.query.zone || defaultCountry.zone
selectedCountry.value=route.query.countryName || defaultCountry.name
})
const vanSwipeRef=ref(null)
const captcha=ref({
nonceStr: "",
blockX: 256 ,
blockWidth:50,
blockHeight:50,
canvasWidth:320,
canvasHeight:191,
place:0,
canvasSrc:'',
blockSrc:'',
blockY:0
})
const getCode =async () => {
isShow.value=true
loadingRef.value.loading1=true
const res=await userCaptcha({
canvasWidth:captcha.value.canvasWidth,
canvasHeight:captcha.value.canvasHeight,
blockWidth:captcha.value.blockWidth,
blockHeight:captcha.value.blockHeight,
place:captcha.value.place
})
if (res.status===0){
captcha.value.canvasSrc=`data:image/png;base64,${res.data.canvasSrc}`
captcha.value.blockSrc=`data:image/png;base64,${res.data.blockSrc}`
captcha.value.blockY=res.data.blockY
captcha.value.nonceStr=res.data.nonceStr
loadingRef.value.loading1=false
}
}
const goBack = () => {
code.value = ''
pane.value = 0
vanSwipeRef.value?.swipeTo(pane.value)
}
const goLogin =async () => {
loadingRef.value.loading2=true
const res=await userLogin({
telNum:phoneNum.value,
zone:selectedZone.value,
code:code.value
})
if (res.status===0){
userInfo.value=res.data.accountInfo
token.value=res.data.token
if (res.data?.accountInfo?.userExtend?.isReal===0){
await router.push({
path: '/realAuth',
query:{
statusCode:0
}
})
}else if (res.data.isJumpFdd){
const res1=await fddCheck()
if (res1.status===0){
window.location.href=res1.data.h5Url
}
}else {
await router.push('/');
}
}
loadingRef.value.loading2=false
}
const isKeyboardVisible = ref(false)
const windowHeight = ref(window.innerHeight)
const isFocused = ref(false)
onMounted(() => {
//
windowHeight.value = window.innerHeight
//
window.addEventListener('resize', () => {
//
isKeyboardVisible.value = window.innerHeight < windowHeight.value * 0.8
})
})
onUnmounted(() => {
window.removeEventListener('resize', () => {})
})
const isShow=ref(false)
const onLeave =async (moveX, callback) => {
const res=await senCode({
telNum:phoneNum.value,
zone:selectedZone.value,
verifyCaptcha:{
blockX:moveX,
nonceStr:captcha.value.nonceStr
}
})
if (res.status===408){
callback(false)
getCode()
message.warning(res.msg)
}else if ([0,407].includes(res.status)){
callback(true)
if (res.status===407){
message.warning(res.msg)
}
setTimeout(() => {
pane.value = 1
vanSwipeRef.value?.swipeTo(pane.value)
startCountdown();
isShow.value=false
}, 1000)
}else {
callback(true)
setTimeout(() => {
isShow.value=false
},1000)
}
}
</script>
<template>
<div class="w-[100vw] bg-[url('@/static/images/asdfsdd.png')] bg-bottom bg-cover grow-1 px-[31px] pt-[86px]">
<div class="w-full flex justify-center mb-[100px]">
<img class="h-[105px] w-[189px]" src="@/static/images/ghfggff.png" alt="">
</div>
<van-swipe ref="vanSwipeRef" :show-indicators="false" :touchable="false" :lazy-render="true" :loop="false">
<van-swipe-item >
<div v-if="pane===0">
<div class="">
<div class="w-full flex justify-between" @click="goToPage">
<div class="text-[16px] text-[#000]">
{{ selectedCountry }}
</div>
<div><van-icon color="#777" name="arrow" size="14" /></div>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<van-field v-model="phoneNum" clearable :placeholder="$t('login.phonePlaceholder')">
<template #label>
<div class="text-[16px] text-[#1A1A1A] flex align-center justify-start">
+{{ selectedZone }}
</div>
</template>
</van-field>
</div>
</div>
<div class="mt-[55px]">
<van-button v-if="phoneNum" :loading-text="$t('login.getCode')" color="#2B53AC" block style="height: 48px" @click="getCode">{{ $t('login.getCode') }}</van-button>
<van-button v-else type="primary" color="#D3D3D3" block style="height: 48px">{{ $t('login.getCode') }}</van-button>
</div>
</div>
</van-swipe-item>
<van-swipe-item>
<div v-if="pane===1">
<div class="flex mb-[16px]">
<div class="text-[16px] text-[#BDBDBD] mr-[10px]">{{ $t('login.hasSendTo') }}</div>
<div class="text-[16px] text-[#000]">+{{ selectedZone }} {{ phoneNum }}</div>
</div>
<div class="relative">
<van-password-input
:value="code"
:gutter="10"
:mask="false"
:focused="isFocused"
/>
<input
v-model="code"
type="tel"
maxlength="6"
ref="codeInput"
class="opacity-0 absolute top-0 left-0 h-full w-full"
@input="code = $event.target.value.replace(/\D/g, '').slice(0, 6)"
@focus="isFocused = true"
@blur="isFocused = false"
/>
</div>
<div class="flex justify-between">
<div :class="`${countdown>0?'text-#BDBDBD':'text-#2B53AC'} text-14px`">
{{ $t('login.reSend') }}<span v-if="countdown>0">({{countdown}})</span>
</div>
<div @click="goBack" class="text-#2B53AC text-14px">
{{ $t('login.back') }}
</div>
</div>
<div class="mt-[17px]">
<van-button v-if="code.length === 6" type="primary" block :loading="loadingRef.loading2" :loading-text="$t('login.login')" style="height: 48px" @click="goLogin">{{ $t('login.login') }}</van-button>
<van-button v-else type="primary" color="#D3D3D3" block style="height: 48px">{{ $t('login.login') }}</van-button>
</div>
</div>
</van-swipe-item>
</van-swipe>
<div v-if="!isKeyboardVisible" class="text-center text-14px absolute left-1/2 transform translate-x--1/2 bottom-20px">
{{ $t('login.agreement') }}<span class="text-#3454AF " @click="$router.push('/privacyPolicy')">{{ $t('login.privacyPolicy') }}</span>
</div>
<van-popup v-model:show="isShow" round style="max-width: initial" teleport="body">
<PuzzleComponent
v-if="isShow"
:loading="loadingRef.loading1"
:options="captcha"
@leave="onLeave"
/>
</van-popup>
</div>
</template>
<style scoped lang="scss">
:deep(.van-cell.van-field) {
padding-left: 0;
}
:deep(.van-password-input) {
margin: 0;
}
:deep(.van-password-input__item) {
border: 1px solid #E5E5E5;
width: 41px;
height: 41px;
}
.verify-popup-content {
width: 90vw;
max-width: 350px;
padding: 20px;
box-sizing: border-box;
}
:deep(.van-popup) {
background: transparent;
}
</style>

View File

@ -0,0 +1,363 @@
<script setup>
import { onMounted, ref } from 'vue'
const config = useRuntimeConfig()
const stripe = Stripe(config.public.NUXT_PUBLIC_PKEY)
const statusIcon = ref('')
const statusText = ref('')
const intentId = ref('')
const intentStatus = ref('')
const viewDetailsUrl = ref('')
const iconColor = ref('')
const SuccessIcon = `<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.4695 0.232963C15.8241 0.561287 15.8454 1.1149 15.5171 1.46949L6.14206 11.5945C5.97228 11.7778 5.73221 11.8799 5.48237 11.8748C5.23253 11.8698 4.99677 11.7582 4.83452 11.5681L0.459523 6.44311C0.145767 6.07557 0.18937 5.52327 0.556912 5.20951C0.924454 4.89575 1.47676 4.93936 1.79051 5.3069L5.52658 9.68343L14.233 0.280522C14.5613 -0.0740672 15.1149 -0.0953599 15.4695 0.232963Z" fill="white"/>
</svg>`
const ErrorIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.25628 1.25628C1.59799 0.914573 2.15201 0.914573 2.49372 1.25628L8 6.76256L13.5063 1.25628C13.848 0.914573 14.402 0.914573 14.7437 1.25628C15.0854 1.59799 15.0854 2.15201 14.7437 2.49372L9.23744 8L14.7437 13.5063C15.0854 13.848 15.0854 14.402 14.7437 14.7437C14.402 15.0854 13.848 15.0854 13.5063 14.7437L8 9.23744L2.49372 14.7437C2.15201 15.0854 1.59799 15.0854 1.25628 14.7437C0.914573 14.402 0.914573 13.848 1.25628 13.5063L6.76256 8L1.25628 2.49372C0.914573 2.15201 0.914573 1.59799 1.25628 1.25628Z" fill="white"/>
</svg>`
const InfoIcon = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 1.5H4C2.61929 1.5 1.5 2.61929 1.5 4V10C1.5 11.3807 2.61929 12.5 4 12.5H10C11.3807 12.5 12.5 11.3807 12.5 10V4C12.5 2.61929 11.3807 1.5 10 1.5ZM4 0C1.79086 0 0 1.79086 0 4V10C0 12.2091 1.79086 14 4 14H10C12.2091 14 14 12.2091 14 10V4C14 1.79086 12.2091 0 10 0H4Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.25 7C5.25 6.58579 5.58579 6.25 6 6.25H7.25C7.66421 6.25 8 6.58579 8 7V10.5C8 10.9142 7.66421 11.25 7.25 11.25C6.83579 11.25 6.5 10.9142 6.5 10.5V7.75H6C5.58579 7.75 5.25 7.41421 5.25 7Z" fill="white"/>
<path d="M5.75 4C5.75 3.31075 6.31075 2.75 7 2.75C7.68925 2.75 8.25 3.31075 8.25 4C8.25 4.68925 7.68925 5.25 7 5.25C6.31075 5.25 5.75 4.68925 5.75 4Z" fill="white"/>
</svg>`
function setPaymentDetails(intent) {
let status = "Something went wrong, please try again."
let color = "#DF1B41"
let icon = ErrorIcon
if (!intent) {
setErrorState()
return
}
switch (intent.status) {
case "succeeded":
status = "Payment succeeded"
color = "#30B130"
icon = SuccessIcon
break
case "processing":
status = "Your payment is processing."
color = "#6D6E78"
icon = InfoIcon
break
case "requires_payment_method":
status = "Your payment was not successful, please try again."
break
}
iconColor.value = color
statusIcon.value = icon
statusText.value = status
intentId.value = intent.id
intentStatus.value = intent.status
viewDetailsUrl.value = `https://dashboard.stripe.com/payments/${intent.id}`
}
function setErrorState() {
iconColor.value = "#DF1B41"
statusIcon.value = ErrorIcon
statusText.value = "Something went wrong, please try again."
}
async function checkStatus() {
const urlParams = new URLSearchParams(window.location.search)
const clientSecret = urlParams.get("payment_intent_client_secret")
if (!clientSecret) {
setErrorState()
return
}
const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret)
setPaymentDetails(paymentIntent)
router.push({
path:'/payment/result',
query:{
orderNo:route.query.orderNo
}
})
}
const router=useRouter()
const route=useRoute()
onMounted(() => {
checkStatus()
})
</script>
<template>
<div id="payment-status">
<div id="status-icon" :style="{ backgroundColor: iconColor }" v-html="statusIcon"></div>
<h2 id="status-text">{{ statusText }}</h2>
<div id="details-table">
<table>
<tbody>
<tr>
<td class="TableLabel">id</td>
<td id="intent-id" class="TableContent">{{ intentId }}</td>
</tr>
<tr>
<td class="TableLabel">status</td>
<td id="intent-status" class="TableContent">{{ intentStatus }}</td>
</tr>
</tbody>
</table>
</div>
<a :href="viewDetailsUrl" id="view-details" rel="noopener noreferrer" target="_blank">
View details
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.125 3.49998C2.64175 3.49998 2.25 3.89173 2.25 4.37498V11.375C2.25 11.8582 2.64175 12.25 3.125 12.25H10.125C10.6082 12.25 11 11.8582 11 11.375V9.62498C11 9.14173 11.3918 8.74998 11.875 8.74998C12.3582 8.74998 12.75 9.14173 12.75 9.62498V11.375C12.75 12.8247 11.5747 14 10.125 14H3.125C1.67525 14 0.5 12.8247 0.5 11.375V4.37498C0.5 2.92524 1.67525 1.74998 3.125 1.74998H4.875C5.35825 1.74998 5.75 2.14173 5.75 2.62498C5.75 3.10823 5.35825 3.49998 4.875 3.49998H3.125Z" fill="#0055DE"/>
<path d="M8.66672 0C8.18347 0 7.79172 0.391751 7.79172 0.875C7.79172 1.35825 8.18347 1.75 8.66672 1.75H11.5126L4.83967 8.42295C4.49796 8.76466 4.49796 9.31868 4.83967 9.66039C5.18138 10.0021 5.7354 10.0021 6.07711 9.66039L12.7501 2.98744V5.83333C12.7501 6.31658 13.1418 6.70833 13.6251 6.70833C14.1083 6.70833 14.5001 6.31658 14.5001 5.83333V0.875C14.5001 0.391751 14.1083 0 13.6251 0H8.66672Z" fill="#0055DE"/>
</svg>
</a>
<NuxtLink id="retry-button" to="/checkout">Test another</NuxtLink>
</div>
</template>
<style scoped>
/* Variables */
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 16px;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
height: 100vh;
width: 100vw;
}
form {
width: 30vw;
min-width: 500px;
align-self: center;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
padding: 40px;
margin-top: auto;
margin-bottom: auto;
}
.hidden {
display: none;
}
#payment-message {
color: rgb(105, 115, 134);
font-size: 16px;
line-height: 20px;
padding-top: 12px;
text-align: center;
}
#payment-element {
margin-bottom: 24px;
}
/* Buttons and links */
button {
background: #0055DE;
font-family: Arial, sans-serif;
color: #ffffff;
border-radius: 4px;
border: 0;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: block;
transition: all 0.2s ease;
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
width: 100%;
}
button:hover {
filter: contrast(115%);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
/* spinner/processing state, errors */
.spinner,
.spinner:before,
.spinner:after {
border-radius: 50%;
}
.spinner {
color: #ffffff;
font-size: 22px;
text-indent: -99999px;
margin: 0px auto;
position: relative;
width: 20px;
height: 20px;
box-shadow: inset 0 0 0 2px;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
.spinner:before,
.spinner:after {
position: absolute;
content: "";
}
.spinner:before {
width: 10.4px;
height: 20.4px;
background: #0055DE;
border-radius: 20.4px 0 0 20.4px;
top: -0.2px;
left: -0.2px;
-webkit-transform-origin: 10.4px 10.2px;
transform-origin: 10.4px 10.2px;
-webkit-animation: loading 2s infinite ease 1.5s;
animation: loading 2s infinite ease 1.5s;
}
.spinner:after {
width: 10.4px;
height: 10.2px;
background: #0055DE;
border-radius: 0 10.2px 10.2px 0;
top: -0.1px;
left: 10.2px;
-webkit-transform-origin: 0px 10.2px;
transform-origin: 0px 10.2px;
-webkit-animation: loading 2s infinite ease;
animation: loading 2s infinite ease;
}
/* Payment status page */
#payment-status {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
row-gap: 30px;
width: 30vw;
min-width: 500px;
min-height: 380px;
align-self: center;
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
border-radius: 7px;
padding: 40px;
opacity: 0;
animation: fadeInAnimation 1s ease forwards;
}
#status-icon {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 40px;
border-radius: 50%;
}
h2 {
margin: 0;
color: #30313D;
text-align: center;
}
a {
text-decoration: none;
font-size: 16px;
font-weight: 600;
font-family: Arial, sans-serif;
display: block;
}
a:hover {
filter: contrast(120%);
}
#details-table {
overflow-x: auto;
width: 100%;
}
table {
width: 100%;
font-size: 14px;
border-collapse: collapse;
}
table tbody tr:first-child td {
border-top: 1px solid #E6E6E6; /* Top border */
padding-top: 10px;
}
table tbody tr:last-child td {
border-bottom: 1px solid #E6E6E6; /* Bottom border */
}
td {
padding-bottom: 10px;
}
.TableContent {
text-align: right;
color: #6D6E78;
}
.TableLabel {
font-weight: 600;
color: #30313D;
}
#view-details {
color: #0055DE;
}
#retry-button {
text-align: center;
background: #0055DE;
color: #ffffff;
border-radius: 4px;
border: 0;
padding: 12px 16px;
transition: all 0.2s ease;
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
width: 100%;
}
@-webkit-keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loading {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes fadeInAnimation {
to {
opacity: 1;
}
}
@media only screen and (max-width: 600px) {
form, #payment-status{
width: 80vw;
min-width: initial;
}
}
</style>

15
app/pages/payment/external/index.vue vendored Normal file
View File

@ -0,0 +1,15 @@
<script setup>
</script>
<template>
<div>
<iframe>
</iframe>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,97 @@
<script setup>
import {liveStore} from "~/stores/live/index.js";
import {createBuyOrder} from "~/api/goods/index.js";
import {goodStore} from "~/stores/goods/index.js";
import { showLoadingToast ,closeToast} from 'vant';
import {authStore} from "~/stores/auth/index.js";
import {message} from "~/components/x-message/useMessage.js";
const {checkoutSessionUrl,payment,payUid}= authStore()
const payStatus=ref(0)
definePageMeta({
i18n: 'payment.title'
})
const {t}=useI18n()
const router=useRouter()
const changePayStatus=()=>{
payStatus.value=payStatus.value===0?1:0
}
const { auctionData} = liveStore()
const amount=ref('')
const confirmPay=async ()=>{
if (payStatus.value===1&&!amount.value){
message.warning(t('payment.amountRequired'))
return
}
if (Number(payment.value.leftPrice)<Number(amount.value)){
message.warning(t('payment.exceedAmount'))
return
}
showLoadingToast({
message: t('payment.loading'),
forbidClick: true,
});
const res=await createBuyOrder({
buyUid:payment.value.buyUid,
price:payStatus.value===0?payment.value.leftPrice:amount.value,
currency:payment.value.leftCurrency,
testReturnHost:window.location.origin,
testReturnEndPoint:'/payment/result'
})
if (res.status===0){
checkoutSessionUrl.value=res.data.checkoutSessionUrl
payUid.value=res.data.payUid
router.push({
path:'/checkoutPage',
query:{
payUid:res.data.payUid,
returnUrl:'/payment/result',
stripeKey:res.data.checkoutSessionUrl
}
})
}
}
const handleInput = (e) => {
//
const value = e.target.value
//
let newValue = value.replace(/[^\d.]/g, '')
//
newValue = newValue.replace(/\.{2,}/g, '.')
//
newValue = newValue.replace(/^(\d*\.\d*)\./, '$1')
//
if (newValue.indexOf('.') > 0) {
newValue = newValue.slice(0, newValue.indexOf('.') + 3)
}
// 0
newValue = newValue.replace(/^0+(\d)/, '$1')
amount.value = newValue
}
</script>
<template>
<div class="w-[100vw] h-screen-nav bg-[url('@/static/images/3532@2x.png')] bg-cover flex-grow-1 flex flex-col items-center pt-183px px-30px">
<div class="mb-30px">
<img class="w-126px h-126px" src="@/static/images/dddf34@2x.png" alt="">
</div>
<div class="text-#1A1A1A text-16px mb-25px font-bold">{{payStatus===0 ? t('payment.fullPayment') : t('payment.partialPayment')}}</div>
<div class="text-#999999 text-16px mb-24px font-bold" v-if="payStatus===0">CNY {{payment?.leftPrice}}</div>
<div class="mb-12px" v-else>
<input v-model="amount" class="w-272px h-48px bg-#F3F3F3 px-11px text-16px" type="text" :placeholder="`${t('payment.placeholder.amount')}CNY${payment?.leftPrice}`" @input="handleInput">
</div>
<div class="text-#2B53AC text-14px" @click="changePayStatus">{{payStatus===1 ? t('payment.fullPayment') : t('payment.partialPayment')}}</div>
<div class="w-full mt-auto mb-40px">
<van-button type="primary" block @click="confirmPay">
{{ t('payment.confirm') }}
</van-button>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,95 @@
<script setup>
import {orderQuery} from "~/api/goods/index.js";
import { showLoadingToast, closeToast } from 'vant';
definePageMeta({
i18n: 'payment.text1',
})
const router = useRouter()
const {t} = useI18n();
const route = useRoute();
const resData = ref({})
let timer = null
let startTime = Date.now()
const queryOrder = async () => {
// 5
if (Date.now() - startTime > 5000) {
clearInterval(timer)
closeToast()
return
}
showLoadingToast({
message:t('common.loading'),
forbidClick: true,
});
try {
const res = await orderQuery({
orderNo: route.query.orderNo
})
if (res.status === 0) {
resData.value = res.data
//
if (resData.value.status === 1) {
clearInterval(timer)
closeToast()
}
}
} catch (error) {
clearInterval(timer)
closeToast()
}
}
//
await queryOrder()
//
timer = setInterval(async () => {
await queryOrder()
}, 1000)
//
onUnmounted(() => {
if (timer) {
clearInterval(timer)
closeToast()
}
})
const statusLabel = {
1: t('payment.text2'),
2: t('payment.text3'),
3: t('payment.text4'),
4: t('payment.text5'),
}
const goHome = () => {
router.push('/')
}
</script>
<template>
<div
class="w-[100vw] h-screen-nav bg-[url('@/static/images/3532@2x.png')] bg-cover grow-1 flex flex-col items-center px-30px">
<div class="flex flex-col items-center mt-150px">
<img v-if="resData.status===1" class="w-119px h-120px mb-36px" src="@/static/images/5554@2x1.png" alt="">
<img v-else class="w-119px h-120px mb-36px" src="@/static/images/zu6021@2x.png" alt="">
<div class="text-#000 text-16px mb-25px">{{ statusLabel[resData.status] }}!</div>
<div class="text-#999 text-16px">{{ resData.currency }}{{ resData.money }}</div>
</div>
<div class="w-full mt-auto mb-40px">
<van-button type="primary" block @click="goHome">
{{ t('payment.result.backToHome') }}
</van-button>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
definePageMeta({
i18n: 'login.privacyPolicy'
})
</script>
<template>
<div class="px-10px pt-20px pb-60px"><iframe class="w-full h-100vh" src="/privacyPolicy.html"></iframe></div>
</template>
<style scoped>
</style>

172
app/pages/profile/index.vue Normal file
View File

@ -0,0 +1,172 @@
<script setup>
import { userArtworks } from "@/api/goods/index.js"
import { authStore } from "@/stores/auth/index.js"
import xImage from '@/components/x-image/index.vue'
import { ref } from "vue"
definePageMeta({
layout: 'default',
i18n: 'menu.profile',
})
const {t}=useI18n();
const router = useRouter()
const { userInfo,payment } = authStore()
const showMyList = ref([])
const localState = ref({
finished: true,
refreshing: false
})
const groupByDate = (data) => {
if (!Array.isArray(data)) return []
return Object.values(data.reduce((acc, curr) => {
const date = curr.userCreatedAt
if (!acc[date]) {
acc[date] = { userCreatedAt: date, list: [] }
}
acc[date].list.push(curr)
return acc
}, {})).sort((a, b) => new Date(b.userCreatedAt) - new Date(a.userCreatedAt))
}
const fetchData = async () => {
try {
const res = await userArtworks({})
if (res.status === 0) {
showMyList.value = groupByDate(res.data.data)
}
} catch (error) {
}
}
const onRefresh = async () => {
localState.value.refreshing = true
await fetchData()
localState.value.refreshing = false
}
const goPay = (item) => {
payment.value.leftPrice=item.leftCnyPrice
payment.value.leftCurrency=item.leftCurrency
payment.value.buyUid=item.uuid
payment.value.auctionArtworkUuid=item?.auctionArtworkUuid
if (item.status===1){
router.push('/signature/protocol')
}else if (item.status===4){
router.push('/payment')
}
}
const goDetail = (item) => router.push({ path: '/artDetail', query: { uuid: item.uuid } })
const statusLabel={
1:t('payment.text4'),
2:t('payment.text2'),
4:t('payment.text6'),
}
fetchData()
</script>
<template>
<div class="w-[100vw] bg-[url('@/static/images/3532@2x.png')] bg-cover pt-43px flex-grow-1 flex flex-col">
<!-- 用户信息 -->
<div class="flex items-center px-16px mb-43px">
<img class="w-57px h-57px mr-23px" src="@/static/images/5514@2x.png" alt="">
<div class="flex flex-col">
<div class="text-18px text-#181818">{{ userInfo.realName }}</div>
<div class="text-#575757 text-14px">{{ userInfo.telNum }}</div>
</div>
</div>
<!-- 设置选项 -->
<div class="px-16px mb-20px">
<van-cell-group inset>
<!-- 移除语言设置入口 -->
</van-cell-group>
</div>
<!-- 列表内容 -->
<div class="grow-1 flex flex-col">
<div class="border-b-1px border-b-#D3D3D3 px-16px">
<div class="text-#000 text-16px border-b-3 border-b-#2B53AC w-80px h-36px">{{ $t('home.my_lots') }}</div>
</div>
<van-pull-refresh
v-model="localState.refreshing"
:success-duration="700"
class="h-full grow-1"
@refresh="onRefresh"
>
<template #success>
<van-icon name="success" /> <span>{{ $t('home.refresh_show') }}</span>
</template>
<van-list :finished="localState.finished" :finished-text="$t('home.finished_text')" class="h-full">
<!-- 空状态 -->
<div v-if="showMyList?.length < 1" class="flex flex-col items-center pt-100px">
<img class="w-103px h-88px mb-19px" src="@/static/images/zu5512@2x.png" alt="">
<div class="text-14px text-#575757">{{$t('profile.text1')}}</div>
<div class="text-14px text-#575757">{{$t('profile.text2')}}</div>
</div>
<!-- 列表内容 -->
<template v-else>
<div v-for="group in showMyList" :key="group.userCreatedAt" class="px-16px pt-14px">
<div class="text-#575757 text-14px mb-3px">{{ group.userCreatedAt }}</div>
<div
v-for="item in group.list"
:key="item.uuid"
class="flex mb-22px"
@click="goDetail(item)"
>
<x-image
class="w-80px h-80px flex-shrink-0 mr-10px rounded-4px overflow-hidden"
:src="item?.auctionArtworkInfo?.artwork?.hdPic"
:preview="false"
/>
<div class="flex flex-col justify-between grow-1">
<div class="flex justify-between">
<div class="text-#000 text-16px ellipsis line-height-21px">
{{ item?.auctionArtworkInfo?.artworkTitle }}
</div>
<div class="text-14px text-right text-#3C55B2 ">
{{statusLabel[item.status]}}
</div>
</div>
<div class="flex justify-between">
<div>
<div class="text-#575757 text-14px line-height-none mb-5px">
{{ $t('home.start_price') }}{{item.auctionArtworkInfo?.startPriceCurrency}} {{item.auctionArtworkInfo?.startPrice}}
</div>
<div class="text-#B58047 text-14px line-height-none">
{{ $t('home.close_price') }}{{item.baseCurrency}} {{item.baseMoney}}
</div>
</div>
<van-button
v-if="[1,3,4].includes(item.status)"
class="w-73px !h-30px"
type="primary"
@click.stop="goPay(item)"
>
<span class="text-12px">{{ $t('art_detail_page.button') }}</span>
</van-button>
</div>
</div>
</div>
</div>
</template>
</van-list>
</van-pull-refresh>
</div>
</div>
</template>
<style scoped>
.ellipsis {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,93 @@
<script setup>
import {publicStore} from "@/stores/public/index.js";
import {useI18n} from 'vue-i18n'
import {outBuyList} from "@/api-public/public/index.js";
import { onUnmounted } from 'vue'
const {auctionData} = publicStore()
function formatThousands(num) {
return Number(num).toLocaleString();
}
const headList=[
{
label:useI18n().t('live_room.head'),
color:'#D03050',
value:'head'
},
{
label:useI18n().t('live_room.out'),
color:'#939393',
value:'out'
},
{
label:useI18n().t('live_room.success'),
color:'#34B633',
value:'success'
}
]
const buyList=ref([])
const timer = ref(null)
const headItem=(statusCode)=>{
return headList.find(x=>x.value===statusCode)
}
const fetchBuyList = async () => {
const res = await outBuyList({uuid: auctionData.value.uuid})
buyList.value = res.data.buys
}
onMounted(async()=>{
await fetchBuyList()
timer.value = setInterval(async () => {
await fetchBuyList()
}, 5000)
})
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
</script>
<template>
<div
id="list-container"
class="w-344px h-86px overflow-y-auto bg-#fff rounded-4px text-14px text-#939393 pt-7px pb-7px px-11px flex flex-col justify-between"
>
<transition-group name="list" tag="div">
<template v-if="buyList?.length>0">
<div v-for="(item, index) in buyList" :key="index" class="flex flex-shrink-0">
<div class="text-start shrink-0 w-1/6 break-words" :style="`color: ${headItem(item.statusCode).color}`">
{{ headItem(item.statusCode).label }}
</div>
<div class="text-start shrink-0 w-[28%] break-words">
{{ item.auctionType==='local'? $t('live_room.spot'):$t('live_room.network') }}
</div>
<div class="text-start shrink-0 w-[28%] break-words">
{{ item.createdAt }}
</div>
<div class="text-start shrink-0 w-[28%] break-words">
{{item.baseCurrency}}{{ formatThousands(item.baseMoney) }}
</div>
</div>
</template>
</transition-group>
</div>
</template>
<style scoped>
.list-enter-active, .list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from, .list-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style>

View File

@ -0,0 +1,126 @@
<script setup>
import { defaultDetail,getLink } from '@/api-public/public/index.js'
import { liveStore } from '@/stores/live/index.js'
import AliyunPlayer from 'aliyun-aliplayer'
import { publicStore } from '@/stores/public/index.js'
import 'aliyun-aliplayer/build/skins/default/aliplayer-min.css'
import broadcast from './components/broadcast/index.vue'
const {decryptUtils} = liveStore()
const {auctionData} = publicStore()
definePageMeta({
layout: 'publicLiveRoom'
})
const {t}= useI18n()
const pullLink = ref('')
const player = ref(null)
const loading1=ref(false)
const handlePlayerError = (error) => {
showConfirmDialog({
message: t('live_room.error_mess'),
showCancelButton: true
}).then(() => {
initializePlayer()
}).catch(() => {
})
}
const initializePlayer = async () => {
try {
if (player.value) {
player.value.dispose()
}
//
const isWechat = /MicroMessenger/i.test(navigator.userAgent)
const playerConfig = {
id: 'J_prismPlayer1',
source: pullLink.value,
isLive: true,
preload: true,
autoplay: true, // true
muted: true, //
diagnosisButtonVisible:false,
// vodRetry:10,
// liveRetry:10,
autoplayPolicy: {
fallbackToMute: true
},
width: '100%', //
height: '100%', //
skinLayout: false,
controlBarVisibility: 'never',
env: 'SEA' ,
license: {
domain: "szjixun.cn",
key: "OProxmWaOZ2XVHXLtf4030126521c43429403194970aa8af9"
}
}
player.value = new AliyunPlayer(playerConfig, (playerInstance) => {
//
if (isWechat) {
const startPlay = () => {
playerInstance?.play()
document.removeEventListener('WeixinJSBridgeReady', startPlay)
document.removeEventListener('touchstart', startPlay)
}
document.addEventListener('WeixinJSBridgeReady', startPlay)
document.addEventListener('touchstart', startPlay)
}
loading1.value = true
playerInstance?.play()
})
player.value.on('playing', () => {
loading1.value = false
})
player.value.on('loading', () => {
})
player.value.on('error', handlePlayerError)
} catch (error) {
console.log('error',error)
showConfirmDialog({
message: t('live_room.error_mess'),
showCancelButton: true
}).then(() => {
initializePlayer()
}).catch(() => {
})
}
}
onMounted(async () => {
const res = await defaultDetail({})
if(res.status === 0){
auctionData.value = res.data
}
const linkRes = await getLink({uuid:auctionData.value.uuid})
pullLink.value =decryptUtils.decryptData(linkRes.data.code)
initializePlayer()
})
</script>
<template>
<div class="grow-1 relative">
<van-nav-bar :title="auctionData.title" />
<div id="J_prismPlayer1" class="w-100vw" style="height: calc(100vh - var(--van-nav-bar-height));"></div>
<div v-if="loading1" class="absolute left-1/2 transform translate-x--1/2 top-1/2 translate-y--1/2">
<van-loading type="spinner" >直播加载中...</van-loading>
</div>
<div class="absolute left-1/2 transform -translate-x-1/2" style="bottom:calc(var(--safe-area-inset-bottom) + 46px)">
<broadcast></broadcast>
</div>
</div>
</template>
<style scoped lang="scss">
:deep(.prism-license-watermark) {
display: none !important;
}
</style>

View File

@ -0,0 +1,79 @@
<script setup>
import {authStore} from "@/stores/auth/index.js";
import {useI18n} from 'vue-i18n'
const {t} = useI18n()
const props = defineProps({
type: {
type: Number,
default: 0
}
})
const columns1 = ref([
{text: t('realAuth.idCard'), value: '1'},
{text: t('realAuth.passport'), value: '2'},
{text: t('realAuth.other'), value: '3'},
])
const {userInfo}= authStore()
</script>
<template>
<div class="text-#1A1A1A text-16px">
<template v-if="type===0">
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.name')}}</div>
<div>{{userInfo.realName}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.gender')}}</div>
<div>{{userInfo.sex===1?$t('realAuth.male'):$t('realAuth.female')}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.birthday')}}</div>
<div>{{userInfo.birthDate}}</div>
</div>
<div class="flex">
<div class="mr-10px">{{$t('realAuth.idCard')}}</div>
<div>{{userInfo.idNum}}</div>
</div>
</template>
<template v-if="type===1">
<div class="flex mb-20px" >
<div class="mr-10px">{{$t('realAuth.name')}}</div>
<div>{{userInfo.realName||userInfo.userExtend.realName||''}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.gender')}}</div>
<div>{{userInfo.sex===1?$t('realAuth.male'):$t('realAuth.female')}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.birthday')}}</div>
<div>{{userInfo.birthDate}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.adress')}}</div>
<div>{{userInfo.userExtend.address}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.bank')}}</div>
<div>{{userInfo.userExtend.bankName}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.bankCard')}}</div>
<div>{{userInfo.userExtend.bankNo}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.idTye')}}</div>
<div>{{columns1.find(x=>x.value===userInfo.userExtend.idType)?.text}}</div>
</div>
<div class="flex mb-20px">
<div class="mr-10px">{{$t('realAuth.idNumber')}}</div>
<div>{{userInfo.userExtend.idNo}}</div>
</div>
</template>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,172 @@
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'
import {userUpdate} from "@/api/auth/index.js";
import {message} from '@/components/x-message/useMessage.js'
import detail from './components/detail.vue'
import {authStore} from "@/stores/auth/index.js";
import XVanDate from '@/components/x-van-date/index.vue'
import XVanSelect from '@/components/x-van-select/index.vue'
import {fddCheck} from "~/api/goods/index.js";
definePageMeta({
i18n: 'realAuth.title',
})
const router = useRouter();
const route = useRoute();
const { locale } = useI18n()
const {userInfo,selectedZone}= authStore()
const active=ref(locale.value==='zh-CN'?0:1)
const { t } = useI18n()
const columns1 = ref([
{text: t('realAuth.passport'), value: '2'},
{text: t('realAuth.other'), value: '3'},
])
const form=ref({
realName: "",
sex:'',
birthDate:'',
userExtend: {
address: "",
bankName: "",
bankNo: ""
}
})
const form1=ref({
idNum:'',
realName:'',
userExtend:{}
})
const columns=ref([
{ text: t('realAuth.male'), value: 1 },
{ text: t('realAuth.female'), value: 2 },
])
function isFormComplete(obj) {
for (const key in obj) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
if (!isFormComplete(obj[key])) {
return false;
}
} else if (obj[key] === "") {
return false;
}
}
return true;
}
const statusCode=ref(Number(route.query.statusCode))
const confirm=async ()=>{
const thatForm=active.value===0?form1.value:form.value
thatForm.userExtend.isMainland=active.value===0?1:0
if (isFormComplete(thatForm)){
const res=await userUpdate(thatForm)
if (res.status===0){
userInfo.value=res.data
message.success(t('realAuth.success_mess'))
//
if (active.value===0){
const res1=await fddCheck()
if (res1.status===0){
if (res1.data.isNeedJump){
window.location.href=res1.data.h5Url
}
}
}else {
statusCode.value=1
}
}
}else {
message.error(t('realAuth.cnTabDesc'))
}
}
const goHome=()=>{
router.push('/')
}
const goLogin=()=>{
router.back()
}
</script>
<template>
<div class="px-[31px] bg-[url('@/static/images/asdfsdd.png')] bg-cover w-100vw flex-grow-1 pt-[46px] relative flex flex-col">
<van-tabs v-if="statusCode===0" v-model:active="active" animated swipeable>
<van-tab :title="$t('realAuth.cnTab')" class="pt-[80px]">
<template v-if="statusCode===0">
<div class="text-[#BDBDBD] text-[16px] mb-[34px]">{{ $t('realAuth.cnTabDesc') }}</div>
<div class="mb-[100px]">
<div class="border-b-[1.7px] mt-[8px]">
<van-field v-model="form1.idNum" :label="$t('realAuth.idCard')" clearable
:placeholder="$t('realAuth.idCardPlaceholder')"></van-field>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<van-field v-model="form1.realName" :label="$t('realAuth.name')" clearable :placeholder="$t('realAuth.namePlaceholder')"></van-field>
</div>
</div>
</template>
</van-tab>
<van-tab :title="$t('realAuth.otherTab')" class="pt-[80px]">
<div class="text-[#BDBDBD] text-[16px] mb-[34px]">{{ $t('realAuth.otherTabDesc') }}</div>
<div class="mb-[100px]">
<div class="border-b-[1.7px] mt-[8px]">
<van-field v-model="form.realName" :label="$t('realAuth.name')" clearable :placeholder="$t('realAuth.namePlaceholder')"></van-field>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<x-van-select v-model="form.sex" :placeholder="$t('realAuth.text1')" :label="$t('realAuth.gender')" :columns="columns"/>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<x-van-date v-model="form.birthDate" :label="$t('realAuth.birthday')" :placeholder="$t('realAuth.birthdayPlaceholder')"/>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<van-field v-model="form.userExtend.address" :label="$t('realAuth.adress')" clearable
:placeholder="$t('realAuth.adressPlaceholder')"></van-field>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<van-field v-model="form.userExtend.bankName" :label="$t('realAuth.bank')" clearable :placeholder="$t('realAuth.bankPlaceholder')"></van-field>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<van-field v-model="form.userExtend.bankNo" :label="$t('realAuth.bankCard')" clearable
:placeholder="$t('realAuth.bankCardPlaceholder')"></van-field>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<x-van-select v-model="form.userExtend.idType" :label="$t('realAuth.idTye')" :columns="columns1"/>
</div>
<div class="border-b-[1.7px] mt-[8px]">
<van-field :label="$t('realAuth.idNumber')" v-model="form.userExtend.idNo" class="mb-10px" :placeholder="$t('realAuth.idNumberPlaceholder')"/>
</div>
</div>
</van-tab>
</van-tabs>
<van-tabs v-else-if="statusCode===1" v-model:active="active" animated swipeable>
<van-tab :title="$t('realAuth.cnTab')" class="pt-[80px]">
<detail :type="active"></detail>
</van-tab>
<van-tab :title="$t('realAuth.otherTab')" class="pt-[80px]">
<detail :type="active"></detail>
</van-tab>
</van-tabs>
<div class="flex justify-between shrink-0 mb-20px" v-if="statusCode===0">
<van-button style="width: 151px;height: 48px" color="#E9F1F8" @click="goLogin">
<div class="text-#2B53AC text-16px">{{ $t('realAuth.cancel') }}</div>
</van-button>
<van-button @click="confirm" style="width: 151px;height: 48px" color="#2B53AC">
<div class="text-#FFFFFF text-16px">{{ $t('realAuth.confirm') }}</div>
</van-button>
</div>
<div v-else class="mt-auto pb-94px">
<van-button color="#E9F1F8" @click="goHome" style="color: #2B53AC;font-weight: 600" block>{{ $t('home.go_home')}}</van-button>
</div>
</div>
</template>
<style scoped>
:deep(.van-tabs__line) {
height: 2px;
width: 107px;
}
:deep(.van-cell) {
padding-left: 0;
}
</style>

View File

@ -0,0 +1,118 @@
<script setup>
import {showToast,showLoadingToast } from 'vant';
import {onMounted, onUnmounted, ref} from 'vue';
import {signOffline, signOnline} from "~/api/goods/index.js";
import {VueSignaturePad} from "vue-signature-pad";
import {authStore} from "~/stores/auth/index.js";
import {useI18n} from "vue-i18n";
const router = useRouter();
const {t:$t} = useI18n()
definePageMeta({
layout: ''
})
const { payment} = authStore()
const signaturePad = ref(null);
const imgUrl = ref('')
const clearSignature = () => {
signaturePad.value?.clearSignature();
};
const toast=ref(false)
const submitSignature = () => {
if (signaturePad.value?.isEmpty()) {
showToast($t('collectCode.signature.pleaseSign'));
return;
}
toast.value=showLoadingToast({
message: $t('common.loading'),
forbidClick: true,
});
const { data } = signaturePad.value?.saveSignature(); // base64
imgUrl.value = data;
confirm()
};
const confirm = async () => {
const res = await signOnline({
auctionArtworkUuid:payment.value.auctionArtworkUuid,
signImgFileData: imgUrl.value
})
if (res.status===0){
await router.push('/payment')
toast.value?.close()
}
}
const goBack = () => {
router.back()
}
</script>
<template>
<div class="signature-container">
<div class="flex flex-col h-100vh px-20px py-20px bg-gray w-100vw">
<client-only>
<VueSignaturePad
width="100%"
height="93%"
class="signature bg-#fff rounded-10px mb-10px"
ref="signaturePad"
/>
</client-only>
<div class="flex justify-evenly">
<van-button class="!h-40px mr-15px" type="primary" @click="goBack">
{{ $t('collectCode.signature.back') }}
</van-button>
<van-button class="!h-40px" type="warning" @click="clearSignature">
{{ $t('collectCode.signature.clear') }}
</van-button>
<van-button class="!h-40px" type="primary" @click="submitSignature">
{{ $t('collectCode.signature.confirm') }}
</van-button>
</div>
</div>
</div>
</template>
<style scoped>
.signature-container {
position: fixed;
inset: 0;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) 0;
}
:deep(.van-button--mini+.van-button--mini) {
margin: 0;
}
:deep(.van-dialog__content) {
display: flex;
justify-content: center;
}
.signature-content {
display: flex;
height: 100%;
}
.control-buttons {
display: flex;
flex-direction: column;
padding: 0 10px 0;
gap: 10px;
}
.control-button {
width: 40px;
}
.orientation-hint {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
font-size: 18px;
}
</style>

View File

@ -0,0 +1,64 @@
<script setup>
import {useI18n} from "vue-i18n";
import XVanSelect from '@/components/x-van-select/index.vue'
import XVanDate from '@/components/x-van-date/index.vue'
definePageMeta({
name: 'personal-info',
})
const {t} = useI18n()
const {t:$t} = useI18n()
const showPicker = ref(false)
const showPicker1 = ref(false)
const onConfirm = () => {
}
const router = useRouter()
const columns = ref([
{text: t('realAuth.male'), value: 1},
{text: t('realAuth.female'), value: 2},
])
const goCountryRegion=()=>{
router.push({
path:'/countryRegion'
})
}
const adress=ref('')
</script>
<template>
<div
class="w-[100vw] bg-[url('@/static/images/asdfsdd.png')] h-screen-nav bg-cover pt-77px flex-grow-1 flex flex-col ">
<div class="text-16px text-#191919 font-bold mb-40px px-34px">
{{$t('personal.title')}}
</div>
<div class="grow-1 px-34px">
<van-field type="tel" :label-width="161" :label="$t('personal.text')" class="mb-10px" :placeholder="$t('login.phonePlaceholder')">
<template #label>
<div class="flex">
<div class="mr-41px whitespace-nowrap">{{$t('profile.phone')}}</div>
<div @click="goCountryRegion">
<span class="mr-13px">+ 86</span>
<van-icon name="arrow-down" class="text-#777777"/>
</div>
</div>
</template>
</van-field>
<van-field :label="$t('profile.name')" class="mb-10px" :placeholder="$t('realAuth.namePlaceholder')"/>
<x-van-select :label="$t('realAuth.gender')" :columns="columns"/>
<x-van-date :label="$t('realAuth.birthday')"/>
<van-field v-model="adress" :label="$t('realAuth.adress')" class="mb-10px" :placeholder="$t('realAuth.adressPlaceholder')"/>
<van-field :label="$t('realAuth.bank')" class="mb-10px" :placeholder="$t('realAuth.bankPlaceholder')"/>
<van-field :label="$t('realAuth.bankCard')" class="mb-10px" :placeholder="$t('realAuth.bankCardPlaceholder')"/>
</div>
<div class="h-81px bg-#fff flex justify-center pt-7px border-t">
<van-button color="#2B53AC" class="w-213px van-btn-h-38px">{{$t('personal.next')}}</van-button>
</div>
</div>
</template>
<style scoped lang="scss">
:deep(.van-cell.van-field){
padding-left: 0;
}
</style>

View File

@ -0,0 +1,128 @@
<script setup>
import pdfView from './pdfView'
import { contractView } from "~/api/goods/index.js"
import { signOnline } from "~/api/goods/index.js"
import { authStore } from "~/stores/auth/index.js"
import {useI18n} from "vue-i18n";
import { useThrottleFn } from '@vueuse/core'
import { contractUserinfo } from "@/api/auth/index.js"
definePageMeta({
layout: 'default',
i18n: 'signature.protocol.title'
})
const { userInfo, payment } = authStore()
const $t=useI18n().t
const activeNames = ref([])
const router = useRouter()
const pmblUrl = ref('') // URL
//
const protocolList = computed(() => [
{ id: '1', title: $t('signature.agreement.notice'), pdfName: 'pmgg', type: 'local' },
{ id: '2', title: $t('signature.agreement.rules'), pdfName: 'pmgz', type: 'local' },
{ id: '3', title: $t('signature.agreement.buyerGuide'), pdfName: 'jmxz', type: 'local' },
{ id: '4', title: $t('signature.agreement.buyerAgreement'), pdfName: 'jmxy', type: 'local' },
{ id: '5', title: $t('signature.agreement.record'), pdfName: pmblUrl.value, type: 'remote' },
{ id: '6', title: $t('signature.agreement.transfer'), pdfName: 'pmyjqrs', type: 'local' }
])
// PDF
const fetchPmblPdf = async () => {
try {
const res = await contractView({
auctionArtworkUuid: payment.value.auctionArtworkUuid,
})
pmblUrl.value = res.data?.viewUrl // PDF URLdata
} catch (error) {
}
}
//
const handleCollapseChange = (name) => {
activeNames.value = name
// PDF
if (name === '5' && !pmblUrl.value) {
fetchPmblPdf()
}
}
const goSignature =useThrottleFn(async () => {
//
const res1 = await contractUserinfo({phone:userInfo.value.telNum})
if(res1.status===0){
//
if(res1.data.specialType===1){
router.push({
path: '/signature/panel'
})
}else if(res1.data.specialType===0){
//
if(userInfo.value.userExtend.isMainland===1){
const res = await signOnline({
auctionArtworkUuid:payment.value.auctionArtworkUuid
})
if (res.status===0){
if(res.data.signType==='fdd'){
window.location.href = res.data.fddVerifyUrl
}else{
router.push({
path: '/signature/panel'
})
}
}
}else if(userInfo.value.userExtend.isMainland===0){
router.push({
path: '/signature/panel'
})
}
}
}
},2000)
</script>
<template>
<div class="bg-#EBEBEB h-screen-nav flex flex-col">
<div class="h-50px text-14px text-#191919 bg-#fff flex items-center px-21px mb-6px shrink-0">
{{ $t('signature.tips.prePayment') }}
</div>
<van-collapse
accordion
v-model="activeNames"
class="grow-1"
@change="handleCollapseChange"
>
<van-collapse-item
v-for="item in protocolList"
:key="item.id"
:name="item.id"
class="mb-6px"
>
<template #title>
<div class="text-#2B53AC text-14px">{{ item.title }}</div>
</template>
<pdfView
:pdf-name="item.pdfName"
:type="item.type"
:is-active="activeNames === item.id"
/>
</van-collapse-item>
</van-collapse>
<div class="h-81px bg-#fff flex justify-center pt-7px border-t">
<van-button
color="#2B53AC"
class="w-213px van-btn-h-38px"
@click="goSignature"
>
{{ $t('signature.button.agreeAndSign') }}
</van-button>
</div>
</div>
</template>

View File

@ -0,0 +1,68 @@
<template>
<div class="pdf-container">
<client-only>
<div v-if="loading" class="loading-container">
<van-loading type="spinner" size="24px">{{ $t('common.loading') }}</van-loading>
</div>
<VuePdfEmbed
v-if="pdfUrl"
:source="pdfUrl"
@rendered="handleRendered"
/>
</client-only>
</div>
</template>
<script setup>
import VuePdfEmbed from 'vue-pdf-embed'
const props = defineProps({
pdfName: {
type: String,
required: true
},
type: {
type: String,
default: 'local', // 'local' 'remote'
},
isActive: {
type: Boolean,
default: false
}
})
const loading = ref(true)
const pdfUrl = computed(() => {
if (!props.pdfName) return ''
return props.type === 'local' ? `/pdfs/${props.pdfName}.pdf` : props.pdfName
})
watch(() => props.isActive, (newVal) => {
if (newVal) {
loading.value = true
}
})
const handleRendered = () => {
loading.value = false
}
</script>
<style scoped>
.pdf-container {
position: relative;
min-height: 200px;
width: 100%;
}
.loading-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
:deep(embed) {
width: 100% !important;
}
</style>

9
app/plugins/http.ts Normal file
View File

@ -0,0 +1,9 @@
import { setupHttp } from '@/api/http'
import { setupHttp as setupHttp1} from '@/api-collect-code/http'
import { setupHttp as setupHttp2} from '@/api-public/http'
export default defineNuxtPlugin(() => {
setupHttp()
setupHttp1()
setupHttp2()
})

42
app/plugins/i18n.ts Normal file
View File

@ -0,0 +1,42 @@
import type { Locale as TypeLocale } from '#i18n'
import { Locale } from 'vant'
import enUS from 'vant/es/locale/lang/en-US'
import zhCN from 'vant/es/locale/lang/zh-CN'
import jaJP from 'vant/es/locale/lang/ja-JP'
import zhTW from 'vant/es/locale/lang/zh-TW'
export default defineNuxtPlugin(() => {
// 载入 vant 语言包
Locale.use('zh-CN', zhCN)
Locale.use('en-US', enUS)
Locale.use('ja-JP', jaJP)
Locale.use('zh-TW', zhTW)
if (import.meta.client) {
const i18n = useNuxtApp().$i18n
const { setLocale } = i18n
const nuxtApp = useNuxtApp()
// 获取系统语言
const getSystemLanguage = () => {
const browserLang = navigator.language
// 将浏览器语言映射到应用支持的语言
if (browserLang.startsWith('zh')) {
return browserLang.includes('TW') || browserLang.includes('HK') ? 'zh-TW' : 'zh-CN'
} else if (browserLang.startsWith('ja')) {
return 'ja-JP'
} else if (browserLang.startsWith('en')) {
return 'en-US'
}
// 默认返回中文
return 'zh-CN'
}
// 使用系统语言
const systemLang = getSystemLanguage()
setLocale(systemLang as TypeLocale)
Locale.use(systemLang)
}
})

View File

@ -0,0 +1,7 @@
import VConsole from 'vconsole'
export default defineNuxtPlugin(() => {
if (process.env.NODE_ENV !== 'production') {
const vConsole = new VConsole()
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Some files were not shown because too many files have changed in this diff Show More