This commit is contained in:
艾贤凌
2026-03-16 12:05:55 +08:00
parent af3a7c83e8
commit 6d4a72161f
33 changed files with 5671 additions and 178 deletions

View File

@@ -1,23 +1,69 @@
import {createRouter, createWebHistory} from 'vue-router'
import Index from "../views/index.vue";
import { createRouter, createWebHistory } from 'vue-router'
// 需要登录才能访问的路由名称
const AUTH_ROUTES = ['Index', 'Withdraw']
// 不需要登录即可访问的路由名称(白名单)
const PUBLIC_ROUTES = ['Login', 'LinuxdoBind', 'Agree']
// 示例路由配置
const routes = [
{
path: '/',
name: 'Home',
component: Index,
redirect: '/login'
},
{
path: '/login', name: 'Login',
path: '/login',
name: 'Login',
component: () => import('@/views/login.vue')
}
},
{
path: '/linuxdo-bind',
name: 'LinuxdoBind',
component: () => import('@/views/linuxdo-bind.vue')
},
{
path: '/index',
name: 'Index',
component: () => import('@/views/index.vue'),
meta: { requiresAuth: true }
},
{
path: '/play',
name: 'Play',
redirect: '/index',
},
{
path: '/agree',
name: 'Agree',
component: () => import('@/views/agree.vue')
},
{
path: '/withdraw',
name: 'Withdraw',
component: () => import('@/views/withdraw.vue'),
meta: { requiresAuth: true }
},
]
const router = createRouter({
history: createWebHistory(),
routes
routes,
})
// ─── 全局路由守卫 ─────────────────────────────────────────────────────────────
router.beforeEach((to, from, next) => {
const token = sessionStorage.getItem('CQ-TOKEN')
const isLoggedIn = !!token
if (to.meta?.requiresAuth && !isLoggedIn) {
// 需要登录但未登录 → 跳转登录页
next({ path: '/login', query: { redirect: to.fullPath } })
} else if (to.name === 'Login' && isLoggedIn) {
// 已登录访问登录页 → 跳转游戏主页
next({ path: '/index' })
} else {
next()
}
})
export default router

View File

@@ -1,13 +1,35 @@
import axios from "axios";
import axios from 'axios'
import { ElMessage } from 'element-plus'
const ins = axios.create({
baseURL: '/',
timeout: 10000,
timeout: 15000,
})
// 请求拦截:自动附带 token
ins.interceptors.request.use(config => {
const token = sessionStorage.getItem('CQ-TOKEN')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
ins.interceptors.response.use(res => {
return res.data
})
// 响应拦截:统一处理错误
ins.interceptors.response.use(
res => {
return res.data
},
err => {
if (err.response?.status === 401) {
sessionStorage.removeItem('CQ-TOKEN')
ElMessage.error('登录已过期,请重新登录')
window.location.href = '/login'
} else {
ElMessage.error(err.response?.data?.message || '网络错误,请稍后重试')
}
return Promise.reject(err)
}
)
export default ins

View File

@@ -0,0 +1,113 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const content = ref('')
const loading = ref(true)
const error = ref('')
onMounted(async () => {
try {
const res = await fetch('/api/misc/agree')
if (!res.ok) throw new Error('请求失败')
content.value = await res.text()
} catch (e) {
error.value = '用户协议加载失败,请稍后重试'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="agree-page pagebg">
<div class="agree-box">
<div class="header">
<button class="back-btn" @click="router.back()"> 返回</button>
<h2>用户协议及隐私协议</h2>
</div>
<div v-if="loading" class="state-tip">加载中</div>
<div v-else-if="error" class="state-tip error">{{ error }}</div>
<div v-else class="content" v-html="content" />
</div>
</div>
</template>
<style lang="scss" scoped>
.agree-page {
min-height: 100vh;
background: #0a0a0a;
display: flex;
justify-content: center;
padding: 20px;
box-sizing: border-box;
.agree-box {
width: 100%;
max-width: 800px;
color: #ddd;
.header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
h2 {
color: #f5bd10;
font-size: 18px;
margin: 0;
}
.back-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.7);
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
&:hover {
border-color: #f5bd10;
color: #f5bd10;
}
}
}
.state-tip {
text-align: center;
padding: 60px 0;
color: rgba(255, 255, 255, 0.4);
font-size: 15px;
&.error {
color: #ff6b6b;
}
}
.content {
line-height: 1.8;
font-size: 14px;
:deep(h1), :deep(h2), :deep(h3) {
color: #f5bd10;
margin-top: 20px;
}
:deep(p) {
margin: 8px 0;
}
:deep(a) {
color: #f5bd10;
}
}
}
}
</style>

View File

@@ -1,10 +1,77 @@
<script setup>
import Loading from "../components/loading.vue";
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import request from '@/utils/request'
import Loading from '../components/loading.vue'
const router = useRouter()
const error = ref('')
const ready = ref(false)
/**
* 解析 JWT payload无需依赖 jwt 库,仅用于读取 username/server_id
*/
function parseJwt(token) {
try {
const base64Url = token.split('.')[1]
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(decodeURIComponent(escape(atob(base64))))
} catch {
return null
}
}
onMounted(async () => {
const token = sessionStorage.getItem('CQ-TOKEN')
if (!token) {
router.replace('/login')
return
}
// 解析 JWT 获取账号信息
const payload = parseJwt(token)
if (!payload?.username) {
sessionStorage.removeItem('CQ-TOKEN')
router.replace('/login')
return
}
const account = payload.username
const password = payload.password // md5 密码哈希,用于游戏客户端鉴权
const serverId = payload.server_id || 1
// 调用 enter_game 接口,更新登录信息
try {
await request.post('/api/enter_game', {
account,
srvId: serverId,
})
} catch {
// enter_game 失败不阻断进入游戏
}
// 将账号/token/区服信息挂载到全局,供 Egret 游戏引擎读取
window.__CQ_ACCOUNT__ = account
window.__CQ_TOKEN__ = password
window.__CQ_SERVER_ID__ = serverId
// 兼容旧版游戏引擎读取方式(通过 localStorage / cookie
try {
localStorage.setItem('account', account)
localStorage.setItem('token', password)
localStorage.setItem('serverId', String(serverId))
} catch {}
ready.value = true
})
</script>
<template>
<loading/>
<div v-if="error" style="color:#f00;text-align:center;padding-top:40vh;font-size:18px;">
{{ error }}
<br />
<a style="color:#f5bd10;cursor:pointer" @click="$router.replace('/login')">重新登录</a>
</div>
<Loading v-else />
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,170 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import request from '@/utils/request'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const connectId = ref('')
const account = ref('')
const password = ref('')
const submitting = ref(false)
const bindMode = ref('register') // 'register' | 'link'
onMounted(() => {
connectId.value = route.query.connect_id || ''
if (!connectId.value) {
ElMessage.error('缺少授权参数,请重新登录')
router.push('/login')
}
})
async function bindRegister() {
submitting.value = true
try {
const res = await request.post('/api/linuxdo/bind', {
connect_id: connectId.value,
action: 'register',
})
if (res.code === 0) {
sessionStorage.setItem('CQ-TOKEN', res.token || '')
ElMessage.success('绑定成功,正在进入游戏...')
setTimeout(() => {
window.location.href = `/play?account=${res.account}&token=${res.token}`
}, 1200)
} else {
ElMessage.error(res.message)
}
} finally {
submitting.value = false
}
}
async function bindLink() {
if (!account.value) return ElMessage.error('请输入账号')
if (!password.value) return ElMessage.error('请输入密码')
submitting.value = true
try {
const res = await request.post('/api/linuxdo/bind', {
connect_id: connectId.value,
account: account.value,
password: password.value,
action: 'link',
})
if (res.code === 0) {
sessionStorage.setItem('CQ-TOKEN', res.token || '')
ElMessage.success('绑定成功,正在进入游戏...')
setTimeout(() => {
window.location.href = `/play?account=${res.account}&token=${res.token}`
}, 1200)
} else {
ElMessage.error(res.message)
}
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="linuxdo-bind pagebg">
<div class="bind-box">
<h2>LinuxDo 账号绑定</h2>
<p class="sub">LinuxDo账号<strong>{{ connectId }}</strong></p>
<div class="tabs">
<span :class="{ active: bindMode === 'register' }" @click="bindMode = 'register'">
直接绑定自动创建账号
</span>
<span :class="{ active: bindMode === 'link' }" @click="bindMode = 'link'">
绑定已有账号
</span>
</div>
<div v-if="bindMode === 'register'" class="tab-content">
<p>将以 LinuxDo 账号名自动创建游戏账号并绑定</p>
<button :disabled="submitting" @click="bindRegister">
{{ submitting ? '处理中' : '确认绑定' }}
</button>
</div>
<div v-else class="tab-content">
<input v-model="account" type="text" placeholder="请输入游戏账号" autocomplete="off" />
<input v-model="password" type="password" placeholder="请输入游戏密码" @keyup.enter="bindLink" />
<button :disabled="submitting" @click="bindLink">
{{ submitting ? '处理中' : '绑定并登录' }}
</button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.linuxdo-bind {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0a0a0a;
.bind-box {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
padding: 32px;
width: 360px;
color: #fff;
h2 { text-align: center; color: #f5bd10; margin-bottom: 8px; }
.sub { text-align: center; color: rgba(255,255,255,0.6); font-size: 13px; margin-bottom: 20px; }
.tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 20px;
span {
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 13px;
cursor: pointer;
color: rgba(255,255,255,0.5);
&.active { color: #f5bd10; border-bottom: 2px solid #f5bd10; }
}
}
.tab-content {
display: flex;
flex-direction: column;
gap: 12px;
p { font-size: 13px; color: rgba(255,255,255,0.6); }
input {
height: 36px;
padding: 0 10px;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 6px;
background: rgba(255,255,255,0.08);
color: #fff;
outline: none;
&::placeholder { color: rgba(255,255,255,0.35); }
&:focus { border-color: #f5bd10; }
}
button {
height: 40px;
border-radius: 8px;
border: none;
background: #f5bd10;
color: #000;
font-weight: bold;
font-size: 15px;
cursor: pointer;
letter-spacing: 4px;
&:hover { opacity: 0.85; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
}
}
}
</style>

View File

@@ -1,114 +1,573 @@
<script setup>
import {defineOptions, onMounted, ref} from "vue";
import request from "@/utils/request";
import {ElMessage} from "element-plus";
import { defineOptions, onMounted, ref, computed, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
defineOptions({name: 'Login'});
defineOptions({ name: 'Login' })
const servers = ref([])
const router = useRouter()
// 表单模式0=登录 1=注册 2=找回密码
const mode = ref(0)
const modeName = computed(() => ({ 0: '登 录', 1: '注 册', 2: '修改密文' }[mode.value]))
// 表单字段
const account = ref('')
const password = ref('')
const password2 = ref('')
const email = ref('')
const code = ref('')
const serverId = ref('')
const agree = ref(false)
const srvId = ref('')
// 区服列表
const servers = ref([])
// 验证码倒计时
const codeCountdown = ref(0)
let countdownTimer = null
// 提交中
const submitting = ref(false)
// 游戏配置(从后端获取)
const gameConfig = ref({
gameName: '清渊传奇',
codeOpen: false,
regCodeOpen: false,
linuxdoAuthorizeUrl: '/api/linuxdo/authorize',
})
async function loadConfig() {
try {
const res = await request.get('/api/config')
if (res?.data) gameConfig.value = { ...gameConfig.value, ...res.data }
} catch {}
}
async function loadServers() {
try {
const res = await request.get('/api/server/list')
if (res?.code === 0) {
const list = res.serverlist?.[0]?.serverlist || []
servers.value = list
if (list.length > 0) serverId.value = list[0].srvid
}
} catch {}
}
// ─── 登录 ─────────────────────────────────────────────────────────────────────
async function handleLogin() {
if (!agree.value) return ElMessage.error('请勾选同意用户协议')
const {data} = await request.post('/api/login', {
username: account,
password
})
sessionStorage.setItem("CQ-TOKEN", data.token)
if (!account.value) return ElMessage.error('请输入账号')
if (!password.value) return ElMessage.error('请输入密码')
submitting.value = true
try {
const res = await request.post('/api/login', { username: account.value, password: password.value })
if (res.code === 0) {
sessionStorage.setItem('CQ-TOKEN', res.token)
ElMessage.success(res.message || '登录成功')
setTimeout(() => router.push('/'), 1500)
} else {
ElMessage.error(res.message || '登录失败')
}
} finally {
submitting.value = false
}
}
async function getServers() {
const {data} = await request.get('/api/server/list')
servers.value = data
// ─── 注册 ─────────────────────────────────────────────────────────────────────
async function handleRegister() {
if (!agree.value) return ElMessage.error('请勾选同意用户协议')
if (!account.value) return ElMessage.error('请输入账号')
if (!password.value) return ElMessage.error('请输入密码')
if (!password2.value) return ElMessage.error('请再次输入密码')
if (password.value !== password2.value) return ElMessage.error('两次密码不一致!')
if (!serverId.value) return ElMessage.error('请选择区服!')
if (gameConfig.value.codeOpen && gameConfig.value.regCodeOpen) {
if (!email.value) return ElMessage.error('请输入邮箱地址!')
if (!code.value) return ElMessage.error('请输入验证码!')
}
submitting.value = true
try {
const res = await request.post('/api/register', {
username: account.value,
password: password.value,
password2: password2.value,
serverId: serverId.value,
email: email.value,
code: code.value,
})
if (res.code === 0) {
ElMessageBox.alert(res.message, '注册成功', { type: 'success', confirmButtonText: '开始游戏' })
.then(() => {
sessionStorage.setItem('CQ-TOKEN', res.token)
router.push('/')
})
} else {
ElMessage.error(res.message || '注册失败')
}
} finally {
submitting.value = false
}
}
function protectProtocol() {
const agree = document.getElementById('agree')
const agreeBtn = document.querySelector('#agree .agree_btn')
agree.addEventListener('click', function () {
agreeBtn.classList.toggle('agree_btn_checked')
})
// ─── 找回密码 ──────────────────────────────────────────────────────────────────
async function handleResetPassword() {
if (!account.value) return ElMessage.error('请输入账号')
if (!email.value) return ElMessage.error('请输入邮箱地址')
if (!password.value) return ElMessage.error('请输入新密码')
if (!password2.value) return ElMessage.error('请再次输入新密码')
if (password.value !== password2.value) return ElMessage.error('两次密码不一致!')
if (!code.value) return ElMessage.error('请输入验证码!')
submitting.value = true
try {
const res = await request.post('/api/reset_password', {
username: account.value,
email: email.value,
password: password.value,
password2: password2.value,
code: code.value,
})
if (res.code === 0) {
ElMessage.success(res.message || '密码修改成功!')
switchMode(0)
} else {
ElMessage.error(res.message || '操作失败')
}
} finally {
submitting.value = false
}
}
// ─── 发送验证码 ────────────────────────────────────────────────────────────────
async function sendCode() {
if (codeCountdown.value > 0) return
if (!account.value) return ElMessage.error('请先输入账号')
if (!email.value) return ElMessage.error('请先输入邮箱地址')
try {
const res = await request.post('/api/send_code', {
username: account.value,
email: email.value,
type: mode.value === 1 ? 1 : 2,
})
if (res.code === 0) {
ElMessage.success(res.message)
codeCountdown.value = res.time || 60
countdownTimer = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) clearInterval(countdownTimer)
}, 1000)
} else {
ElMessage.error(res.message)
if (res.time) {
codeCountdown.value = res.time
countdownTimer = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) clearInterval(countdownTimer)
}, 1000)
}
}
} catch {}
}
// ─── 提交总入口 ────────────────────────────────────────────────────────────────
function handleSubmit() {
if (mode.value === 0) handleLogin()
else if (mode.value === 1) handleRegister()
else handleResetPassword()
}
// ─── 切换模式 ──────────────────────────────────────────────────────────────────
function switchMode(m) {
mode.value = m
password.value = ''
password2.value = ''
code.value = ''
if (m !== 1) email.value = ''
}
// ─── 查看用户协议 ──────────────────────────────────────────────────────────────
async function showProtocol() {
try {
const res = await fetch('/api/misc/agree')
const html = await res.text()
ElMessageBox({
title: '用户协议及隐私协议',
message: `<div style="max-height:60vh;overflow:auto;font-size:12px;">${html}</div>`,
dangerouslyUseHTMLString: true,
showCancelButton: false,
confirmButtonText: '我已阅读',
})
} catch {}
}
// ─── LinuxDo 登录 ──────────────────────────────────────────────────────────────
function loginWithLinuxDo() {
window.location.href = gameConfig.value.linuxdoAuthorizeUrl
}
onMounted(() => {
getServers()
loadConfig()
loadServers()
})
onUnmounted(() => {
clearInterval(countdownTimer)
})
</script>
<template>
<div class="login wrapper pagebg">
<div class="loginBox flex column p-8 gap-8" id="account-login">
<b class="title mb-20">神临苍月</b>
<input class="w100" type="text" id="account" v-model="account" placeholder="请输入账号" @keyup="v=>account=v.replace(/[\W]/g, '')" autocomplete="off"/>
<input class="w100" type="password" id="password" v-model="password" placeholder="请输入密码"/>
<el-select id="serverId" v-model="srvId" style="border: none; display: none; margin-bottom: 10px;">
<option value="0">请选择区服</option>
<option v-for="item in servers" :value="item.id">{{ item.name }}</option>
</el-select>
<div id="agree" class="agree flex gap-4">
<el-checkbox v-model="agree"/>
我已阅读并同意 <a @click="protectProtocol">用户协议及隐私协议</a>
<div class="login">
<div class="loginBox" id="account-login">
<b class="title">{{ gameConfig.gameName }}</b>
<!-- 账号 -->
<input
class="w100"
type="text"
v-model="account"
placeholder="请输入账号"
@input="account = account.replace(/[^\w]/g, '')"
autocomplete="off"
maxlength="16"
/>
<!-- 密码 -->
<input
class="w100"
type="password"
v-model="password"
:placeholder="mode === 2 ? '请输入新密码' : '请输入密码'"
maxlength="16"
@keyup.enter="handleSubmit"
/>
<!-- 确认密码注册/找回密码 -->
<input
v-if="mode !== 0"
class="w100"
type="password"
v-model="password2"
:placeholder="mode === 2 ? '请再次输入新密码' : '请再次输入密码'"
maxlength="16"
/>
<!-- 区服选择注册 -->
<select v-if="mode === 1" v-model="serverId" class="w100 server-select">
<option value="">请选择区服</option>
<option v-for="item in servers" :key="item.srvid" :value="item.srvid">
{{ item.serverName }}
</option>
</select>
<!-- 邮箱注册+验证码开启 / 找回密码 -->
<input
v-if="(mode === 1 && gameConfig.codeOpen) || mode === 2"
class="w100"
type="email"
v-model="email"
:placeholder="mode === 2 ? '请输入绑定的邮箱地址' : '邮箱地址(用于找回密码,选填)'"
/>
<!-- 验证码注册+强制验证 / 找回密码 -->
<div
v-if="(mode === 1 && gameConfig.codeOpen && gameConfig.regCodeOpen) || mode === 2"
class="code-row"
>
<input class="flex-1" type="text" v-model="code" placeholder="请输入验证码" maxlength="6" />
<button class="code-btn" :disabled="codeCountdown > 0" @click="sendCode">
{{ codeCountdown > 0 ? `${codeCountdown}s 后重发` : '获取验证码' }}
</button>
</div>
<div class="button pointer w100 py-8" @click="handleLogin"> </div>
<div style="display:flex;justify-content:center;gap:8px;font-size:12px">
<div style="display:flex;align-items:center;flex-direction:column;gap:4px;cursor:pointer" id="linuxdoConnect">
<img src="/img/linuxdo_logo.png" style="width:60px;height:60px" alt="Linux.Do登录"/>
<div>Linux.do</div>
<!-- 同意协议登录/注册 -->
<div v-if="mode !== 2" class="agree">
<el-checkbox v-model="agree" />
<span>我已阅读并同意 <a class="link" @click="showProtocol">用户协议及隐私协议</a></span>
</div>
<!-- 主按钮 -->
<div
class="button"
:class="{ disabled: submitting }"
@click="handleSubmit"
>
{{ submitting ? '处理中…' : modeName }}
</div>
<!-- 第三方登录 -->
<div v-if="mode === 0" class="third-login">
<div class="third-item" @click="loginWithLinuxDo">
<img src="/img/linuxdo_logo.png" alt="Linux.Do 登录" />
<div>Linux.Do</div>
</div>
</div>
<div class="forget_password">
<a href="javascript:void(0);" id="forgetPassword" data-type="2">忘记密码?</a>
<a href="javascript:void(0);" class="pull-right" id="switchBtn" data-type="1">注册</a>
<!-- 底部切换 -->
<div class="footer-links">
<template v-if="mode === 0">
<a class="link" @click="switchMode(2)">忘记密码?</a>
<a class="link" @click="switchMode(1)">注册</a>
</template>
<template v-else>
<a class="link" @click="switchMode(0)">返回登录</a>
</template>
</div>
</div>
<div id="bg" class="gamebg"/>
</div>
</template>
<style lang="scss" scoped>
/* ── 页面容器 ─────────────────────────────────────────────────── */
.login {
position: relative;
background-image: url("/img/login_bg.jpg");
background-image: url('/img/login_bg.jpg');
background-repeat: no-repeat;
background-size: 100% 100%;
background-size: cover;
background-position: center;
height: 100vh;
min-height: 100vh;
min-height: 100dvh; /* 兼容移动端动态视口 */
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
.loginBox {
position: absolute;
width: 300px;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
backdrop-filter: blur(4px);
/* ── 登录卡片 ─────────────────────────────────────────────────── */
.loginBox {
width: 100%;
max-width: 400px;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 14px;
padding: 28px 24px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
& > .title {
font-size: 28px
/* 手机竖屏:紧贴两侧,去掉多余圆角 */
@media (max-width: 480px) {
max-width: 100%;
padding: 20px 16px;
border-radius: 12px;
gap: 10px;
}
}
/* ── 标题 ─────────────────────────────────────────────────────── */
.title {
font-size: 26px;
color: #f5bd10;
text-align: center;
font-weight: bold;
margin-bottom: 4px;
letter-spacing: 2px;
@media (max-width: 480px) {
font-size: 22px;
}
}
/* ── 表单元素 ─────────────────────────────────────────────────── */
input, select {
width: 100%;
height: 42px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 15px; /* 防止 iOS Safari 自动缩放(< 16px 会触发) */
outline: none;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
&::placeholder {
color: rgba(255, 255, 255, 0.38);
}
&:focus {
border-color: #f5bd10;
background: rgba(255, 255, 255, 0.12);
}
option {
color: #333;
background: #fff;
}
@media (max-width: 480px) {
height: 44px; /* 更大的点击区域 */
}
}
.server-select {
cursor: pointer;
}
/* ── 验证码行 ──────────────────────────────────────────────────── */
.code-row {
display: flex;
gap: 8px;
input {
flex: 1;
min-width: 0;
}
.code-btn {
flex-shrink: 0;
padding: 0 14px;
height: 42px;
border-radius: 8px;
border: 1px solid #f5bd10;
background: transparent;
color: #f5bd10;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
transition: background 0.2s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button {
background: rgb(245, 189, 16);
text-align: center;
letter-spacing: 8px;
border-radius: 8px;
cursor: pointer;
&:hover {
opacity: .8
}
&:not(:disabled):hover {
background: rgba(245, 189, 16, 0.15);
}
.agree_btn {
width: 16px;
}
& > input {
height: 32px;
@media (max-width: 480px) {
height: 44px;
padding: 0 10px;
font-size: 12px;
}
}
}
/* ── 协议勾选 ──────────────────────────────────────────────────── */
.agree {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
.link {
color: #f5bd10;
cursor: pointer;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
/* ── 主按钮 ────────────────────────────────────────────────────── */
.button {
width: 100%;
height: 46px;
background: #f5bd10;
color: #000;
text-align: center;
line-height: 46px;
letter-spacing: 6px;
border-radius: 10px;
cursor: pointer;
font-weight: bold;
font-size: 15px;
transition: opacity 0.2s;
-webkit-tap-highlight-color: transparent;
user-select: none;
&:hover {
opacity: 0.85;
}
&:active {
opacity: 0.7;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 480px) {
height: 48px;
line-height: 48px;
letter-spacing: 4px;
}
}
/* ── 第三方登录 ────────────────────────────────────────────────── */
.third-login {
display: flex;
justify-content: center;
gap: 20px;
.third-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
-webkit-tap-highlight-color: transparent;
transition: color 0.2s;
&:hover, &:active {
color: #fff;
}
img {
border-radius: 10px;
width: 46px;
height: 46px;
@media (max-width: 480px) {
width: 40px;
height: 40px;
}
}
}
}
/* ── 底部链接 ──────────────────────────────────────────────────── */
.footer-links {
display: flex;
justify-content: space-between;
font-size: 13px;
.link {
color: #f5bd10;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:hover {
text-decoration: underline;
}
}
}
/* ── 通用工具类 ────────────────────────────────────────────────── */
.flex { display: flex; }
.flex-1 { flex: 1; }
.column { flex-direction: column; }
.align-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-4 { gap: 4px; }
.gap-8 { gap: 8px; }
.w100 { width: 100%; }
.mb-20 { margin-bottom: 4px; }
.p-8 { padding: 8px; }
.py-8 { padding-top: 10px; padding-bottom: 10px; }
.pointer { cursor: pointer; }
</style>

View File

@@ -0,0 +1,400 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
// 账号信息(从 JWT 解析)
const currentAccount = ref('')
const currentServerId = ref(1)
// 表单
const serverId = ref('')
const roleId = ref('')
const roleName = ref('')
const payType = ref(0) // 0=支付宝 1=微信
const payAccount = ref('')
const amount = ref('')
// 区服列表
const servers = ref([])
// 游戏配置
const gameConfig = ref({})
const submitting = ref(false)
// 计算兑换金额
const money = computed(() => {
const ratio = gameConfig.value?.withdrawRatio || 10000
const a = parseInt(amount.value)
if (!a || a < ratio) return 0
return Math.floor(a / ratio)
})
const currencyName = computed(() => gameConfig.value?.currencyName || '货币')
/**
* 解析 JWT payload
*/
function parseJwt(token) {
try {
const base64Url = token.split('.')[1]
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(decodeURIComponent(escape(atob(base64))))
} catch {
return null
}
}
async function loadConfig() {
try {
const res = await request.get('/api/config')
if (res?.data) {
gameConfig.value = {
...gameConfig.value,
gameName: res.data.gameName,
withdrawRatio: res.data.withdrawRatio || 10000,
withdrawMinOnce: res.data.withdrawMinOnce || 20,
currencyName: res.data.currencyName || '货币',
}
}
} catch {}
}
async function loadServers() {
try {
const res = await request.get('/api/server/list')
if (res?.code === 0) {
servers.value = res.serverlist?.[0]?.serverlist || []
}
} catch {}
}
async function handleWithdraw() {
if (!serverId.value) return ElMessage.error('请选择区服')
if (!roleId.value) return ElMessage.error('请输入角色 ID')
if (!roleName.value) return ElMessage.error('请输入角色名')
if (!payAccount.value) return ElMessage.error('请输入收款账户')
const a = parseInt(amount.value)
if (!a || a <= 0) return ElMessage.error('请输入提现数量')
const ratio = gameConfig.value?.withdrawRatio || 10000
const minOnce = gameConfig.value?.withdrawMinOnce || 20
if (a < ratio * minOnce) return ElMessage.error(`单次提现数量不能低于 ${ratio * minOnce}`)
const payTypeName = payType.value === 0 ? '支付宝' : '微信'
try {
await ElMessageBox.confirm(
`确认提现 ${a} ${currencyName.value},折合人民币 ${money.value} 元?\n收款方式${payTypeName}${payAccount.value}`,
'确认提现',
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
const res = await request.post('/api/game/withdraw', {
server_id: 's' + serverId.value,
account: currentAccount.value,
role_id: roleId.value,
role_name: roleName.value,
pay_type: payType.value,
pay_account: payAccount.value,
amount: a,
})
if (res.code === 0) {
ElMessageBox.alert(res.message, '提现成功', { type: 'success', confirmButtonText: '知道了' })
amount.value = ''
} else {
ElMessage.error(res.message || '提现失败')
}
} catch {} finally {
submitting.value = false
}
}
onMounted(async () => {
const token = sessionStorage.getItem('CQ-TOKEN')
if (!token) {
router.replace('/login')
return
}
const payload = parseJwt(token)
if (payload?.username) {
currentAccount.value = payload.username
currentServerId.value = payload.server_id || 1
serverId.value = currentServerId.value
}
await Promise.all([loadConfig(), loadServers()])
})
</script>
<template>
<div class="withdraw-page pagebg">
<div class="withdraw-box">
<div class="header">
<button class="back-btn" @click="router.back()"> 返回</button>
<h2>游戏提现</h2>
</div>
<div class="account-info">
当前账号<strong>{{ currentAccount }}</strong>
</div>
<div class="form">
<!-- 区服 -->
<div class="form-item">
<label>选择区服</label>
<select v-model="serverId">
<option value="">请选择区服</option>
<option v-for="s in servers" :key="s.srvid" :value="s.srvid">
{{ s.serverName }}
</option>
</select>
</div>
<!-- 角色 ID -->
<div class="form-item">
<label>角色 ID</label>
<input
v-model="roleId"
type="number"
placeholder="请输入游戏内角色 ID"
min="1"
/>
</div>
<!-- 角色名 -->
<div class="form-item">
<label>角色名</label>
<input
v-model="roleName"
type="text"
placeholder="请输入游戏内角色名"
maxlength="24"
/>
</div>
<!-- 提现数量 -->
<div class="form-item">
<label>提现数量{{ currencyName }}</label>
<input
v-model="amount"
type="number"
:placeholder="`最少 ${(gameConfig.withdrawRatio || 10000) * (gameConfig.withdrawMinOnce || 20)} ${currencyName}`"
min="0"
/>
<div v-if="money > 0" class="hint"> 人民币 {{ money }} </div>
</div>
<!-- 收款方式 -->
<div class="form-item">
<label>收款方式</label>
<div class="pay-type-row">
<label class="radio-label" :class="{ active: payType === 0 }" @click="payType = 0">
支付宝
</label>
<label class="radio-label" :class="{ active: payType === 1 }" @click="payType = 1">
微信
</label>
</div>
</div>
<!-- 收款账户 -->
<div class="form-item">
<label>{{ payType === 0 ? '支付宝账号' : '微信号' }}</label>
<input
v-model="payAccount"
type="text"
:placeholder="payType === 0 ? '请输入支付宝账号(手机号/邮箱)' : '请输入微信号'"
maxlength="30"
/>
</div>
<div class="tip">
提示提现到账时间为工作日如有疑问请联系客服
</div>
<button
class="submit-btn"
:disabled="submitting"
@click="handleWithdraw"
>
{{ submitting ? '处理中…' : '申请提现' }}
</button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.withdraw-page {
min-height: 100vh;
background: #0a0a0a;
display: flex;
justify-content: center;
padding: 20px;
box-sizing: border-box;
.withdraw-box {
width: 100%;
max-width: 480px;
color: #ddd;
.header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
h2 {
color: #f5bd10;
font-size: 18px;
margin: 0;
}
.back-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.7);
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
&:hover {
border-color: #f5bd10;
color: #f5bd10;
}
}
}
.account-info {
background: rgba(245, 189, 16, 0.08);
border: 1px solid rgba(245, 189, 16, 0.2);
border-radius: 8px;
padding: 10px 16px;
font-size: 14px;
margin-bottom: 20px;
strong {
color: #f5bd10;
}
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
input, select {
height: 38px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
color: #fff;
font-size: 14px;
outline: none;
box-sizing: border-box;
&::placeholder {
color: rgba(255, 255, 255, 0.3);
}
&:focus {
border-color: #f5bd10;
}
option {
color: #333;
}
}
.hint {
font-size: 12px;
color: #f5bd10;
}
.pay-type-row {
display: flex;
gap: 12px;
.radio-label {
flex: 1;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
transition: all 0.2s;
&.active {
border-color: #f5bd10;
color: #f5bd10;
background: rgba(245, 189, 16, 0.08);
}
&:hover:not(.active) {
border-color: rgba(255, 255, 255, 0.3);
}
}
}
}
.tip {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
padding: 8px 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
border-left: 3px solid rgba(245, 189, 16, 0.4);
}
.submit-btn {
height: 44px;
border-radius: 8px;
border: none;
background: #f5bd10;
color: #000;
font-weight: bold;
font-size: 16px;
letter-spacing: 4px;
cursor: pointer;
transition: opacity 0.2s;
&:hover:not(:disabled) {
opacity: 0.85;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
}
</style>