feat:initial commit

This commit is contained in:
tangxinyue 2026-07-22 13:41:12 +08:00
commit 264abf002e
182 changed files with 13470 additions and 0 deletions

0
.env Normal file
View File

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# 依赖包
node_modules/
# 构建产物
dist/
dist-ssr/
*.local
# 系统自动生成的无用文件
.DS_Store
.DS_Store?
ehthumbs.db
Thumbs.db
# 日志文件
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 编辑器配置
.vscode/*
!.vscode/extensions.json

1
.npmrc Normal file
View File

@ -0,0 +1 @@
@batiao:registry=https://maven.batiao8.com/repository/npm-releases/

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>装样大师</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

69
log/server.log Normal file
View File

@ -0,0 +1,69 @@
[2026-07-22 11:40:20,637] [INFO] [D:\AAA-Code\模拟器pc端\frontend\service\main.cjs:13] server start at http://127.0.0.1:9270
[2026-07-22 11:40:52,948] [INFO] [D:\AAA-Code\模拟器pc端\frontend\service\main.cjs:13] server start at http://127.0.0.1:9270
[2026-07-22 11:40:53,151] [INFO] [unknow:0] GET 200 /
[2026-07-22 11:40:53,179] [INFO] [unknow:0] GET 200 /.well-known/appspecific/com.chrome.devtools.json
[2026-07-22 11:40:53,230] [INFO] [unknow:0] GET 200 /favicon.ico
[2026-07-22 11:41:09,737] [INFO] [unknow:0] GET 200 /
[2026-07-22 11:41:09,788] [INFO] [unknow:0] GET 200 /.well-known/appspecific/com.chrome.devtools.json
[2026-07-22 11:41:09,850] [INFO] [unknow:0] GET 200 /favicon.ico
[2026-07-22 11:44:37,840] [INFO] [D:\AAA-Code\模拟器pc端\frontend\service\main.cjs:13] server start at http://127.0.0.1:9270
[2026-07-22 11:44:39,868] [INFO] [unknow:0] GET 200 /
[2026-07-22 11:44:39,906] [INFO] [unknow:0] GET 200 /.well-known/appspecific/com.chrome.devtools.json
[2026-07-22 11:44:40,032] [INFO] [unknow:0] GET 200 /src/public/header/chat-icon.png
[2026-07-22 11:44:40,033] [INFO] [unknow:0] GET 200 /src/public/header/money-icon.png
[2026-07-22 11:44:40,036] [INFO] [unknow:0] GET 200 /src/public/header/zhengjianmoni.png
[2026-07-22 11:44:40,038] [INFO] [unknow:0] GET 200 /src/public/header/feijipiao.png
[2026-07-22 11:44:40,056] [INFO] [unknow:0] GET 200 /src/public/header/huochepiao.png
[2026-07-22 11:44:40,058] [INFO] [unknow:0] GET 200 /src/public/header/gouwuxiangqing.png
[2026-07-22 11:44:40,059] [INFO] [unknow:0] GET 200 /src/public/header/qinglvzheng.png
[2026-07-22 11:44:40,060] [INFO] [unknow:0] GET 200 /src/public/header/shipinliaotian.png
[2026-07-22 11:44:40,093] [INFO] [unknow:0] GET 200 /src/public/index/liaotianmoni.png
[2026-07-22 11:44:40,094] [INFO] [unknow:0] GET 200 /src/public/index/yuemoni.png
[2026-07-22 11:44:40,095] [INFO] [unknow:0] GET 200 /src/public/index/gongzidan.png
[2026-07-22 11:44:40,095] [INFO] [unknow:0] GET 200 /src/public/index/huochepiao.png
[2026-07-22 11:44:40,097] [INFO] [unknow:0] GET 200 /src/public/index/feijipiao.png
[2026-07-22 11:44:40,099] [INFO] [unknow:0] GET 200 /src/public/index/zhengjian.png
[2026-07-22 11:44:40,106] [INFO] [unknow:0] GET 200 /src/public/index/gouwumoni.png
[2026-07-22 11:44:40,110] [INFO] [unknow:0] GET 200 /src/public/index/service2.png
[2026-07-22 11:44:40,111] [INFO] [unknow:0] GET 200 /src/public/index/service1.png
[2026-07-22 11:44:40,112] [INFO] [unknow:0] GET 200 /src/public/index/service4.png
[2026-07-22 11:44:40,113] [INFO] [unknow:0] GET 200 /src/public/index/service3.png
[2026-07-22 11:44:40,148] [INFO] [unknow:0] GET 200 /favicon.ico
[2026-07-22 11:44:40,249] [INFO] [D:\AAA-Code\模拟器pc端\frontend\service\core\router\index.cjs:115] GET 400 /api/user/config?nonce=0a7749351dc3464e80f434725e2f62f7&signature=34558c5c41e567ed8c8f698a80493987&timestamp=1784691880
[2026-07-22 11:45:45,061] [INFO] [D:\AAA-Code\模拟器pc端\frontend\service\main.cjs:13] server start at http://127.0.0.1:9270
[2026-07-22 11:45:49,496] [INFO] [unknow:0] GET 200 /
[2026-07-22 11:45:49,550] [INFO] [unknow:0] GET 200 /.well-known/appspecific/com.chrome.devtools.json
[2026-07-22 11:45:49,654] [INFO] [unknow:0] GET 200 /src/public/header/money-icon.png
[2026-07-22 11:45:49,656] [INFO] [unknow:0] GET 200 /src/public/header/chat-icon.png
[2026-07-22 11:45:49,658] [INFO] [unknow:0] GET 200 /src/public/header/zhengjianmoni.png
[2026-07-22 11:45:49,661] [INFO] [unknow:0] GET 200 /src/public/header/feijipiao.png
[2026-07-22 11:45:49,663] [INFO] [unknow:0] GET 200 /src/public/header/huochepiao.png
[2026-07-22 11:45:49,669] [INFO] [unknow:0] GET 200 /src/public/header/gouwuxiangqing.png
[2026-07-22 11:45:49,695] [INFO] [unknow:0] GET 200 /src/public/header/qinglvzheng.png
[2026-07-22 11:45:49,696] [INFO] [unknow:0] GET 200 /src/public/header/shipinliaotian.png
[2026-07-22 11:45:49,735] [INFO] [unknow:0] GET 200 /src/public/index/huochepiao.png
[2026-07-22 11:45:49,736] [INFO] [unknow:0] GET 200 /src/public/index/yuemoni.png
[2026-07-22 11:45:49,741] [INFO] [unknow:0] GET 200 /src/public/index/gongzidan.png
[2026-07-22 11:45:49,743] [INFO] [unknow:0] GET 200 /src/public/index/liaotianmoni.png
[2026-07-22 11:45:49,745] [INFO] [unknow:0] GET 200 /src/public/index/feijipiao.png
[2026-07-22 11:45:49,755] [INFO] [unknow:0] GET 200 /src/public/index/gouwumoni.png
[2026-07-22 11:45:49,757] [INFO] [unknow:0] GET 200 /src/public/index/zhengjian.png
[2026-07-22 11:45:49,759] [INFO] [unknow:0] GET 200 /src/public/index/service1.png
[2026-07-22 11:45:49,762] [INFO] [unknow:0] GET 200 /src/public/index/service2.png
[2026-07-22 11:45:49,765] [INFO] [unknow:0] GET 200 /src/public/index/service3.png
[2026-07-22 11:45:49,768] [INFO] [unknow:0] GET 200 /src/public/index/service4.png
[2026-07-22 11:45:49,780] [INFO] [unknow:0] GET 200 /favicon.ico
[2026-07-22 11:45:49,926] [INFO] [D:\AAA-Code\模拟器pc端\frontend\service\core\router\index.cjs:28] GET 200 /api/user/config?nonce=a64235de6ea7ffebad418f9c2811cf65&signature=4c0d5aea657628fea8eb614c30bbc072&timestamp=1784691949
[2026-07-22 11:53:07,083] [INFO] [unknow:0] GET 200 /
[2026-07-22 11:53:07,194] [INFO] [unknow:0] GET 200 /src/public/header/money-icon.png
[2026-07-22 11:53:07,195] [INFO] [unknow:0] GET 200 /src/public/header/chat-icon.png
[2026-07-22 11:53:07,195] [INFO] [unknow:0] GET 200 /src/public/header/gouwuxiangqing.png
[2026-07-22 11:53:07,196] [INFO] [unknow:0] GET 200 /src/public/header/zhengjianmoni.png
[2026-07-22 11:53:07,197] [INFO] [unknow:0] GET 200 /src/public/header/feijipiao.png
[2026-07-22 11:53:07,197] [INFO] [unknow:0] GET 200 /src/public/header/huochepiao.png
[2026-07-22 11:53:07,215] [INFO] [unknow:0] GET 200 /src/public/header/qinglvzheng.png
[2026-07-22 11:53:07,216] [INFO] [unknow:0] GET 200 /src/public/header/shipinliaotian.png
[2026-07-22 11:53:07,226] [INFO] [unknow:0] GET 200 /favicon.ico
[2026-07-22 11:53:07,318] [INFO] [D:\AAA-Code\模拟器pc端\frontend\service\core\router\index.cjs:28] GET 200 /api/user?nonce=fddaedee3575963db77e91737a2348ef&signature=8088d7e0f2529b98895a16838cf5a5be&timestamp=1784692387
[2026-07-22 11:53:07,321] [INFO] [D:\AAA-Code\模拟器pc端\frontend\service\core\router\index.cjs:28] GET 200 /api/user/config?nonce=08ef64bc62ffad421fe79bee1cbba2c5&signature=d13b6ce366e861bbce4924bd500e68c3&timestamp=1784692387
[2026-07-22 11:53:11,132] [INFO] [unknow:0] GET 200 /.well-known/appspecific/com.chrome.devtools.json

3705
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "wechat-pc-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "node --stack-size=12800 --stack-trace-limit=20 ./service/main.cjs"
},
"dependencies": {
"@batiao/batiao-sdk-vue-vite": "^1.0.357",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.8",
"body-parser": "^2.3.0",
"crypto-js": "^4.2.0",
"dotenv": "^17.4.2",
"element-plus": "^2.6.1",
"express": "^5.2.1",
"html2canvas": "^1.4.1",
"http-proxy": "^1.18.1",
"mime": "^4.1.0",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"request": "^2.88.2",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"winston": "^3.19.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"sass": "^1.71.1",
"vite": "^5.2.0"
},
"auth": {
"sign": "xn08hYoizXhZ1zHP8DVqfCm2yHxPmhil",
"decode": "e4rOtnF8tJjtHO7ecZeJHN1rapED5ImB"
},
"authExpire": 5,
"debug": false,
"debug_build": false,
"dat_only_main": true
}

View File

@ -0,0 +1,127 @@
const logger = require('../utils/log.cjs')
const routes = require('./routes.cjs')
const httpProxy = require('http-proxy')
const proxy = httpProxy.createProxyServer({})
const fs = require('fs')
const dist = 'dist'
let mime
import('mime').then((res) => {
mime = res.default
})
let proxys = require('../../../src/proxy.cjs')
const { Exception } = require("sass");
const httpOk = 200
const httpBadRequest = 400
const matchProxy = (request, response) => {
const match = request.url.split('/')[1]
const proxyInfo = proxys['/' + match]
if (proxyInfo) {
const host = request.headers.host;
delete request.headers.host
if (typeof proxyInfo.rewrite === 'function') {
request.url = proxyInfo.rewrite(request.url)
}
proxy.web(request, response, {
target: proxyInfo.target || ("http://" + host),
changeOrigin: proxyInfo.changeOrigin
})
logger.info(`${request.method} ${httpOk} ${request.url}`)
return true
} else {
return null
}
}
const matchRouter = async (request, response) => {
let [url] = request.url.split('?')
let route = routes
let url_sp = url.split('/')
for (let p of url_sp) {
if (!p) {
continue
}
if (route[p]) {
route = route[p]
} else {
return false
}
}
if (typeof route === 'function') {
await route(request, response)
// response.writeHead(httpOk, { 'Content-Type': 'application/json' })
// response.end(JSON.stringify(res))
logger.info(`${request.method} ${httpOk} ${request.url}`)
return true
} else {
return false
}
}
const matchFile = async (request, response) => {
return new Promise((resolve) => {
const path = request.url.split('?')[0]
fs.readFile(dist + decodeURIComponent(path), (err, file) => {
if (!err) {
const mimetype = mime.getType(request.url)
const responseHdeaders = { 'Content-Type': mimetype }
if (path.endsWith("-sw.js")) {
responseHdeaders["Service-Worker-Allowed"] = '/'
}
response.writeHead(httpOk, responseHdeaders)
response.end(file)
// logger.info(`${request.method} ${httpOk} ${request.url}`)
resolve(true)
} else {
resolve(false)
}
})
})
}
const endIndex = async (request, response) => {
console.log(dist + '/index.html')
fs.readFile(dist + '/index.html', (err, html) => {
if (!err) {
response.writeHead(httpOk, { 'Content-type': 'text/html;charset=utf-8' })
response.end(html)
logger.info(`${request.method} ${httpOk} ${request.url}`)
}
})
}
const matchType = async (request, response) => {
const isRouter = await matchRouter(request, response)
if (isRouter) {
return
}
let isProxy = matchProxy(request, response)
if (isProxy) {
return
}
let isFile = await matchFile(request, response)
if (isFile) {
return
}
await endIndex(request, response)
}
const httpService = async (request, response) => {
try {
await matchType(request, response)
} catch (e) {
response.writeHead(httpBadRequest, { 'Content-Type': 'application/json' })
response.end(
JSON.stringify({
code: -1,
message: String(e)
})
)
logger.info(`${request.method} ${httpBadRequest} ${request.url}`)
}
}
module.exports = {
httpService,
matchType,
matchRouter,
matchFile,
endIndex,
matchProxy,
}

View File

@ -0,0 +1,96 @@
// const logger = require("../utils/log.cjs")
const config = require("../utils/config.cjs");
const request = require("request");
const CryptoJS = require("crypto-js");
module.exports = {
config: async () => {
return config;
},
getEnvByKey:async (req,res)=>{
const query = req.query;
const key = query.key;
res.writeHead(200, {"Content-Type": "text/plain"});
res.end(process.env[key])
},
downloadWithFileName: async (req, res) => {
const query = req.query;
const response = request(decodeURIComponent(query.url), {
headers: query.headers,
});
response.on("response", (resp) => {
["content-length", "content-type"].forEach(
(key) => {
if (resp.headers[key]) {
res.setHeader(key, resp.headers[key]);
}
}
);
let disposition;
if(query.filename){
const filename = encodeURIComponent(query.filename);
disposition = `attachment; filename="${filename}"`;
}else {
disposition = resp.headers["content-dispositions"] || ""
}
res.setHeader("content-disposition", disposition);
res.setHeader("access-control-allow-origin", "*")
});
response.on("error", (e) => {
res.status(500).send(String(e));
});
response.on("end", () => {
res.end();
});
response.on("data", (chunk) => {
res.write(chunk);
});
},
download: async (req, res) => {
const query = req.query;
query.url = CryptoJS.enc.Utf8.stringify(
CryptoJS.enc.Base64.parse(query.url)
).toString();
query.headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0",
Accept: "*/*",
"Accept-Language":
"zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
Connection: "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
...JSON.parse(
CryptoJS.enc.Utf8.stringify(
CryptoJS.enc.Base64.parse(query.headers)
).toString()
),
};
const response = request(query.url, {
headers: query.headers,
});
response.on("response", (resp) => {
["content-length", "content-type", "content-disposition"].forEach(
(key) => {
if (resp.headers[key]) {
res.setHeader(key, resp.headers[key]);
}
}
);
res.setHeader("access-control-allow-origin", "*")
});
response.on("error", (e) => {
res.status(500).send(String(e));
});
response.on("end", () => {
res.end();
});
response.on("data", (chunk) => {
res.write(chunk);
});
// response.pipe(res);
},
};

View File

@ -0,0 +1,2 @@
const config = require("../../../src/config.json")
module.exports = config

View File

@ -0,0 +1,53 @@
const winston = require('winston');
function thisLine() {
try {
const e = new Error();
const regex = /\((.*):(\d+):(\d+)\)$/
const stacks = e.stack.split("\n")
const temp = []
stacks.forEach(stack => {
if (!stack.includes("node_modules")) {
temp.push(stack)
}
})
const match = regex.exec(temp[3]);
return {
filepath: match[1],
line: match[2],
column: match[3]
};
} catch (e) {
return {
filepath: "unknow",
line: "0",
column: "0"
};
}
}
const logger = winston.createLogger({
level: 'info',
transports: [
new winston.transports.Console(),
new winston.transports.File({
dirname: 'log', filename: 'server.log'
}),
],
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss,SSS" }),
winston.format.align(),
winston.format.splat(),
winston.format.printf(
(info) => {
const { timestamp, level, message } = info;
const { filepath, line } = thisLine()
return `[${timestamp}] [${level.toUpperCase()}] [${filepath}:${line}] ${message}`; // 包括文件名和行号
}
)
),
maxSize: '500m', // 每个日志文件最大尺寸
maxFiles: '1d' // 保留的日志文件天数
});
module.exports = logger

20
service/main.cjs Normal file
View File

@ -0,0 +1,20 @@
/* eslint-disable no-undef */
// 这里是生产环境的反向代理逻辑
const initService = () => {
const express = require("express");
const router = require("./core/router/index.cjs");
// const logger = console
const config = require("./core/utils/config.cjs");
const logger = require("./core/utils/log.cjs");
const server = express();
const PORT = config.port;
server.use(router.httpService);
server.listen(PORT, function () {
logger.info(`server start at http://127.0.0.1:${PORT}`);
});
};
try {
initService();
} catch (e) {
console.error(e);
}

13
service/mock.cjs Normal file
View File

@ -0,0 +1,13 @@
// 这里是开发环境的反向代理逻辑
const routes = require("./core/router/routes.cjs");
const router = require("./core/router/index.cjs");
module.exports = async function mock(req, res, next) {
if (await router.matchRouter(req,res)) {
return;
}
if (router.matchProxy(req, res)){
return;
}
next();
};

64
src/App.vue Normal file
View File

@ -0,0 +1,64 @@
<template>
<div id="app-wrapper">
<AppHeader class="header" />
<main class="main-content">
<router-view />
</main>
<AppFooter class="footer" v-if="false" />
<!-- Payment Dialog -->
<PaymentDialog ref="paymentDialog" @success="onPaymentSuccess" />
</div>
</template>
<script setup>
import { ref, provide, onMounted } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import PaymentDialog from '@/components/PaymentDialog.vue'
import { useUserStore } from '@/stores/user'
import api from '@/api'
const userStore = useUserStore()
const paymentDialog = ref(null)
onMounted(() => {
if (localStorage.getItem('token') && !userStore.tokenChecked) {
// 401/
// api.auth.profileSilent().then(res => {
// userStore.username = res.username
// userStore.isVip = res.is_vip
// userStore.avatarUrl = res.avatar_url
// userStore.membershipExpireAt = res.membership_expire_at
// userStore.tokenChecked = true
// userStore.tokenValid = true
// }).catch(() => {
// userStore.tokenChecked = true
// userStore.tokenValid = false
// })
userStore.fetchProfile().catch(() => { })
}
})
provide('showPayment', () => {
paymentDialog.value?.open()
})
function onPaymentSuccess() {
userStore.fetchProfile()
}
</script>
<style lang="scss">
#app-wrapper {
min-height: 100vh;
max-width: 2000px;
min-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
.main-content {
flex: 1;
}
}
</style>

72
src/api/authService.js Normal file
View File

@ -0,0 +1,72 @@
import { get, postJson } from "@/utils/requestService";
import appConfig from "@/utils/appConfig";
export default {
/**
* 0. 获取系统与用户初始化配置 (user-config)
* 文档参考: https://gs.batiao8.com/doc/#api-User-user-config
*/
async getUserConfig(data = {}) {
return await get(appConfig.baseURL, "/user/config", data);
},
/**
* 1. 发送手机验证码 (send-user-code)
* 文档参考: https://gs.batiao8.com/doc/#api-User-send-user-code
* @param {Object} data - { phone: '13800138000' }
*/
async sendCode(data) {
return await postJson(appConfig.baseURL, "/user/code", data);
},
/**
* 2. 手机验证码登录 (user-login)
* 文档参考: https://gs.batiao8.com/doc/#api-User-user-login
* @param {Object} data - { login_type: 'phone', phone: { phone, code, timestamp } }
*/
async login(data) {
return await postJson(appConfig.baseURL, "/user/login", data);
},
/**
* 3. 获取微信扫码登录二维码
*/
async getLoginCode(data = {}) {
return await postJson(appConfig.baseURL, "/weixin/qrcode", data);
},
/**
* 4. 轮询微信扫码状态
* @param {Object} data - { key: 'xxx' }
*/
async askLoginCode(data) {
return await get(appConfig.baseURL, "/weixin/qrcode", data);
},
/**
* 5. 获取当前登录用户信息
*/
async getUserInfo(data = {}) {
return await get(appConfig.baseURL, "/user", data);
},
/**
* 获取商品列表
* @param {*} data
* @returns
*/
async getGoods(data = { type: "member" }) {
return await get(appConfig.baseURL, "/order/goods", data);
},
/**
* 创建订单
* @param {*} data
* @returns
*/
async createOrder(data = {}) {
return await postJson(appConfig.baseURL, "/order", data);
},
};

85
src/api/index.js Normal file
View File

@ -0,0 +1,85 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const http = axios.create({
baseURL: '/api',
timeout: 15000,
})
http.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
res => res.data,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
const userStore = useUserStore()
userStore.reset()
// _noRedirect 标记:静默验证时不跳转(避免注册/登录页被重定向)
if (!err.config?._noRedirect) {
router.push('/login')
}
return Promise.reject(err)
}
// 支持静默模式config._silent=true 时不弹出错误提示
if (!err.config?._silent) {
const msg = err.response?.data?.error || '请求失败'
ElMessage.error(msg)
}
return Promise.reject(err)
}
)
// Auth
export const auth = {
register: data => http.post('/auth/register', data),
login: data => http.post('/auth/login', data),
logout: () => http.post('/auth/logout'),
profile: () => http.get('/auth/profile'),
profileSilent: () => http.get('/auth/profile', { _silent: true, _noRedirect: true }),
captcha: () => http.get('/auth/captcha'),
wechatLoginUrl: () => http.get('/auth/wechat/login'),
}
// Works
export const works = {
list: params => http.get('/works', { params }),
create: data => http.post('/works', data),
get: id => http.get(`/works/${id}`),
update: (id, data) => http.put(`/works/${id}`, data),
delete: id => http.delete(`/works/${id}`),
}
// Payment
export const payment = {
createOrder: planType => http.post('/payment/create-order', { plan_type: planType }),
getOrder: orderNo => http.get(`/payment/order/${orderNo}`),
orders: () => http.get('/payment/my-orders'),
checkResult: orderNo => http.get(`/payment/result/${orderNo}`, { _silent: true }),
}
// Product (商品管理)
export const product = {
list: () => http.get('/product/list'), // 前端公开:启用商品列表
buy: (productId, data) => http.post(`/product/${productId}/buy`, data), // 购买商品
// 管理端接口需登录token
adminList: params => http.get('/product', { params }), // 分页列表
get: id => http.get(`/product/${id}`),
create: data => http.post('/product', data),
update: (id, data) => http.put(`/product/${id}`, data),
delete: id => http.delete(`/product/${id}`),
toggle: id => http.post(`/product/${id}/toggle`),
}
// Public
export const publicApi = {
banners: () => http.get('/public/banners'),
}
export default { auth, works, payment, product, public: publicApi }

View File

@ -0,0 +1,75 @@
<template>
<div class="footer-box">
<div class="top-box">
<div class="item left">
<div class="title">关于我们</div>
<p class="">
我们是一个由整蛊爱好者戏精本精组成的团队致力于打造一个开放包容充满创意的平台让每个人都能轻松释放自己的想象力用图片编辑的方式创作出独一无二的恶搞作品
</p>
</div>
<div class="item right">
<div class="title">联系方式</div>
<div class="desc" style="margin-top: 0;">在线客服</div>
<div class="desc">公司红红火火恍恍惚惚或或或或</div>
<div class="desc">地址红红火火恍恍惚惚或或或或或或</div>
</div>
</div>
<div class="bottom-text">用户协议隐私政策退款政策</div>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.footer-box {
background-color: #1A1A1A;
width: 100%;
padding: 24px;
.top-box {
margin: 0 auto;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 200px;
.item {
width: 489px;
.desc {
font-size: 12px;
color: #979797;
line-height: 12px;
margin-top: 16px;
}
}
}
.title {
font-weight: 700;
font-size: 14px;
color: #FFFFFF;
line-height: 24px;
margin-bottom: 9px;
}
p {
font-weight: 400;
font-size: 12px;
color: #979797;
line-height: 25px;
max-width: 489px;
}
.bottom-text {
font-size: 14px;
color: #979797;
line-height: 12px;
margin-top: 28px;
width: 100%;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,529 @@
<template>
<div style="height: 112px;"></div>
<header class="app-header">
<div class="group_47637">
<div v-if="true" class="notice-banner-wrapper">
<img class="banner-bg" src="@/public/header/down-app-banner-bg.png" alt="heightFix">
<div class="banner-content">
<span class="main-text"> 装样大师 APP已上线</span>
<span class="sub-text">各大应用市场均可下载</span>
<img class="down-app-img" src="@/public/header/down-app-btn.png" alt="">
</div>
<!-- <img class="close-icon" src="@/public/header/close.png" alt=""> -->
</div>
<div class="app-header-bar">
<div class="left-box">
<img class="logo-img" src="@/public/header/logo-img.png" />
<img class="logo-name" src="@/public/header/logo-name.png" mode="widthFix" />
<div class="line"></div>
<div class="menu-box">
<template v-for="item in navList" :key="item.key">
<div v-if="item.children" class="dropdown-wrapper">
<span :class="['item', { active: isItemActive(item) }]"> {{ item.name }} </span>
<div class="dropdown-panel" style="padding-top: 22px;">
<div class="panel-content">
<div class="dropdown-item" v-for="child in item.children" :key="child.key"
@click="handleNavClick(child)">
<img class="icon" :src="child.icon" alt=""> {{ child.name }}
</div>
</div>
</div>
</div>
<span v-else :class="['item', { active: isItemActive(item) }]" @click="handleNavClick(item)">
{{ item.name }}
</span>
</template>
</div>
</div>
<div class="right-box">
<img v-if="!userStore.isVip && isLoggedIn" @click="handleCommand('vip')" class="vip-logo"
src="@/public/header/kaitonghuiyuan.png" mode="heightFix" />
<img v-if="!isLoggedIn" @click="openLoginDialog" class="vip-logo" src="@/public/header/login-btn.png"
mode="heightFix" />
<div v-else class="dropdown-wrapper">
<div class="user-info-trigger">
<div class="user-avatar">
<img class="user-logo-img" :src="userStore.avatarUrl" />
</div>
<span class="user-name"> {{ userStore.username || '已登录用户' }} </span>
</div>
<div class="dropdown-panel">
<div class="panel-content" style="width: 200px;">
<div class="dropdown-item mg-7" @click="handleCommand('profile')">
<img class="icon" src="@/public/header/gerenzhongxin.png" alt=""> 个人中心
</div>
<div class="dropdown-item mg-7" @click="handleCommand('vip')">
<img class="icon" src="@/public/header/kaitonghuiyuanmenu.png" alt=""> 开通会员
</div>
<div class="dropdown-item mg-7" @click="handleCommand('works')">
<img class="icon" src="@/public/header/wodezuoping.png" alt=""> 我的作品
</div>
<div style="border-top: 0.5px solid #D8D8D8;height: 1px;"></div>
<div class="dropdown-item mg-7" style="border-radius: 0 0 8px 8px;" @click="handleCommand('logout')">
<img class="icon" src="@/public/header/tuichudenglu.png" alt="">退出登录
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<LoginDialog ref="loginDialogRef" />
</header>
</template>
<script setup>
import { computed, inject, ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import LoginDialog from '@/components/LoginDialog.vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const showPayment = inject('showPayment')
const loginDialogRef = ref(null)
onMounted(() => {
if (localStorage.getItem('token')) {
userStore.fetchProfile().catch(() => { })
}
if (route.query.showLogin) {
loginDialogRef.value?.open()
const query = { ...route.query }
delete query.showLogin
router.replace({ query })
}
})
watch(() => route.query.showLogin, (newVal) => {
if (newVal) {
loginDialogRef.value?.open()
const query = { ...route.query }
delete query.showLogin
router.replace({ query })
}
})
const isLoggedIn = computed(() => userStore.isLoggedIn)
const navList = [
{ name: '首页', key: 'index', path: '/' },
{
name: '微信模拟',
key: 'wechat',
children: [
{ name: '模拟聊天', key: 'chat', path: '/chat', icon: '/src/public/header/chat-icon.png' },
{ name: '余额模拟', key: 'balance', path: '/balance', icon: '/src/public/header/money-icon.png' }
]
},
{
name: '娱乐模拟',
key: 'entertainment',
children: [
{ name: '工资单', key: 'gongzidan', path: '/gongzidan', icon: '/src/public/header/zhengjianmoni.png' },
{ name: '飞机票', key: 'flight', path: '/flight', icon: '/src/public/header/feijipiao.png' },
{ name: '火车票', key: 'train', path: '/train', icon: '/src/public/header/huochepiao.png' },
{ name: '购物详情', key: 'shopping', path: '/shopping', icon: '/src/public/header/gouwuxiangqing.png' },
{ name: '情侣证', key: 'couple', path: '/couple', icon: '/src/public/header/qinglvzheng.png' },
{ name: '视频聊天', key: 'video', path: '/video', icon: '/src/public/header/shipinliaotian.png' }
]
},
{ name: '作品库', key: 'works', path: '/works' },
{ name: '关于我们', key: 'about', path: '/about' }
]
function handleNavClick(item) {
if (item.key === 'works' && !isLoggedIn.value) {
loginDialogRef.value?.open()
return
}
if (item.path) {
router.push(item.path)
}
}
function isItemActive(item) {
if (item.path === route.path) return true
if (item.children) {
return item.children.some(child => child.path === route.path)
}
return false
}
async function handleCommand(cmd) {
if (cmd === 'logout') {
try { await api.auth.logout() } catch { }
userStore.reset()
localStorage.removeItem('token')
localStorage.removeItem('user_info')
ElMessage.success('已退出登录')
router.push('/')
} else if (cmd === 'works') {
if (!isLoggedIn.value) {
loginDialogRef.value?.open()
return
}
router.push('/works')
} else if (cmd === 'profile') {
router.push('/profile')
} else if (cmd === 'vip') {
showPayment?.()
}
}
function openLoginDialog() {
loginDialogRef.value?.open()
}
</script>
<style lang="scss" scoped>
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
margin: 0 auto;
max-width: 2000px;
min-width: 1200px;
background: #fff;
box-shadow: 0 1px 8px rgba(0, 0, 0, .08);
z-index: 100;
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
height: 100%;
display: flex;
align-items: center;
gap: 40px;
}
.logo {
display: flex;
align-items: center;
gap: 6px;
font-size: 18px;
font-weight: bold;
color: #07c160;
.logo-icon {
font-size: 24px;
}
}
.nav-links {
display: flex;
gap: 24px;
flex: 1;
align-items: center;
a {
color: #333;
font-size: 14px;
padding: 4px 0;
&.router-link-active {
color: #07c160;
}
}
.nav-dropdown-trigger {
cursor: pointer;
font-size: 14px;
color: #333;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px 0;
}
}
.user-area {
display: flex;
align-items: center;
gap: 10px;
}
.user-name {
cursor: pointer;
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 2px;
}
}
.app-header {
height: auto;
}
.notice-banner-wrapper {
position: relative;
min-width: 1200px;
height: 52px;
display: flex;
justify-content: center;
background: linear-gradient(258deg, #C05BFF 0%, #2E95FF 68.44%, #55D5FC 88.34%, #6CFFEA 100%);
.banner-text {
height: 52px;
}
.banner-content {
display: flex;
align-items: center;
justify-content: center;
line-height: 52px;
.main-text {
font-weight: 700;
font-size: 20px;
color: #FFFFFF;
letter-spacing: 2px;
margin-right: 8px;
}
.sub-text {
font-weight: 400;
font-size: 20px;
color: #FFFFFF;
line-height: 52px;
letter-spacing: 5px;
}
.down-app-img {
height: 26px;
margin-left: 18px;
cursor: pointer;
animation: pulseScale 1.5s infinite ease-in-out;
&:hover {
opacity: 0.9;
}
}
}
@keyframes pulseScale {
0% {
transform: scale(0.9);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(0.9);
}
}
.banner-bg {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 0;
height: 52px;
}
.close-icon {
position: absolute;
right: 34px;
top: 50%;
transform: translateY(-50%);
width: 34px;
height: 34px;
cursor: pointer;
}
}
.app-header-bar {
width: 100%;
padding: 22px 72px;
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
.left-box {
display: flex;
align-items: center;
.logo-img {
width: 36px;
height: 36px;
margin-right: 12px;
}
.logo-name {
width: 87px;
}
.line {
margin-left: 30px;
width: 2px;
height: 26px;
background: #D8D8D8;
}
.menu-box {
display: flex;
margin-left: 48px;
gap: 50px;
margin-right: 100px;
.item {
font-weight: 500;
font-size: 18px;
color: #767676;
line-height: 20px;
white-space: nowrap;
cursor: pointer;
&.active {
position: relative;
font-weight: 700;
color: #1A1A1A;
&::after {
content: '';
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
width: 26px;
height: 4px;
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
border-radius: 129px 129px 129px 129px;
}
}
}
}
}
.right-box {
display: flex;
align-items: center;
.vip-logo {
height: 32px;
margin-right: 16px;
cursor: pointer;
}
.user-avatar {
height: 45px;
width: 45px;
margin-right: 16px;
border-radius: 50%;
background-color: #eaeaea;
cursor: pointer;
flex-shrink: 0;
.user-logo-img {
width: 45px;
height: 45px;
border-radius: 50%;
object-fit: cover;
}
}
.user-name {
font-weight: 500;
font-size: 18px;
color: #1A1A1A;
line-height: 18px;
cursor: pointer;
}
}
}
.dropdown-wrapper {
position: relative;
display: flex;
align-items: center;
height: 100%;
&:hover .dropdown-panel {
display: block;
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.dropdown-panel {
display: none;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%) translateY(-10px);
padding-top: 15px;
z-index: 100;
transition: all 0.3s ease;
.panel-content {
background: #FFFFFF;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 1px 0;
min-width: 120px;
position: relative;
&::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #fff;
filter: drop-shadow(0 -2px 2px rgba(0, 0, 0, 0.05));
}
}
.dropdown-item {
display: flex;
align-items: center;
height: 34px;
padding: 12px 20px;
font-size: 14px;
border-radius: 6px;
color: #1A1A1A;
cursor: pointer;
white-space: nowrap;
margin: 3px 4px;
transition: all 0.2s;
&.mg-7 {
margin: 7px;
height: 40px;
}
.icon {
width: 18px;
height: 18px;
margin-right: 8px;
object-fit: contain;
}
&:hover {
background: #F7F8FF;
}
}
}
.user-info-trigger {
display: flex;
align-items: center;
cursor: pointer;
height: 100%;
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<div class="base-dialog">
<el-dialog class="base-dialog" v-model="visible" :width="width" align-center>
<img src="@/public/header/close.png" class="close" @click="handleClose" />
<slot></slot>
<template #footer v-if="$slots.footer">
<slot name="footer"></slot>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
width: { type: [Number, String], default: '600px' },
})
const emit = defineEmits(['update:modelValue', 'close'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
function handleClose() {
visible.value = false
emit('close')
}
</script>
<style lang="scss">
.base-dialog {
position: relative;
.close {
width: 34px;
height: 34px;
cursor: pointer;
position: absolute;
top: -34px;
right: -34px;
z-index: 9999;
}
.el-dialog__headerbtn {
display: none;
}
.el-dialog {
border-radius: 20px;
}
.el-dialog__header {
display: none;
}
}
</style>

View File

@ -0,0 +1,594 @@
<template>
<BaseDialog v-model="visible" width="400px">
<div class="login-wrapper">
<div class="login-title">
<h2 style="position: relative;z-index: 1;">登录</h2>
</div>
<div class="form-container">
<template v-if="loginType === 'phone'">
<div class="input-box" style="margin-top: 98px;">
<div class="phone-prefix">
<span>+86</span>
<div class="divider"></div>
</div>
<input type="text" v-model="form.phone" placeholder="请输入手机号" class="native-input" />
</div>
<div class="code-box-row">
<div class="input-box code-input-box">
<input type="text" v-model="form.code" placeholder="请输入验证码" class="native-input" />
</div>
<button class="send-code-btn" type="button" :disabled="sendingCode || countdown > 0" @click="sendCode">
<span class="text">{{ countdown > 0 ? `${countdown}s` : (sendingCode ? '发送中...' : '发送验证码') }}</span>
</button>
</div>
<div class="error-text" v-show="showError">请输入正确验证码</div>
</template>
<template v-else>
<div class="qrcode-wrapper">
<div class="qrcode-img">
<img v-if="qrcode" :src="qrcode" style="width: 100%; height: 100%; object-fit: cover;" alt="微信扫码登录" />
</div>
<!-- <img class="qrcode-img" src="/src/public/login/qrcode.png" alt="微信登录二维码" /> -->
<div class="qr-text">
请使用微信扫描二维码登录<br>
装样大师 网页版
</div>
</div>
</template>
<div class="agreement-row" @click="form.agreed = !form.agreed"
:style="{ 'margin-top': loginType === 'phone' ? '54px' : '0px' }">
<div class="check-icon" :class="{ 'is-checked': form.agreed }">
<img v-if="form.agreed" class="empty-circle" src="/src/public/login/agreement.png">
<img v-else class="empty-circle" src="/src/public/login/unAgreement.png">
</div>
<span class="agreement-text">我已阅读并同意<a @click.stop>用户协议</a><a @click.stop>隐私政策</a></span>
</div>
<button class="submit-btn" :class="{ 'is-loading': loading }" @click="handleLogin">登录</button>
<div class="other-methods">
<div class="line"></div>
<div class="text">其他登录方式</div>
<div class="line2"></div>
</div>
<div class="wechat-icon" @click="toggleLoginType">
<img class="login-style" src="/src/public/login/wechat-login.png" v-if="loginType === 'phone'" />
<img class="login-style" src="/src/public/login/phone-login.png" v-else />
</div>
</div>
</div>
</BaseDialog>
</template>
<script setup>
import { ref, reactive, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Close } from '@element-plus/icons-vue'
import api from '@/api'
import authService from '@/api/authService'
import { useUserStore } from '@/stores/user'
import BaseDialog from './BaseDialog.vue'
const emit = defineEmits(['success'])
const userStore = useUserStore()
const visible = ref(false)
const loading = ref(false)
const sendingCode = ref(false)
const countdown = ref(0)
let timer = null
let qrTimer = null
const qrcode = ref('')
const qrKey = ref('')
const showError = ref(false)
const loginType = ref('phone')
const form = reactive({ phone: '', code: '', agreed: false })
function open() {
visible.value = true
loginType.value = 'phone'
form.phone = localStorage.getItem('remember_phone') || ''
form.code = ''
form.agreed = false
showError.value = false
fetchQrCode()
}
async function fetchQrCode() {
try {
// Token ( Token)
if (!localStorage.getItem('token')) {
const configRes = await authService.getUserConfig()
if (configRes && configRes.data && configRes.data.token) {
localStorage.setItem('token', configRes.data.token)
}
}
const res = await authService.getLoginCode()
if (res && res.data) {
qrKey.value = res.data.key
qrcode.value = res.data.qrcode
if (qrTimer) clearInterval(qrTimer)
qrTimer = setInterval(checkQrLogin, 2000)
}
} catch (err) {
console.error('获取微信登录二维码失败:', err)
}
}
async function checkQrLogin() {
if (loginType.value !== 'wechat' || !visible.value) return
try {
const res = await authService.askLoginCode({ key: qrKey.value })
if (res && res.code === 0 && res.data && res.data.token) {
localStorage.setItem('token', res.data.token)
if (qrTimer) {
clearInterval(qrTimer)
qrTimer = null
}
visible.value = false
emit('success')
ElMessage.success('微信登录成功')
window.location.reload()
}
} catch (err) {
//
}
}
function toggleLoginType() {
loginType.value = loginType.value === 'phone' ? 'wechat' : 'phone'
}
function startCountdown() {
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
if (countdown.value > 1) {
countdown.value--
} else {
countdown.value = 0
clearInterval(timer)
timer = null
}
}, 1000)
}
const smsTimestamp = ref('')
async function sendCode() {
if (sendingCode.value || countdown.value > 0) return
const phoneReg = /^1[3-9]\d{9}$/
if (!form.phone) {
ElMessage.warning('请输入手机号')
return
}
if (!phoneReg.test(form.phone)) {
ElMessage.warning('请输入正确的11位手机号')
return
}
//
form.code = ''
showError.value = false
if (form.phone) {
localStorage.setItem('remember_phone', form.phone)
}
sendingCode.value = true
try {
const res = await authService.sendCode({ phone: form.phone })
if (res && res.data && res.data.timestamp) {
smsTimestamp.value = res.data.timestamp
}
ElMessage.success(res?.message || '验证码发送成功')
startCountdown()
} catch (err) {
console.error('发送验证码失败:', err)
} finally {
sendingCode.value = false
}
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
if (qrTimer) {
clearInterval(qrTimer)
qrTimer = null
}
})
async function handleLogin() {
showError.value = false
if (!form.phone || !form.code) {
showError.value = true
return
}
if (!form.agreed) {
ElMessage.warning('请先阅读并同意用户协议和隐私政策')
return
}
loading.value = true
try {
const loginPayload = {
type: 'phone',
data: {
phone: form.phone,
code: form.code,
timestamp: smsTimestamp.value
}
}
const res = await authService.login(loginPayload)
if (res && res.data && res.data.token) {
localStorage.setItem('token', res.data.token)
userStore.setUser(res.data, form.phone)
console.log("登录成功", res);
ElMessage.success('登录成功')
visible.value = false
emit('success')
// window.location.reload()
} else {
showError.value = true
}
} catch (err) {
showError.value = true
console.error('手机验证码登录失败:', err)
} finally {
loading.value = false
}
}
async function handleWechatLogin() {
// try {
// const res = await api.auth.wechatLoginUrl()
// if (res.oauth_url) {
// window.location.href = res.oauth_url
// // }
// } catch (err) {
// ElMessage.warning('')
// }
}
defineExpose({ open })
</script>
<style lang="scss">
.image-style-login-dialog {
border-radius: 20px !important;
background: #ffffff !important;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1) !important;
position: relative;
overflow: visible !important;
.el-dialog__header {
display: none !important;
}
.el-dialog__body {
padding: 0 !important;
overflow: visible !important;
}
}
</style>
<style lang="scss" scoped>
.custom-close-btn {
position: absolute;
top: -45px;
right: -5px;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
cursor: pointer;
z-index: 999;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
.login-wrapper {
padding: 6px 30px 40px;
display: flex;
flex-direction: column;
align-items: center;
height: 500px;
}
.login-title {
// margin-bottom: 98px;
position: relative;
text-align: center;
::before {
content: '';
display: inline-block;
width: 120px;
height: 33px;
background-image: url('@/public/index/decoration1.png');
background-size: 100%;
background-repeat: no-repeat;
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: -24px;
opacity: 0.5;
z-index: 0;
}
h2 {
margin: 0;
letter-spacing: 2px;
font-weight: 700;
font-size: 30px;
color: #1A1A1A;
line-height: 30px;
letter-spacing: 2px;
}
.title-deco {
position: absolute;
bottom: -15px;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 24px;
pointer-events: none;
}
}
.form-container {
width: 100%;
}
.input-box {
background: #D8D8D850;
border-radius: 12px;
height: 50px;
display: flex;
align-items: center;
padding: 0 16px;
margin-bottom: 18px;
.phone-prefix {
display: flex;
align-items: center;
color: #3D3D3D;
font-size: 14px;
.divider {
width: 1px;
height: 29px;
background: #CECBDC;
margin: 0 12px;
}
}
.native-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 16px;
color: #1A1A1A;
width: 100%;
&::placeholder {
color: #767676;
}
}
}
.code-box-row {
display: flex;
align-items: center;
gap: 8px;
height: 52px;
.code-input-box {
flex: 1;
margin-bottom: 0;
}
.send-code-btn {
width: 110px;
height: 50px;
border-radius: 12px;
border: 1px solid #D8D8D8;
background: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
outline: none;
.text {
font-weight: 400;
font-size: 16px;
text-align: left;
font-style: normal;
text-transform: none;
background: linear-gradient(124.63845108274714deg, #A679F4 7%, #778CF5 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
&:hover {
background: #fcfaff;
border-color: #9254ff;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
border-color: #e4e4e4;
background: #f5f5f5;
pointer-events: none;
.text {
background: none;
-webkit-text-fill-color: #999;
color: #999;
}
}
}
}
.error-text {
font-size: 12px;
color: #FF4D4F;
line-height: 10px;
margin-top: 8px;
margin-bottom: -10px;
padding-left: 15px;
}
.agreement-row {
display: flex;
align-items: center;
cursor: pointer;
.check-icon {
width: 15px;
height: 15px;
border-radius: 50%;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.empty-circle {
width: 16px;
height: 16px;
}
}
.agreement-text {
font-size: 12px;
color: #AAAAAA;
a {
color: #767676;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
.submit-btn {
width: 100%;
height: 56px;
border-radius: 27px;
background: linear-gradient(90deg, #778CF5 0%, #A679F4 100%);
font-weight: 400;
font-size: 16px;
color: #FFFFFF;
line-height: 20px;
border: none;
margin-top: 8px;
cursor: pointer;
outline: none;
&:hover {
opacity: 0.95;
}
}
.other-methods {
display: flex;
align-items: center;
justify-content: center;
margin-top: 14px;
gap: 12px;
.line {
width: 60px;
height: 2px;
background: linear-gradient(90deg, #FFFFFF 0%, #E5E5E5 100%);
}
.line2 {
width: 60px;
height: 2px;
background: linear-gradient(90deg, #E5E5E5 0%, #FFFFFF 100%);
}
.text {
font-size: 14px;
color: #767676;
line-height: 10px;
}
}
.qrcode-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 40px;
margin-bottom: 20px;
.qrcode-img {
width: 150px;
height: 150px;
margin-bottom: 18px;
object-fit: cover;
}
.qr-text {
font-size: 14px;
color: #1A1A1A;
line-height: 22px;
text-align: center;
}
}
.wechat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #7c88ff 0%, #b77bff 100%);
display: flex;
align-items: center;
justify-content: center;
margin: 15px auto 0;
cursor: pointer;
box-shadow: 0 4px 12px rgba(163, 113, 255, 0.2);
.login-style {
width: 50px;
height: 50px;
}
&:hover {
transform: translateY(-2px);
}
}
</style>

View File

@ -0,0 +1,739 @@
<template>
<BaseDialog v-model="visible" width="1000px" class="payment-base-dialog">
<div class="recharge-dialog">
<!-- 头部用户信息 -->
<div class="user-header">
<el-avatar :size="45" :src="userInfo.avatar_url || undefined">
{{ userInfo.username?.charAt(0)?.toUpperCase() }}
</el-avatar>
<div class="user-meta">
<span class="username">{{ userInfo.username || '用户' }}</span>
<span class="vip-status" :class="{ expired: !isVip }">
{{ isVip ? `会员 ${formatExpire(userInfo.membership_expire_at)}到期` : '未开通会员' }}
</span>
</div>
</div>
<div class="dialog-body">
<!-- 左侧面板 -->
<div class="left-panel">
<div v-if="!loading && productList.length" class="plans-scroll" ref="scrollContainer" @mousedown="onMouseDown"
@mouseleave="onMouseLeave" @mouseup="onMouseUp" @mousemove="onMouseMove">
<div v-for="(item, idx) in productList" :key="item.goods_id" class="plan-card"
:class="{ active: selectedProductId === item.goods_id }" @click="handleProductChange(item.goods_id)">
<div class="plan-name">{{ item.goods_name }}</div>
<div class="plan-original">&yen;{{ item.origin_price }}</div>
<div class="plan-price"><span class="currency">&yen;</span>{{ item.price }}</div>
</div>
</div>
<div v-else-if="loading" class="loading-wrap">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>加载商品...</span>
</div>
<!-- 会员权益 -->
<div class="privilege-section">
<div class="privilege-title">
<img style="width: 171px;height: 18px;" src="@/public/rechargeDialog/huiyuanquanyi.png" alt="">
</div>
<div class="privilege-grid">
<div class="scroll-row">
<div class="scroll-content right-to-left">
<div class="scroll-group">
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon1.png" alt=""> 无水印高清下载</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon2.png" alt=""> 模拟微信</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon3.png" alt=""> 工资单</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon4.png" alt=""> 证件模拟</div>
</div>
<div class="scroll-group">
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon1.png" alt=""> 无水印高清下载</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon2.png" alt=""> 模拟微信</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon3.png" alt=""> 工资单</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon4.png" alt=""> 证件模拟</div>
</div>
</div>
</div>
<div class="scroll-row">
<div class="scroll-content left-to-right">
<div class="scroll-group">
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon5.png" alt=""> 模拟飞机票</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon6.png" alt=""> 模拟高铁票</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon7.png" alt=""> 模拟购物订单</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon8.png" alt=""> 我的零钱</div>
</div>
<div class="scroll-group">
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon5.png" alt=""> 模拟飞机票</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon6.png" alt=""> 模拟高铁票</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon7.png" alt=""> 模拟购物订单</div>
<div class="p-item"><img class="icon" src="@/public/rechargeDialog/icon8.png" alt=""> 我的零钱</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧毛玻璃面板 -->
<div class="right-panel">
<div class="price-display">
<span class="currency">&yen;</span>
<span class="price-num">{{ Number(currentPrice).toFixed(2) || '0.00' }}</span>
</div>
<div class="discount-tag" v-if="currentOriginalPrice">
已优惠 &yen;{{ (currentOriginalPrice - currentPrice) }}
</div>
<div class="qr-box">
<img v-if="qrCode" :src="qrCode" alt="支付二维码" class="qr-img" />
<div v-else class="qr-placeholder" @click="handlePay">
<el-icon v-if="paying" class="is-loading">
<Loading />
</el-icon>
<span v-else>点击获取支付码</span>
</div>
</div>
<div class="pay-methods">
<div class="method-btn" :class="{ active: payMethod === 'weixin' }"
@click="handlePayMethodChange('weixin')">
<img class="icon" src="@/public/rechargeDialog/wxpay.png" alt="微信支付">
微信支付
</div>
<div class="method-btn alipay" :class="{ active: payMethod === 'alipay' }"
@click="handlePayMethodChange('alipay')">
<img class="icon" src="@/public/rechargeDialog/alipay.png" alt="支付宝">
支付宝支付
</div>
</div>
<div class="footer-tips">
<p>支付即视为同意用户协议</p>
<p>如遇到支付问题在线反馈</p>
</div>
</div>
</div>
</div>
</BaseDialog>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import BaseDialog from './BaseDialog.vue'
import { Loading, Download, ChatDotRound, Tickets, Postcard, Position, Van, ShoppingBag, Wallet } from '@element-plus/icons-vue'
import authService from '@/api/authService.js'
import { useUserStore } from '@/stores/user'
const emit = defineEmits(['success'])
const userStore = useUserStore()
const visible = ref(false)
const selectedProductId = ref(null)
const paying = ref(false)
const qrCode = ref('')
const loading = ref(false)
const productList = ref([])
const payMethod = ref('weixin')
let pollTimer = null
//
const scrollContainer = ref(null)
const isDragging = ref(false)
const startX = ref(0)
const scrollLeft = ref(0)
let hasDragged = false
const userInfo = computed(() => ({
username: userStore.username,
avatar_url: userStore.avatarUrl || '',
membership_expire_at: userStore.membershipExpireAt || null,
}))
const isVip = computed(() => userStore.isVip)
const currentPrice = computed(() => {
const p = productList.value.find(i => i.goods_id === selectedProductId.value)
return p ? p.price : ''
})
const currentOriginalPrice = computed(() => {
const p = productList.value.find(i => i.goods_id === selectedProductId.value)
return p ? (p.origin_price || '') : ''
})
function open() {
visible.value = true
selectedProductId.value = null
qrCode.value = ''
paying.value = false
loadProducts()
}
async function loadProducts() {
loading.value = true
try {
const res = await authService.getGoods()
console.log('商品列表', res)
productList.value = Array.isArray(res.data) ? res.data : []
if (productList.value.length > 0) {
//
selectedProductId.value = productList.value[0].goods_id
}
if (!productList.value.length) ElMessage.warning('暂无可售商品')
} catch (e) {
ElMessage.error(e?.response?.data?.error || '加载商品失败')
productList.value = []
} finally {
loading.value = false
}
}
/**
* 智能格式化支付二维码图片地址
* 解决微信支付二维码加载不出来的常见原因
* 1. 微信原生支付协议 (weixin://) <img> URL
* 2. Base64 字符串缺失 data:image/png;base64, 前缀
* 3. 兼容 res.data.qrcodeBase64res.data.qrcode 等不同字段
*/
function getQrCodeUrl(resData) {
if (!resData) return ''
const dataObj = resData.data || resData
//
const raw = dataObj.qrcodeBase64 || dataObj.qrcode_base64 || dataObj.qrcode || dataObj.payParam || (typeof dataObj === 'string' ? dataObj : '')
if (!raw) return ''
// 1. Data URI HTTP(S)
if (raw.startsWith('data:image/') || raw.startsWith('http://') || raw.startsWith('https://')) {
return raw
}
// 2. Base64
if (!raw.startsWith('weixin://') && !raw.startsWith('alipays://') && raw.length > 100) {
return `data:image/png;base64,${raw.replace(/\s/g, '')}`
}
// 3. (weixin://wxpay/...)
if (raw.startsWith('weixin://') || raw.startsWith('alipays://') || raw.includes('://')) {
return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(raw)}`
}
return raw
}
async function handlePay() {
if (!selectedProductId.value) return
paying.value = true
const data = {
"goods_id": selectedProductId.value, //
"pay_type": payMethod.value, // weixin/alipay/apple/bank/balance
"pay_source": "web", // appappjsapiwebh5
"source": "web", //
"extra": {
qrCodeImgUrl: "true",
}
}
try {
const res = await authService.createOrder(data)
console.log('创建订单响应结果:', res)
const orderData = res.data || res
if (payMethod.value === 'alipay') {
qrCode.value = orderData.payParam || ''
} else {
// 使 <img>
qrCode.value = getQrCodeUrl(orderData)
}
const orderNo = res.order_no || orderData.order_no || orderData.orderId
if (orderNo) {
startPolling(orderNo)
}
} catch (e) {
ElMessage.error(e?.response?.data?.error || '创建订单失败')
} finally {
paying.value = false
}
}
function handlePayMethodChange(method) {
if (payMethod.value === method) return
payMethod.value = method
if (qrCode.value) {
handlePay()
}
}
function handleProductChange(id) {
if (hasDragged) return
if (selectedProductId.value === id) return
selectedProductId.value = id
if (qrCode.value) {
handlePay()
}
}
//
function onMouseDown(e) {
if (!scrollContainer.value) return
isDragging.value = true
hasDragged = false
scrollContainer.value.style.cursor = 'grabbing'
startX.value = e.pageX - scrollContainer.value.offsetLeft
scrollLeft.value = scrollContainer.value.scrollLeft
}
function onMouseLeave() {
isDragging.value = false
if (scrollContainer.value) scrollContainer.value.style.cursor = 'grab'
}
function onMouseUp() {
isDragging.value = false
if (scrollContainer.value) scrollContainer.value.style.cursor = 'grab'
}
function onMouseMove(e) {
if (!isDragging.value || !scrollContainer.value) return
e.preventDefault()
const x = e.pageX - scrollContainer.value.offsetLeft
const walk = (x - startX.value) * 1.5 //
if (Math.abs(walk) > 5) {
hasDragged = true
}
scrollContainer.value.scrollLeft = scrollLeft.value - walk
}
function startPolling(orderNo) {
if (pollTimer) clearInterval(pollTimer)
let count = 0
pollTimer = setInterval(async () => {
count++
try {
const res = await api.payment.getOrder(orderNo)
if (res.status === 'paid') {
clearInterval(pollTimer)
ElMessage.success('支付成功,会员已开通')
setTimeout(() => {
visible.value = false
emit('success')
}, 1500)
}
} catch { }
if (count > 120) {
clearInterval(pollTimer)
ElMessage.warning('轮询超时,请刷新页面查看')
}
}, 3000)
}
function formatDate(d) {
if (!d) return '-'
return new Date(d).toLocaleString('zh-CN')
}
function formatExpire(d) {
if (!d) return ''
return new Date(d).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
}
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
defineExpose({ open })
</script>
<style lang="scss" scoped>
.recharge-dialog {
width: 1000px;
height: 704px;
background-image: url('/src/public/rechargeDialog/bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
flex-direction: column;
position: relative;
}
.user-header {
position: absolute;
top: 124px;
left: 48px;
display: flex;
align-items: center;
gap: 16px;
color: #fff;
.username {
font-weight: 500;
font-size: 18px;
color: #FFFFFF;
line-height: 18px;
}
.vip-status {
font-size: 12px;
opacity: 0.8;
}
.user-meta {
display: flex;
flex-direction: column;
justify-content: center;
.vip-status {
margin-top: 8px;
font-weight: 500;
font-size: 14px;
color: #FFFFFF;
line-height: 14px;
}
}
}
.dialog-body {
margin-top: 187px;
padding: 0 24px;
display: flex;
justify-content: space-between;
gap: 24px;
}
.left-panel {
flex-shrink: 0;
width: 677px;
height: 493px;
padding: 44px 46px;
box-sizing: border-box;
}
.plans-scroll {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 2px;
cursor: grab;
user-select: none;
&::-webkit-scrollbar {
display: none;
}
}
.plan-card {
flex: 0 0 160px;
height: 210px;
background: #F5F5FF;
border-radius: 16px 16px 16px 16px;
border: 2px solid #BFC8F8;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding-top: 30px;
transition: all 0.3s;
.plan-name {
font-size: 24px;
color: #26106B;
line-height: 21px;
font-weight: 500;
}
.plan-original {
width: 100%;
padding-left: 33px;
font-size: 20px;
color: #26106B;
line-height: 20px;
text-decoration: line-through;
margin-top: 6px;
margin-bottom: 50px;
}
.plan-price {
font-size: 55px;
color: #26106B;
line-height: 52px;
font-weight: 500;
.currency {
font-size: 26px;
color: #26106B;
line-height: 26px;
font-weight: 700;
}
}
&.active {
background: url('/src/public/rechargeDialog/active-product-bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
background-clip: padding-box;
border: none;
&::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
z-index: -1;
border-radius: 14px;
background: linear-gradient(135deg, #B17DFF, #50E5FF);
}
}
}
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 220px;
color: #999;
}
.privilege-section {
margin-top: 38px;
.privilege-title {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 34px;
.text {
font-size: 16px;
font-weight: bold;
color: #8B70FF;
}
.line {
width: 40px;
height: 1px;
background: #E0E0E0;
}
}
.privilege-grid {
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
width: 100%;
.scroll-row {
width: 100%;
overflow: hidden;
display: flex;
}
.scroll-content {
display: flex;
width: max-content;
&.right-to-left {
animation: scroll-rl 15s linear infinite;
}
&.left-to-right {
transform: translateX(-50%);
animation: scroll-lr 15s linear infinite;
}
&:hover {
animation-play-state: paused;
}
}
.scroll-group {
display: flex;
gap: 10px;
padding-right: 10px;
}
.p-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-size: 14px;
color: #1A1A1A;
line-height: 14px;
white-space: nowrap;
flex-shrink: 0;
width: 145px;
.icon {
width: 46px;
height: 46px;
}
.el-icon {
font-size: 20px;
color: #8B70FF;
}
}
}
@keyframes scroll-rl {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@keyframes scroll-lr {
0% {
transform: translateX(-50%);
}
100% {
transform: translateX(0);
}
}
}
.right-panel {
width: 251px;
height: 493px;
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
padding: 53px 16px 22px;
box-sizing: border-box;
.price-display {
color: #fff;
.currency {
font-size: 32px;
color: #FFFFFF;
line-height: 48px;
margin-right: 2px;
}
.price-num {
font-size: 52px;
color: #FFFFFF;
line-height: 48px;
}
}
.discount-tag {
padding-inline: 20px;
min-width: 120px;
height: 44px;
background: url('/src/public/rechargeDialog/discount-tag-bg.png');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
color: #fff;
padding: 0 12px;
border-radius: 20px;
margin-bottom: 28px;
font-weight: 700;
font-size: 18px;
line-height: 48px;
color: #FFFFFF;
white-space: nowrap;
}
.qr-box {
width: 158px;
height: 158px;
background: #fff;
border-radius: 10px;
padding: 15px;
margin-bottom: 20px;
box-sizing: border-box;
.qr-img {
width: 100%;
height: 100%;
}
.qr-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
cursor: pointer;
background: #f5f5f5;
border-radius: 10px;
}
}
.pay-methods {
display: flex;
gap: 12px;
margin-bottom: auto;
.method-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
font-weight: 700;
font-size: 14px;
color: #FFFFFF;
line-height: 14px;
white-space: nowrap;
cursor: pointer;
border: 1px solid transparent;
.icon {
width: 18px;
height: 18px;
}
}
.active {
background: #FFFFFF40;
}
}
.footer-tips {
text-align: center;
font-weight: 500;
font-size: 12px;
color: #FFFFFFAA;
line-height: 24px;
}
}
.payment-base-dialog {
:deep(.el-dialog) {
padding: 0 !important;
background-color: transparent;
box-shadow: 0 0 0 0 #00000000;
}
:deep(.base-dialog .close) {
top: 80px !important;
right: -32px !important;
}
}
</style>

193
src/components/ToolCard.vue Normal file
View File

@ -0,0 +1,193 @@
<template>
<div v-if="type === 'large'" class="feature-card" @click="$emit('click')">
<div class="row-top">
<img class="feature-img" :src="icon" />
<div class="text-col">
<span class="feature-title"> {{ title }} </span>
<span class="feature-desc"> {{ desc }} </span>
</div>
</div>
<div class="btn-box">
<span class="btn"> 开始模拟 </span>
</div>
</div>
<div v-else class="tool-card" @click="$emit('click')">
<img class="tool-img" :src="icon" />
<div class="tool-text">
<span class="tool-title"> {{ title }} </span>
<span class="tool-desc"> {{ desc }} </span>
</div>
</div>
</template>
<script setup>
defineProps({
icon: {
type: String,
required: true
},
title: {
type: String,
required: true
},
desc: {
type: String,
required: true
},
type: {
type: String,
default: 'small'
}
})
defineEmits(['click'])
</script>
<style lang="scss" scoped>
/* Large Card */
.feature-card {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 72px 35px 28px 44px;
position: relative;
width: 550px;
height: 300px;
border-radius: 19px;
background-color: #ffffff;
transition: box-shadow 0.3s ease;
cursor: pointer;
&:hover {
box-shadow: 0px 0px 25px 0px rgba(0, 0, 0, 0.2);
}
.row-top {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
.feature-img {
width: 154px;
height: 154px;
border-radius: 20px;
margin-right: 12px;
}
.text-col {
display: flex;
flex-direction: column;
}
.feature-title {
font-weight: 700;
font-size: 30px;
color: #1A1A1A;
line-height: 30px;
margin-bottom: 30px;
font-family: Source Han Sans, Source Han Sans;
}
.feature-desc {
font-family: Source Han Sans, Source Han Sans;
font-weight: 400;
font-size: 24px;
color: #767676;
line-height: 24px;
}
.btn-box {
width: 100%;
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: auto;
.btn {
width: 120px;
height: 36px;
line-height: 36px;
text-align: center;
font-size: 18px;
color: #fff;
font-weight: 700;
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
border-radius: 10px;
}
}
}
/* Small Card */
.tool-card {
position: relative;
width: 370px;
height: 200px;
border-radius: 15px;
background-color: #f7f8ff;
display: flex;
flex-direction: row;
align-items: flex-start;
isolation: isolate;
margin-top: 0;
cursor: pointer;
&:hover {
background: #FFFFFF;
box-shadow: 0px 0px 20px 0px #E7E8F3;
}
.tool-img {
width: 100px;
height: 100px;
border-radius: 15px;
object-fit: cover;
z-index: 1;
position: relative;
margin-left: 35px;
margin-top: 50px;
}
.tool-text {
position: relative;
display: flex;
width: 198px;
height: 200px;
flex-direction: column;
align-items: flex-start;
z-index: 0;
margin-left: 12px;
margin-top: 0;
.tool-title {
width: 127px;
height: 36px;
position: relative;
font-size: 24px;
white-space: pre;
margin-top: 63px;
margin-bottom: -4.5px;
margin-left: calc(50% - 99px);
margin-right: auto;
font-weight: 700;
color: #1A1A1A;
line-height: 24px;
}
.tool-desc {
width: 100%;
height: 30px;
position: relative;
white-space: pre;
margin-top: 23px;
margin-bottom: -4px;
margin-left: auto;
margin-right: auto;
font-size: 20px;
color: #767676;
line-height: 20px;
}
}
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<div class="warning-footer">
<img src="@/public/warning.png" class="warning-icon" />
<span class="warning-text">本工具生成的截图仅供娱乐参考禁止用于任何非法用途</span>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.warning-footer {
margin: 0 auto;
max-width: 2000px;
min-width: 1200px;
height: 54px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
text-align: center;
background-color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
z-index: 100;
pointer-events: none;
.warning-icon {
width: 20px;
height: 20px;
}
.warning-text {
font-weight: 400;
font-size: 16px;
color: #767676;
line-height: 18px;
}
}
</style>

View File

@ -0,0 +1,309 @@
<template>
<PhoneMockup :theme="settings.theme" :class="{ 'ios-font': settings.phoneModel === 'ios' }">
<!-- Status Bar -->
<StatusBar :time="settings.statusTime" :battery="settings.battery" :signal="settings.signal" :wifi="settings.wifi"
:is-charging="settings.isCharging" :theme="settings.theme" />
<!-- Nav Bar -->
<div class="nav-bar">
<img class="back-icon" :src="`/src/public/common/back-${settings.theme}.png`" alt="">
<span class="right-title">{{ settings.pageTitle || '零钱明细' }}</span>
</div>
<!-- Content -->
<div class="page-content">
<div class="user-info">
<img class="money-icon" src="@/public/balance/money.png" alt="">
<p class="nick-name">{{ settings.nickName || '我的零钱' }}</p>
</div>
<div class="amount-display wx-font-regular">
<span class="amount-symbol"></span>
<span class="amount-num">{{ (settings.amount ?? 0).toFixed(2) }}</span>
</div>
<div v-if="settings.showLimit" class="limit-warrp">
<div class="limit-text">
<img class="icon" src="@/public/balance/warning.png" alt="">
<span>支付额度剩余<span class="wx-font-regular">¥{{ Number(settings.limitAmount || 0).toFixed(2)
}}</span>了解解决办法</span>
<el-icon size="14">
<ArrowRight color="#E75E63" />
</el-icon>
</div>
</div>
<template v-else>
<div v-if="settings.showLiqitong" class="liqitong-card">
<img src="@/public/balance/lingtong-logo.png" class="lt-icon" />
<div class="lt-info">
<p class="lt-desc">{{ settings.liqitongDesc || '转入零钱通,能赚又能花' }}</p>
<p class="lt-rate">零钱通 {{ settings.liqitongRate || '七日年化1.17%' }}</p>
</div>
<el-icon size="16">
<ArrowRight color="#AEAEAE" />
</el-icon>
</div>
<div v-else class="lingtong-text">
<span>转入零钱通能赚又能花</span>
<el-icon size="16">
<ArrowRight color="#FA9D3B" />
</el-icon>
</div>
</template>
<div v-if="settings.showFAQ" class="faq-section">
<div class="btn-group">
<button v-if="settings.showChargeBtn" class="action-btn primary">充值</button>
<button v-if="settings.showWithdrawBtn" class="action-btn secondary">提现</button>
</div>
<p class="faq-title">常见问题</p>
<p class="faq-desc">{{ settings.serviceText || '本服务由财付通提供' }}</p>
</div>
</div>
<!-- Home Bar -->
<HomeBar />
</PhoneMockup>
</template>
<script setup>
import PhoneMockup from '@/components/simulator/PhoneMockup.vue'
import StatusBar from '@/components/simulator/StatusBar.vue'
import HomeBar from '@/components/simulator/homeBar.vue'
defineProps({ settings: { type: Object, default: () => ({}) } })
</script>
<style lang="scss" scoped>
$w: 393px;
.ios-font {
font-family: -apple-system, "PingFang SC", "Helvetica Neue", Helvetica, Arial, sans-serif !important;
}
.nav-bar {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 0 12px;
background: var(--wx-bg-primary);
color: var(--wx-text-primary);
.back-icon {
width: 24px;
height: 24px;
}
.right-title {
font-size: 16px;
color: var(--wx-text-primary);
line-height: 23px;
text-align: right;
}
}
.page-content {
flex: 1;
padding: 55px 20px;
background: var(--wx-bg-primary);
color: var(--wx-text-primary);
.user-info {
text-align: center;
margin-bottom: 21px;
.money-icon {
width: 52px;
height: 52px;
}
.nick-name {
margin-top: 32px;
font-size: 16px;
color: var(--wx-text-primary);
line-height: 24px;
}
}
.amount-display {
text-align: center;
display: flex;
align-items: flex-start;
justify-content: center;
.amount-symbol {
font-weight: 700;
font-size: 30px;
color: var(--wx-text-primary);
line-height: 40px;
// margin-top: 3px;
}
.amount-num {
font-weight: 500;
font-size: 46px;
color: var(--wx-text-primary);
line-height: 40px;
margin-left: 8px;
}
}
.limit-warrp {
display: flex;
justify-content: center;
}
.limit-text {
height: 34px;
display: inline-flex;
align-items: center;
font-weight: 400;
font-size: 14px;
color: var(--wx-limit-text-red);
line-height: 24px;
background: var(--wx-limit-bg-red);
border-radius: 4px 4px 4px 4px;
padding: 9px 4px 9px 8px;
margin: 24px auto 0;
.icon {
width: 16px;
height: 16px;
margin-right: 6px;
}
}
.liqitong-card {
position: relative;
display: flex;
align-items: center;
padding: 12px 0;
border-radius: 8px;
margin-bottom: 24px;
margin-top: 34px;
&::before {
content: '';
position: absolute;
top: 1px;
left: 0;
width: 100%;
height: 1px;
background-color: var(--wx-line-color);
transform: scaleY(0.5);
}
&::after {
content: '';
position: absolute;
bottom: 1px;
left: 0;
width: 100%;
height: 1px;
background-color: var(--wx-line-color);
transform: scaleY(0.5);
}
.lt-icon {
width: 48px;
height: 48px;
border-radius: 50%;
margin-right: 8px;
}
.lt-info {
flex: 1;
.lt-desc {
font-weight: 400;
font-size: 16px;
color: var(--wx-text-primary);
}
.lt-rate {
font-weight: 400;
font-size: 13px;
color: var(--wx-text-primary);
margin-top: 1px;
}
}
.lt-arrow {
font-size: 20px;
color: #ccc;
}
}
.lingtong-text {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
margin-top: 18px;
font-weight: 400;
font-size: 15px;
color: #FA9D3B;
line-height: 24px;
}
.btn-group {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 65px;
.action-btn {
border: none;
border-radius: 8px;
font-size: 16px;
padding: 13px;
width: 175px;
height: 46px;
font-weight: 500;
cursor: pointer;
&.primary {
background: var(--wx-btn-primary);
font-size: 16px;
color: #FFFFFF;
line-height: 24px;
}
&.secondary {
background: var(--wx-btn-secondary);
font-size: 16px;
color: var(--wx-text-primary);
line-height: 24px;
}
}
}
.faq-section {
text-align: center;
position: absolute;
bottom: 53px;
left: 50%;
transform: translateX(-50%);
.faq-title {
font-weight: 400;
font-size: 13px;
color: var(--wx-desc-text-blue);
line-height: 20px;
}
.faq-desc {
font-weight: 400;
font-size: 11px;
color: var(--wx-desc-text-grey);
line-height: 20px;
margin-top: 4px;
}
}
}
</style>

View File

@ -0,0 +1,931 @@
<template>
<PhoneMockup :theme="settings.theme" :class="{ 'ios-font': settings.phoneModel === 'ios' }">
<!-- Status Bar -->
<StatusBar :time="settings.statusTime" :battery="settings.battery" :signal="settings.signal" :wifi="settings.wifi"
:is-charging="settings.isCharging" :theme="settings.theme"
:bgColor="settings.theme == 'light' ? '#ECECEC' : '#111111'" />
<!-- Nav Bar -->
<div class="nav-bar">
<div class="between-box left-box">
<img class="back-icon" :src="`/src/public/common/back-${settings.theme}.png`" alt="">
<span v-if="settings.unread > 0" class="unread">{{ settings.unread }}</span>
</div>
<div class="center-box" :style="{ 'margin-right': !settings.earpiece && !settings.notDisturb ? '0' : '14px' }">
<div class="title-wrapper">
<div class="text-primary">{{ settings.theirName }}</div>
<div class="icons-wrapper">
<img v-if="settings.earpiece" class="icon tingtong" src="@/public/wx-chat/tingtong.png" alt="">
<img v-if="settings.notDisturb" class="icon miandarao" src="@/public/wx-chat/miandarao.png" alt="">
</div>
</div>
</div>
<div class="between-box right-box">
<img class="back-icon" :src="`/src/public/common/more-${settings.theme}.png`" alt="">
</div>
</div>
<!-- Chat Area -->
<div class="chat-area" :style="chatAreaStyle">
<!-- Messages -->
<div v-for="(msg, idx) in messages" :key="msg.id" :class="['msg-row', msg.type, msg.sender]">
<!-- Time divider -->
<div v-if="msg.type === 'time'" class="flex-row flex-1" style="justify-content: center;display: flex;">
<div class="time-divider" :class="{ 'has-bg': settings.wallpaper, 'dark': settings.theme == 'dark' }">
{{ formatTimeDisplay(msg.timeText) }}
</div>
</div>
<!-- System message -->
<div v-else-if="msg.type === 'system'" class="flex-row flex-1" style="justify-content: center;display: flex;">
<div class="system-msg" :class="{ 'has-bg': settings.wallpaper, 'dark': settings.theme == 'dark' }"
v-html="msg.content"></div>
</div>
<!-- Text message -->
<div v-else-if="msg.type === 'text'" class="bubble-wrap" :class="msg.sender">
<img :src="getAvatar(msg.sender)" class="avatar" alt="" />
<div class="bubble text-bubble" :class="{ 'failed': msg.status === 'failed' && msg.sender === 'me' }">
<span v-html="msg.content"></span>
</div>
</div>
<!-- Red packet -->
<div v-else-if="msg.type === 'redpacket'" class="bubble-wrap" :class="msg.sender">
<img :src="getAvatar(msg.sender)" class="avatar" alt="" />
<div class="bubble redpacket-bubble"
:class="{ 'failed': msg.status === 'failed' && msg.sender === 'me', 'rp-received': msg.status != 'send' }">
<div class="rp-content flex-row flex-1">
<div class="rp-icon">
<img style="width: 100%;height: 100%;"
:src="`/src/public/wx-chat/${msg.status == 'received' ? 'open-red-bag' : 'red-bag'}${settings.theme === 'dark' ? '-dark' : ''}.png`"
alt="">
</div>
<div class="flex column">
<span class="rp-text">{{ truncateText(msg.title || '恭喜发财,大吉大利', 144, 16) }}</span>
<span v-if="msg.status != 'send'" class="rp-desc-text">{{ msg.status == 'received' ? '已领取' : '已过期'
}}</span>
</div>
</div>
<div class="rp-label">微信红包</div>
</div>
</div>
<!-- Transfer -->
<div v-else-if="msg.type === 'transfer'" class="bubble-wrap" :class="msg.sender">
<img :src="getAvatar(msg.sender)" class="avatar" alt="" />
<div class="bubble transfer-bubble" :class="{
'failed': msg.status === 'failed' && msg.sender === 'me',
'tr-received': msg.status === 'received' || msg.status === 'returned',
'tr-expired': msg.status === 'expired'
}">
<div class="rp-content tr-content-box flex-row flex-1">
<div class="tr-icon">
<img v-if="msg.status == 'expired'" style="width: 100%;height: 100%;"
:src="`/src/public/wx-chat/zhuanzhang-yiguoqi${settings.theme === 'dark' ? '-dark' : ''}.png`" alt="">
<img v-else-if="msg.status == 'received'" style="width: 100%;height: 100%;"
:src="`/src/public/wx-chat/zhuanzhang-yijieshou${settings.theme === 'dark' ? '-dark' : ''}.png`"
alt="">
<img v-else-if="msg.status == 'returned'" style="width: 100%;height: 100%;"
:src="`/src/public/wx-chat/zhuanzhang-yituihuan${settings.theme === 'dark' ? '-dark' : ''}.png`"
alt="">
<img v-else style="width: 100%;height: 100%;"
:src="`/src/public/wx-chat/zhuanzhang-weijieshou${settings.theme === 'dark' ? '-dark' : ''}.png`"
alt="">
</div>
<div class="tr-content">
<div class="tr-amount wx-font-regular"> <span class="symbol">¥</span>{{ (msg.amount || 0).toFixed(2) }}
</div>
<div class="tr-remark">{{ truncateText(msg.remark || '你发起了一笔转账', 155, 12) }}</div>
</div>
</div>
<div class="tr-label">微信转账</div>
</div>
</div>
<!-- Call Message (Video/Audio) -->
<div v-else-if="msg.type === 'call'" class="bubble-wrap" :class="msg.sender">
<img :src="getAvatar(msg.sender)" class="avatar" alt="" />
<div class="bubble text-bubble" style="display: flex; align-items: center; gap: 6px; padding: 10px 14px;">
<template v-if="msg.sender === 'me'">
<span style="font-size: 15px;">{{ getCallText(msg) }}</span>
<img v-if="msg.callType === 'video'" class="call-icon" src="@/public/wx-chat/video-light.png" alt="">
<img v-else class="call-icon" src="@/public/wx-chat/voice-light.png" alt="">
</template>
<template v-else>
<img v-if="msg.callType === 'video'" class="call-icon other"
:src="`/src/public/wx-chat/video-${settings.theme === 'dark' ? 'dark' : 'light'}.png`" alt="">
<img v-else class="call-icon other"
:src="`/src/public/wx-chat/voice-${settings.theme === 'dark' ? 'dark' : 'light'}.png`" alt="">
<span style="font-size: 15px;">{{ getCallText(msg) }}</span>
</template>
</div>
</div>
<!-- Voice Message (Hold-to-talk) -->
<div v-else-if="msg.type === 'voice'" class="bubble-wrap" :class="msg.sender">
<img :src="getAvatar(msg.sender)" class="avatar" alt="" />
<div class="bubble text-bubble voice-msg"
:class="{ 'failed': msg.status === 'failed' && msg.sender === 'me' }"
:style="`display: flex; align-items: center; gap: 2px; padding: 10px 14px; width: ${getVoiceWidth(msg.duration)}px;`">
<template v-if="msg.sender === 'me'">
<span style="font-size: 15px; flex: 1;text-align: right;">{{ msg.duration || 1 }}"</span>
<img class="voice-msg-icon" src="@/public/wx-chat/voice-msg-light.png" alt="">
</template>
<template v-else>
<img class="voice-msg-icon other"
:src="`/src/public/wx-chat/voice-msg-${settings.theme === 'dark' ? 'dark' : 'light'}.png`" alt="">
<span style="font-size: 15px; flex: 1; text-align: left;">{{ msg.duration || 1 }}"</span>
</template>
</div>
<!-- Unread red dot -->
<div v-if="msg.sender === 'other' && msg.status === 'unread'"
style="width: 8px; height: 8px; background-color: #F35651; border-radius: 50%; margin-left: 11px; margin-top: 16px;">
</div>
<div v-if="msg.sender === 'other' && msg.id === firstUnreadVoiceId" class="voice-text"
:class="settings.theme">
<span>转文字</span>
</div>
</div>
</div>
</div>
<!-- Input Bar -->
<div class="input-bar" v-if="settings.showInput !== false">
<img class="icon" :src="`/src/public/wx-chat/keyboard-${settings.theme}.png`" alt="">
<div class="input-field">
<span v-if="settings.voiceMode" class="text">按住 说话</span>
<img v-if="settings.showMicIcon" class="mic" src="@/public/wx-chat/mic.png" alt="">
</div>
<img class="icon" :src="`/src/public/wx-chat/sticker-${settings.theme}.png`" alt="">
<img class="icon" :src="`/src/public/wx-chat/add-${settings.theme}.png`" alt="">
</div>
<!-- Home Bar -->
<HomeBar v-if="settings.showBottomBar !== false" />
</PhoneMockup>
</template>
<script setup>
import { computed } from 'vue'
import PhoneMockup from '@/components/simulator/PhoneMockup.vue'
import StatusBar from '@/components/simulator/StatusBar.vue'
import HomeBar from '@/components/simulator/homeBar.vue'
const props = defineProps({
messages: { type: Array, default: () => [] },
settings: { type: Object, default: () => ({}) },
})
const displayTitle = computed(() => {
return props.settings.title || props.settings.theirName || '微信'
})
const firstUnreadVoiceId = computed(() => {
const msg = props.messages.find(m => m.type === 'voice' && m.sender === 'other' && m.status === 'unread')
return msg ? msg.id : null
})
function getAvatar(sender) {
if (sender === 'me') return props.settings.myAvatarUrl || '/src/public/header/logo-img.png'
return props.settings.theirAvatarUrl || '/src/public/common/default-avatar.png'
}
function getVoiceWidth(dur) {
let d = parseInt(dur, 10) || 1
if (d > 60) d = 60
if (d <= 1) return 70
let w = 70
if (d <= 10) {
w += (d - 1) * 8
} else {
w += 9 * 8 // 1-10
w += (d - 10) * 8 / 10
}
return w
}
function getCallText(msg) {
const isMe = msg.sender === 'me'
if (msg.status === 'connected') {
let dur = msg.duration || '00:00'
// 8 12 mm:ss
if (/^\d+$/.test(dur)) {
const d = parseInt(dur, 10)
const m = Math.floor(d / 60).toString().padStart(2, '0')
const s = (d % 60).toString().padStart(2, '0')
dur = `${m}:${s}`
}
return `通话时长 ${dur}`
}
if (msg.status === 'refused') return isMe ? '对方已拒绝' : '已拒绝'
if (msg.status === 'missed') return isMe ? '对方无应答' : '未应答'
if (msg.status === 'cancelled') return isMe ? '已取消' : '对方已取消'
if (msg.status === 'unread' || msg.status === 'read') return `${msg.duration || 1}''`
return '中断通话'
}
function truncateText(text, maxWidth, fontSize) {
if (!text) return ''
let totalWidth = 0
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i)
totalWidth += (charCode > 255) ? fontSize : (fontSize * 0.55)
}
if (totalWidth <= maxWidth) return text
let width = 0
let result = ''
// '...' fontSize
const limit = maxWidth - fontSize
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i)
// / fontSize fontSize * 0.55
const charWidth = (charCode > 255) ? fontSize : (fontSize * 0.55)
if (width + charWidth > limit) {
return result + '...'
}
width += charWidth
result += text[i]
}
return text
}
function formatTimeDisplay(timeText) {
if (!timeText) return ''
let dateStr = timeText
if (typeof dateStr === 'string') {
dateStr = dateStr.replace(/年|月/g, '/').replace(/日/g, '')
dateStr = dateStr.replace(/-/g, '/')
}
let date = new Date(dateStr)
if (isNaN(date.getTime())) {
if (/^\s*\d{1,2}:\d{1,2}(:\d{1,2})?\s*$/.test(timeText)) {
const timeStr = timeText.trim()
const today = new Date()
const yyyy = today.getFullYear()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
date = new Date(`${yyyy}/${mm}/${dd} ${timeStr}`)
}
if (isNaN(date.getTime())) {
return timeText
}
}
const now = new Date()
const targetDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffTime = todayDate.getTime() - targetDate.getTime()
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24))
const hhmm = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0')
if (diffDays === 0) {
return hhmm
} else if (diffDays === 1) {
return `昨天 ${hhmm}`
} else if (diffDays > 1 && diffDays < 7) {
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return `${days[date.getDay()]} ${hhmm}`
} else if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}${date.getDate()}${hhmm}`
} else {
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${hhmm}`
}
}
const chatAreaStyle = computed(() => {
if (!props.settings.wallpaper) return undefined
return {
backgroundImage: `url(${props.settings.wallpaper})`,
backgroundSize: 'cover',
}
})
</script>
<style lang="scss" scoped>
.ios-font {
font-family: -apple-system, "PingFang SC", "Helvetica Neue", Helvetica, Arial, sans-serif !important;
}
.flex-row {
display: flex;
}
.flex-1 {
flex: 1;
}
.nav-bar {
position: relative;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
background: var(--wx-bg-secondary);
color: var(--wx-text-primary);
.left-box {
display: flex;
align-items: center;
.unread {
padding: 2px 6px;
font-size: 13px;
color: var(--wx-text-primary);
line-height: 16px;
background-color: var(--wx-chat-unread-bg);
border-radius: 508px;
height: 20px;
margin-left: -1px;
}
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 1px;
background-color: var(--wx-chat-line-color);
transform: scaleY(0.5);
}
.back-icon {
width: 24px;
height: 24px;
}
.right-title {
font-size: 16px;
color: var(--wx-text-primary);
line-height: 23px;
text-align: right;
}
.right-box {
text-align: right;
padding-right: 10px;
}
.between-box {
width: 60px;
}
.center-box {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
margin-left: 6px;
.title-wrapper {
position: relative;
display: flex;
align-items: center;
max-width: calc(100%);
/* 给绝对定位的图标留出空间,防止重叠右侧按钮 */
}
.text-primary {
color: var(--wx-text-primary);
font-size: 16px;
line-height: 16px;
overflow: hidden;
white-space: nowrap;
}
.icons-wrapper {
position: absolute;
left: 100%;
display: flex;
align-items: center;
}
.icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
}
.chat-area {
flex: 1;
overflow-y: auto;
background: var(--wx-bg-secondary);
padding: 12px;
min-height: 0;
&::-webkit-scrollbar {
width: 0;
}
.time-divider {
text-align: center;
color: var(--wx-chat-time);
font-size: 12px;
line-height: 18px;
padding: 0 3px;
border-radius: 4px;
margin: 8px 0;
}
.system-msg {
text-align: center;
color: #767676;
font-size: 13px;
padding: 0px 10px;
line-height: 20px;
border-radius: 4px;
margin: 0 8px;
&.dark {
color: #AAAAAA;
}
}
.has-bg {
background-color: var(--wx-chat-time-bg);
box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.2);
}
.msg-row {
margin-bottom: 12px;
}
.bubble-wrap {
display: flex;
align-items: flex-start;
&.me {
margin-left: 50px;
flex-direction: row-reverse;
.avatar {
margin-left: 10px;
}
}
&.other {
margin-right: 50px;
.avatar {
margin-right: 10px !important;
}
}
.avatar {
width: 38px;
height: 38px;
border-radius: 4px;
flex-shrink: 0;
margin: 0;
object-fit: cover;
}
.voice-text {
padding: 3px 7px;
background-color: #E1E1E1;
border-radius: 243px 243px 243px 243px;
font-size: 11px;
color: #A1A1A1;
line-height: 12px;
margin-top: 11px;
margin-left: 8px;
&.dark {
background-color: #1D1D1D;
color: #767676;
}
}
}
.bubble {
padding: 8px 10px;
position: relative;
font-size: 16px;
line-height: 1.5;
word-break: break-all;
word-wrap: break-word;
white-space: pre-wrap;
border-radius: 4px;
}
.text-bubble {
p {
margin: 0;
}
.call-icon {
width: 26px;
height: 16px;
margin-left: 2px;
&.other {
margin-right: 2px;
margin-left: 0;
}
}
.voice-msg-icon {
width: 16px;
height: 16px;
&.other {
transform: rotate(180deg);
}
}
&.voice-msg {
display: flex;
justify-content: flex-end !important;
min-width: 80px !important;
}
}
.bubble-wrap.other .text-bubble {
background: var(--wx-chat-bubble-other);
color: var(--wx-text-primary);
font-size: 16px;
line-height: 22px;
&::after {
content: "";
position: absolute;
top: 14px;
left: -4px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-top: 3px solid transparent;
border-right: 3px solid var(--wx-chat-bubble-other);
border-bottom: 6px solid var(--wx-chat-bubble-other);
transform: rotate(135deg);
border-bottom-right-radius: 2px;
}
}
.bubble-wrap.me .text-bubble {
background: var(--wx-chat-bubble-me);
color: #1A1A1A;
&::after {
content: "";
position: absolute;
top: 14px;
right: -4px;
width: 0;
height: 0;
border-left: 6px solid var(--wx-chat-bubble-me);
border-top: 3px solid var(--wx-chat-bubble-me);
border-right: 3px solid transparent;
border-bottom: 6px solid transparent;
transform: rotate(135deg);
border-top-left-radius: 2px;
}
}
.bubble-wrap.me .bubble {
&.failed {
position: relative;
&::before {
content: "";
position: absolute;
background: url('/src/public/wx-chat/send-filled.png');
background-size: 100% 100%;
width: 20px;
height: 20px;
left: -26px;
top: 50%;
transform: translateY(-50%);
}
}
}
.redpacket-bubble,
.transfer-bubble {
height: 82px;
width: 230px;
background: var(--wx-redpacket-bg);
color: var(--wx-redpacket-text);
padding: 10px 13px;
border-radius: 4px;
position: relative;
.rp-content {
position: relative;
display: flex;
align-items: center;
padding-bottom: 10px;
gap: 18px;
}
.tr-content-box {
gap: 8px;
padding-bottom: 6px;
position: relative;
}
.rp-icon,
.tr-icon {
width: 36px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.tr-icon {
width: 43px;
height: 43px;
}
.rp-text {
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
line-height: 20px;
}
.rp-desc-text {
font-size: 11px;
margin-top: 5px;
color: var(--wx-redpacket-redpacket-text);
line-height: 14px;
}
.tr-remark {
font-size: 13px;
color: var(--wx-redpacket-text);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 160px;
/* Use width instead of max-width for html2canvas */
}
.rp-label,
.tr-label {
position: absolute;
bottom: 6px;
left: 13px;
font-size: 11px;
color: var(--wx-redpacket-desc-text);
line-height: 12px;
}
padding-bottom: 24px;
}
.redpacket-bubble.rp-received {
background-color: var(--wx-redpacket-received-bg);
color: var(--wx-redpacket-redpacket-text);
.rp-label {
color: var(--wx-redpacket-redpacket-text);
}
}
.transfer-bubble {
padding: 12px 11px;
.tr-amount {
font-size: 16px;
color: var(--wx-redpacket-text);
line-height: 21px;
display: flex;
align-items: center;
justify-content: flex-start;
.symbol {
display: block;
width: 15px;
height: 21px;
text-align: center;
line-height: 21px;
}
}
.tr-remark {
line-height: 16px;
margin-left: 2px;
}
&.tr-received {
background-color: var(--wx-transfer-received-bg);
.tr-amount {
color: var(--wx-transfer-received-text);
}
.tr-remark {
color: var(--wx-transfer-received-text);
}
.tr-label {
color: var(--wx-transfer-received-text);
}
}
&.tr-expired {
background-color: var(--wx-transfer-expired-bg);
.tr-amount {
color: var(--wx-transfer-expired-text);
}
.tr-remark {
color: var(--wx-transfer-expired-text);
}
.tr-label {
color: var(--wx-transfer-expired-text);
}
}
}
.bubble-wrap.other .redpacket-bubble,
.bubble-wrap.other .transfer-bubble {
&::after {
content: "";
position: absolute;
top: 14px;
left: -4px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-top: 3px solid transparent;
border-right: 3px solid var(--wx-redpacket-bg);
border-bottom: 6px solid var(--wx-redpacket-bg);
transform: rotate(135deg);
border-bottom-right-radius: 2px;
}
&.rp-received::after {
border-left: 6px solid transparent;
border-top: 3px solid transparent;
border-right: 3px solid var(--wx-redpacket-received-bg);
border-bottom: 6px solid var(--wx-redpacket-received-bg);
}
&.tr-received::after {
border-left: 6px solid transparent;
border-top: 3px solid transparent;
border-right: 3px solid var(--wx-transfer-received-bg);
border-bottom: 6px solid var(--wx-transfer-received-bg);
}
&.tr-expired::after {
border-left: 6px solid transparent;
border-top: 3px solid transparent;
border-right: 3px solid var(--wx-transfer-expired-bg);
border-bottom: 6px solid var(--wx-transfer-expired-bg);
}
}
.bubble-wrap.me .redpacket-bubble,
.bubble-wrap.me .transfer-bubble {
&::after {
content: "";
position: absolute;
top: 14px;
right: -4px;
width: 0;
height: 0;
border-left: 6px solid var(--wx-redpacket-bg);
border-top: 3px solid var(--wx-redpacket-bg);
border-right: 3px solid transparent;
border-bottom: 6px solid transparent;
transform: rotate(135deg);
border-top-left-radius: 2px;
}
&.rp-received::after {
border-left: 6px solid var(--wx-redpacket-received-bg);
border-top: 3px solid var(--wx-redpacket-received-bg);
border-right: 3px solid transparent;
border-bottom: 6px solid transparent;
}
&.tr-received::after {
border-left: 6px solid var(--wx-transfer-received-bg);
border-top: 3px solid var(--wx-transfer-received-bg);
border-right: 3px solid transparent;
border-bottom: 6px solid transparent;
}
&.tr-expired::after {
border-left: 6px solid var(--wx-transfer-expired-bg);
border-top: 3px solid var(--wx-transfer-expired-bg);
border-right: 3px solid transparent;
border-bottom: 6px solid transparent;
}
}
}
.input-bar {
position: relative;
background: var(--wx-chat-input-bg-color);
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
padding-bottom: 42px;
padding-top: 8px;
.icon {
width: 30px;
height: 30px;
}
&::after {
content: '';
position: absolute;
top: 0px;
left: 0;
width: 100%;
height: 1px;
background-color: var(--wx-chat-line-color);
transform: scaleY(0.5);
}
.input-icon {
font-size: 26px;
cursor: pointer;
color: var(--wx-chat-line-color);
}
.input-field {
flex: 1;
position: relative;
height: 38px;
background: var(--wx-chat-bubble-other);
border-radius: 4px;
border: none;
margin-right: -3px;
display: flex;
align-items: center;
justify-content: center;
.text {
font-size: 16px;
color: #1A1A1A;
line-height: 21px;
font-weight: 500;
}
.mic {
width: 26px;
height: 26px;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
}
.add-icon {
width: 28px;
height: 28px;
background: var(--wx-bg-primary);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: bold;
}
}
</style>
<style>
.flex.column {
display: flex;
flex-direction: column;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<div class="phone-box">
<img class="phone-bg" src="@/public/simulator/ios-phone.png" alt="">
<div class="iphone-frame" :class="theme" id="capture-frame">
<slot></slot>
</div>
<div class="watermark-overlay"></div>
</div>
</template>
<script setup>
defineProps({
theme: {
type: String,
default: 'light',
}
})
</script>
<style lang="scss" scoped>
$w: 375px;
.phone-box {
width: 395px;
height: 824px;
position: relative;
transform-origin: top center;
transform: scale(calc(285/395));
margin-bottom: calc(824px * (285 / 395) - 824px);
.iphone-frame {
position: relative;
width: 375px;
height: 812.98px;
border-radius: 40px;
background-color: var(--wx-bg-primary);
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-bg {
position: absolute;
width: 395px;
height: 824px;
top: -6px;
left: -10px;
}
.watermark-overlay {
position: absolute;
top: 0;
left: 0;
width: 375px;
height: 812.98px;
border-radius: 40px;
background-image: url('@/public/common/watermaker.png');
background-size: 125px 125px;
background-repeat: repeat;
pointer-events: none;
z-index: 999;
}
}
</style>

View File

@ -0,0 +1,326 @@
<template>
<div class="editor-page">
<div class="workspace">
<!-- 左侧预览区 -->
<div class="preview-area col" ref="previewAreaRef">
<div class="preview-scroll">
<slot name="preview">
<!-- <PhoneMockup>
<StatusBar />
</PhoneMockup> -->
<BalancePreview> </BalancePreview>
</slot>
</div>
</div>
<!-- 右侧配置区 -->
<div class="panel-area col">
<div class="panel-header">
<div class="tabs-container">
<div v-for="tab in tabs" :key="tab.value" class="tab-item" :class="{ active: activeTab === tab.value }"
@click="handleTabClick(tab)">
{{ tab.label }}
</div>
</div>
<div>
<slot name="actions">
<div class="btn-box">
<img src="@/public/simulator/save-btn.png" alt="" class="save-btn btn" @click="$emit('save')">
<img src="@/public/simulator/export-btn.png" alt="" class="export-btn btn" @click="$emit('export')">
</div>
</slot>
</div>
</div>
<div class="panel-scroll">
<div class="setting-list">
<slot name="config"></slot>
</div>
</div>
</div>
</div>
</div>
<WarningFooter />
</template>
<script setup>
import { ref } from 'vue'
import { FolderAdd, Download } from '@element-plus/icons-vue'
import WarningFooter from '@/components/WarningFooter.vue'
import PhoneMockup from '@/components/simulator/PhoneMockup.vue'
import StatusBar from '@/components/simulator/StatusBar.vue'
defineProps({
tabs: {
type: Array,
default: () => [{ label: '编辑设置', value: 'edit' }]
},
activeTab: {
type: String,
default: 'edit'
}
})
const emit = defineEmits(['save', 'export', 'update:activeTab', 'tab-click'])
function handleTabClick(tab) {
emit('update:activeTab', tab.value)
emit('tab-click', tab)
}
const previewAreaRef = ref()
// ref 使
defineExpose({ previewAreaRef })
</script>
<style lang="scss" scoped>
.editor-page {
height: calc(100vh - 166px);
overflow: hidden;
display: flex;
flex-direction: column;
background-color: #FAFAFB;
}
.workspace {
display: flex;
gap: 16px;
flex: 1;
padding: 40px 10px 80px;
min-width: 1400px;
max-width: 1440px;
margin: 0 auto;
overflow: hidden;
}
.preview-area {
flex: 0 0 350px;
position: relative;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
.preview-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 24px;
}
}
/* ===== 列统一样式 ===== */
.col {
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 0 18px;
padding-left: 56px;
height: 49px;
background: rgba(255, 255, 255, .85);
border-bottom: 1px solid #D8D8D8;
.tabs-container {
display: flex;
align-items: center;
height: 100%;
gap: 30px;
.tab-item {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
color: #1A1A1A;
line-height: 20px;
padding: 0 14px;
cursor: pointer;
position: relative;
transition: color 0.2s;
&:hover {
color: #666;
}
&.active {
color: #333;
font-weight: bold;
&::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 3px;
background-color: #5AC878;
border-radius: 3px 3px 0 0;
}
}
}
}
.btn-box {
gap: 16px;
display: flex;
align-items: center;
.btn {
height: 30px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.85;
}
}
}
}
.panel-scroll {
flex: 1;
overflow-y: auto;
max-height: 580px;
padding: 18px 68px 0;
}
}
/* 编辑设置面板 - 浅紫主题 */
.panel-area {
flex: 1;
width: 1000px;
background-color: #ffffff;
box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.1);
border-radius: 18px !important;
/* 浅紫 */
/* 穿透样式让插槽内部也能复用相同的配置项样式 */
:deep(.setting-list) {
.section-label {
font-weight: bold;
font-size: 16px;
color: #1A1A1A;
}
.status-row {
display: flex;
gap: 10px;
.setting-item {
margin-bottom: 0;
margin-right: 40px;
}
}
.section-divider {
height: 1px;
background: rgba(94, 53, 177, .12);
margin: 18px 0 14px;
}
.setting-item {
margin-bottom: 14px;
label {
display: block;
font-weight: 400;
font-size: 14px;
color: #767676;
line-height: 20px;
margin-top: 16px;
margin-bottom: 8px;
}
.el-input__wrapper,
.el-radio-group,
.el-input-number,
.el-select__wrapper,
.rich-editor {
width: 100%;
height: 30px;
border-radius: 4px;
// box-shadow: 0 1px 3px #D8D8D880, 0 0 0 1px #D8D8D8;
transition: box-shadow .2s;
&:hover {
box-shadow: 0 2px 6px rgba(94, 53, 177, .12), 0 0 0 1px rgba(94, 53, 177, .1) inset;
}
&.is-focus,
&.is-focused,
&:focus {
box-shadow: 0 0 0 1px rgba(103, 58, 183, .4) inset;
outline: none;
}
}
.is-checked {
.el-switch__core {
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
border: none;
}
}
.el-select {
width: 100%;
}
.el-time-picker {
width: 100%;
}
.el-input-number {
.el-input__inner {
text-align: left;
}
}
.el-checkbox__label {
color: #555;
font-size: 13px;
}
.el-slider {
.el-slider__bar {
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
}
.el-slider__button {
width: 14px;
height: 14px;
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
border: none;
}
span {
font-weight: 400;
font-size: 14px !important;
color: #1A1A1A !important;
line-height: 20px !important;
}
}
}
.switch-item {
display: flex;
align-items: center;
justify-content: space-between;
label {
margin-bottom: 0;
}
}
}
}
</style>

View File

@ -0,0 +1,146 @@
<template>
<div class="status-bar" :class="{ 'dark-mode': theme === 'dark' }" :style="{ 'background-color': bgColor }">
<span class="time">{{ time }}</span>
<span class="status-icons">
<img class="signal" :src="`/src/public/status/signal-${theme === 'dark' ? 'dark-' + signal : signal}.png`"
alt="" />
<img class="wifi" :src="`/src/public/status/wifi-${theme === 'dark' ? 'dark-' + wifi : wifi}.png`" alt="" />
<div class="battery">
<div class="power" :class="{ 'full-power': battery >= 100 }"
:style="{ 'background-color': isCharging ? '#2DC752' : 'var(--battery-bg)', width: battery + '%' }">
</div>
<div class="percent">
<span class="battery-text" :style="{ 'color': isCharging ? '#ffffff' : 'var(--battery-color)' }">
{{ battery }}</span>
<img v-if="isCharging" class="charging" src="/src/public/status/charging.png" alt="" />
</div>
</div>
<img style="width: 1px;height: 4px;margin-left: 1px;" src="/src/public/status/battery.png" alt="" />
</span>
</div>
</template>
<script setup>
defineProps({
time: {
type: String,
default: '12:00'
},
battery: {
type: [String, Number],
default: 60
},
signal: {
type: [String, Number],
default: 4
},
wifi: {
type: [String, Number],
default: 3
},
isCharging: {
type: Boolean,
default: true
},
theme: {
type: String,
default: 'light'
},
bgColor: {
type: String,
default: 'transparent'
}
})
</script>
<style lang="scss" scoped>
.status-bar {
height: 50px;
display: flex;
justify-content: space-between;
// align-items: center;
font-size: 14px;
font-weight: 600;
padding: 20px 31px 16px 44px;
color: var(--wx-icon-color);
background: transparent;
flex-shrink: 0;
z-index: 10;
.time {
font-weight: 500;
font-size: 17px;
color: var(--wx-text-primary);
line-height: 14px;
}
.status-icons {
font-size: 11px;
display: flex;
align-items: center;
color: var(--wx-icon-color);
.signal {
width: 20px;
height: 12px;
}
.wifi {
width: 17px;
height: 12px;
margin-left: 6px;
}
.battery {
position: relative;
width: 24px;
height: 13px;
background: #A7A7A7;
border-radius: 4px 4px 4px 4px;
font-weight: 700;
font-size: 11px;
color: #FFFFFF;
line-height: 11px;
margin-left: 6px;
text-align: center;
.power {
height: 100%;
display: block;
border-radius: 4px 0 0 4px;
&.full-power {
border-radius: 4px;
}
}
.percent {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
.battery-text {
display: inline-block;
font-weight: 700;
font-size: 11px;
color: #FFFFFF;
margin-top: -1px;
}
.charging {
display: inline-block;
width: 7px;
height: 9px;
// margin-top: 1px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<div class="home-bar">
<div class="nav"></div>
</div>
</template>
<script>
</script>
<style lang="scss" scoped>
.home-bar {
width: 100%;
height: 34px;
position: absolute;
bottom: 0;
left: 0;
display: flex;
justify-content: center;
.nav {
height: 5px;
width: 136px;
margin-top: 23px;
background-color: var(--ipone-bottom-bar);
border-radius: 143px;
}
}
</style>

28
src/config.json Normal file
View File

@ -0,0 +1,28 @@
{
"title": "快存图",
"baseUrl": "/kct",
"messageDuration": 3000,
"requestTimeout": 60000,
"successCode": [
200,
0
],
"invalidCode": [
1001106,
1001003
],
"port": 9270 ,
"rbBuldInfo": {
"api": "http://rb2.batiao8.com/console/custom/deploy/f5b5219cce4a7d47a60416ef4d3fa891",
"key": "bNJXlzwc"
},
"policyUrl": "kct/static/policy",
"policyUrl2": "kct/static/policy2_web",
"goodsType":"member",
"ignoreCodeErrorList": ["GET&/api/weixin/qrcode","GET&/api/app/code", "GET&/api/order"],
"hideConsole": true,
"downloadSuccessCode":[200,201,202,203,204,205,206],
"configIgnoreParams": ["inputvalue"]
}

39
src/main.js Normal file
View File

@ -0,0 +1,39 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from './router'
import { createPinia } from 'pinia'
import App from './App.vue'
import './styles/global.scss'
import authService from '@/api/authService'
const app = createApp(App)
// Register all Element Plus icons
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn })
app.use(createPinia())
app.use(router)
app.mount('#app')
// 后台异步获取配置与游客 Token不阻塞首屏渲染
authService.getUserConfig().then(res => {
if (res && res.data) {
console.log("用户配置", res.data)
// 只有在本地没有 token 时,才写入游客 token
if (res.data.token && !localStorage.getItem('token')) {
localStorage.setItem('token', res.data.token)
}
if (res.data.config) {
window.__USER_CONFIG__ = res.data.config
}
}
}).catch(err => {
console.error('获取用户配置失败:', err)
})

12
src/proxy.cjs Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
'/api': {
target: 'https://flaunt.batiao8.com',
changeOrigin: true,
rewrite: (path) => path
},
'/static': {
target: 'http://wechat.wenyitu.com',
changeOrigin: true,
rewrite: (path) => path
}
}

BIN
src/public/about-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
src/public/common/touzi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
src/public/direct-inbox.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

BIN
src/public/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

BIN
src/public/header/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

13
src/public/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>关于我们</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
src/public/index/left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
src/public/index/right.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

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