first commit
22
.gitignore
vendored
Normal 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/**
|
10
.vscode/extensions.json
vendored
Normal 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
@ -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
@ -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>
|
||||
|
||||
[](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` 目录中看到生成的可以部署的文件。
|
||||
|
30
app/api-collect-code/auth/index.js
Normal 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
|
||||
})
|
||||
}
|
63
app/api-collect-code/goods/index.js
Normal 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
|
||||
})
|
||||
}
|
130
app/api-collect-code/http.js
Normal 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
@ -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
|
||||
}
|
||||
}
|
25
app/api-public/public/index.js
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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>
|
38
app/components/AppFooter.vue
Normal 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>
|
51
app/components/AppHeader.vue
Normal 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>
|
194
app/components/app-skeleton/index.vue
Normal 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>
|
238
app/components/floating2/index.vue
Normal 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>
|
100
app/components/floatingBubble/floating.js
Normal 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
|
||||
}
|
||||
}
|
55
app/components/floatingBubble/index.vue
Normal 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>
|
15
app/components/icons/HomeIcon.vue
Normal 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>
|
15
app/components/icons/MyIcon.vue
Normal 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>
|
BIN
app/components/icons/images/home1.png
Normal file
After Width: | Height: | Size: 864 B |
BIN
app/components/icons/images/home2.png
Normal file
After Width: | Height: | Size: 964 B |
BIN
app/components/icons/images/my1.png
Normal file
After Width: | Height: | Size: 1015 B |
BIN
app/components/icons/images/my2.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
147
app/components/itemDetail/index.vue
Normal 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>
|
106
app/components/liveLoading/index.vue
Normal 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>
|
55
app/components/liveMinWindow/createMinWindow.js
Normal 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)
|
||||
}
|
||||
}
|
183
app/components/liveMinWindow/index.vue
Normal 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>
|
264
app/components/puzzleComponent/index.vue
Normal 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>
|
||||
|
||||
|
331
app/components/stripe/CheckoutPage.vue
Normal 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>
|
115
app/components/stripe/CompletePage.vue
Normal 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>
|
72
app/components/waterfallFlow/index.vue
Normal 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>
|
28
app/components/x-button/index.vue
Normal 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>
|
34
app/components/x-image/index.vue
Normal 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>
|
BIN
app/components/x-message/images/error.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
app/components/x-message/images/info.png
Normal file
After Width: | Height: | Size: 932 B |
BIN
app/components/x-message/images/success.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
app/components/x-message/images/warning.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
93
app/components/x-message/index.vue
Normal 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>
|
121
app/components/x-message/message/index.vue
Normal 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>
|
78
app/components/x-message/useMessage.js
Normal 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 }
|
57
app/components/x-popup/index.vue
Normal 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>
|
114
app/components/x-van-date/index.vue
Normal 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>
|
86
app/components/x-van-select/index.vue
Normal 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>
|
21
app/composables/useWebSocket.ts
Normal 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
@ -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
2
app/constants/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const appName = '豐和'
|
||||
export const appDescription = '泰丰国际京都拍卖会'
|
23
app/layouts/404.vue
Normal 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
@ -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>
|
60
app/pages/artDetail/index.vue
Normal 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>
|
402
app/pages/checkoutPage/index.vue
Normal 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>
|
278
app/pages/collectCode/login/index.vue
Normal 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>
|
103
app/pages/collectCode/mine/components/codeCard/index.vue
Normal 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>
|
220
app/pages/collectCode/mine/index.vue
Normal 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>
|
102
app/pages/collectCode/payment/index.vue
Normal 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>
|
87
app/pages/collectCode/payment/result/index.vue
Normal 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>
|
85
app/pages/collectCode/signature/panel/index.vue
Normal 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>
|
232
app/pages/collectCode/signature/personal-Info/index.vue
Normal 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>
|
171
app/pages/collectCode/signature/protocol/index.vue
Normal 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>
|
68
app/pages/collectCode/signature/protocol/pdfView/index.vue
Normal 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>
|
34
app/pages/collectCode/signature/result/index.vue
Normal 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">●   {{ t('collectCode.signature.resultText') }}   ●</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>
|
1515
app/pages/countryRegion/data/index.js
Normal file
225
app/pages/countryRegion/index.vue
Normal 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>
|
18
app/pages/home/components/Cescribe/index.vue
Normal 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>
|
28
app/pages/home/components/DetailPopup/index.vue
Normal 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>
|
112
app/pages/home/components/ItemList/index.vue
Normal 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
@ -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
@ -0,0 +1,10 @@
|
||||
<script setup>
|
||||
import Home from './home/index.vue'
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
i18n: 'menu.home',
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Home/>
|
||||
</template>
|
69
app/pages/liveRoom/components/Broadcast/index.vue
Normal 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>
|
89
app/pages/liveRoom/components/PaymentInput/index.vue
Normal 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>
|
42
app/pages/liveRoom/components/PaymentResults/index.vue
Normal 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>
|
123
app/pages/liveRoom/components/SideButton/index.vue
Normal 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>
|
171
app/pages/liveRoom/components/SideButton/tangPopup.vue
Normal 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>
|
247
app/pages/liveRoom/index.client.vue
Normal 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
@ -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>
|
363
app/pages/payment/completePage/index.vue
Normal 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
@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<iframe>
|
||||
|
||||
</iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
97
app/pages/payment/index.vue
Normal 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>
|
95
app/pages/payment/result/index.vue
Normal 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>
|
14
app/pages/privacyPolicy/index.vue
Normal 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
@ -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>
|
93
app/pages/publicLiveRoom/components/broadcast/index.vue
Normal 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>
|
126
app/pages/publicLiveRoom/index.client.vue
Normal 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>
|
79
app/pages/realAuth/components/detail.vue
Normal 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>
|
172
app/pages/realAuth/index.vue
Normal 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>
|
118
app/pages/signature/panel/index.vue
Normal 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>
|
64
app/pages/signature/personal-Info/index.vue
Normal 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>
|
128
app/pages/signature/protocol/index.vue
Normal 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 URL在data字段中
|
||||
} 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>
|
68
app/pages/signature/protocol/pdfView/index.vue
Normal 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
@ -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
@ -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)
|
||||
}
|
||||
})
|
7
app/plugins/vconsole.client.js
Normal file
@ -0,0 +1,7 @@
|
||||
import VConsole from 'vconsole'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const vConsole = new VConsole()
|
||||
}
|
||||
})
|
BIN
app/static/images/1981736313.png
Normal file
After Width: | Height: | Size: 283 KiB |
BIN
app/static/images/3532@2x.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
app/static/images/5514@2x.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
app/static/images/5532@2x.png
Normal file
After Width: | Height: | Size: 62 KiB |