重构 Play.vue 组件,拆分为多个子组件,提升代码可维护性和可读性。

主要变更:
- 将 Play.vue 拆分为 LoadingOverlay、SelectRegion、ScanPage、GamePage、RefreshWaitPage、ErrorPage 等组件
- 新增 composables 目录,分离业务逻辑(usePlayState、useTimers、useQrCode)
- 优化代码结构,提升开发效率和维护性

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zyh
2025-08-29 19:03:56 +08:00
parent a8083088c6
commit 7497817640
12 changed files with 3578 additions and 1612 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git add:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -0,0 +1,83 @@
<template>
<div class="error-page">
<div class="error-container">
<div class="error-icon"></div>
<h2 class="error-title">{{ errorTitle }}</h2>
<p class="error-message">{{ errorMessage }}</p>
<button @click="$emit('retry')" class="retry-btn">重新尝试</button>
</div>
</div>
</template>
<script>
export default {
name: 'ErrorPage',
props: {
errorTitle: {
type: String,
required: true
},
errorMessage: {
type: String,
required: true
}
},
emits: ['retry']
}
</script>
<style scoped>
.error-page {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 40px 20px;
}
.error-container {
background: white;
padding: 40px;
border-radius: 20px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
text-align: center;
max-width: 400px;
width: 100%;
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px 0;
color: #333;
}
.error-message {
font-size: 16px;
color: #666;
margin: 0 0 24px 0;
line-height: 1.5;
}
.retry-btn {
background: #667eea;
color: white;
border: none;
padding: 12px 32px;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.retry-btn:hover {
background: #5a6fd8;
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,238 @@
<template>
<div class="game-page">
<!-- 顶部标签页 -->
<div class="tab-header">
<div class="tab-item active">代练大区</div>
<div class="tab-item status-tab">状态</div>
<div class="tab-item target-tab">目标点数</div>
</div>
<div class="tab-header">{{ region }}
<div class="tab-item active" v-if="region === 'Q'">QQ</div>
<div class="tab-item active" v-if="region === 'V'">微信</div>
<div class="tab-item status-tab">{{ displayStatus }}</div>
<div class="tab-item target-tab">{{ completedPoints || 0 }}/{{ totalPoints || 0 }}</div>
</div>
<div class="tab-content">
<!-- 状态提示 -->
<div class="status-message" :class="statusMessageClass">
{{ statusMessage }}
</div>
<!-- 游戏截图展示区域 -->
<div class="image-gallery">
<div class="image-item" v-if="assets?.homepageUrl">
<img :src="assets.homepageUrl" alt="首次主页" class="game-image" />
<div class="image-label">首次主页</div>
</div>
<div class="image-item" v-if="assets?.firstRewardUrl">
<img :src="assets.firstRewardUrl" alt="首次赏金" class="game-image" style="transform: rotate(-90deg);" />
<div class="image-label">首次赏金</div>
</div>
<div class="image-item" v-if="assets?.midRewardUrl">
<img :src="assets.midRewardUrl" alt="中途赏金" class="game-image" />
<div class="image-label">中途赏金</div>
</div>
<div class="image-item" v-if="assets?.endRewardUrl">
<img :src="assets.endRewardUrl" alt="结束赏金" class="game-image" />
<div class="image-label">结束赏金</div>
</div>
</div>
<!-- 底部状态显示 -->
<div class="bottom-status">
ss{{ currentPoints || 0 }}
</div>
</div>
</div>
</template>
<script>
export default {
name: 'GamePage',
props: {
region: {
type: String,
required: true
},
displayStatus: {
type: String,
required: true
},
completedPoints: {
type: Number,
default: 0
},
totalPoints: {
type: Number,
default: 0
},
statusMessage: {
type: String,
required: true
},
statusMessageClass: {
type: String,
default: ''
},
assets: {
type: Object,
default: null
},
currentPoints: {
type: Number,
default: 0
}
}
}
</script>
<style scoped>
.game-page {
flex: 1;
background: white;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.tab-header {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.tab-item {
flex: 1;
padding: 12px 16px;
text-align: center;
font-size: 14px;
font-weight: 500;
color: #666;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.3s ease;
}
.tab-item.active {
background: #4CAF50;
color: white;
border-bottom-color: #4CAF50;
}
.tab-item.status-tab {
background: #2196F3;
color: white;
}
.tab-item.target-tab {
color: #f44336;
font-weight: 600;
}
.tab-content {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
}
.status-message {
background: #fff3cd;
color: #856404;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 20px;
text-align: center;
font-size: 14px;
border-left: 4px solid #f44336;
}
.status-message-completed {
background: #d4edda !important;
color: #155724 !important;
border-left: 4px solid #28a745 !important;
}
.image-gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.image-item {
display: flex;
flex-direction: column;
align-items: center;
background: white;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.image-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.game-image {
width: 100%;
max-width: 150px;
height: auto;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 8px;
}
.image-label {
font-size: 12px;
color: #333;
font-weight: 500;
text-align: center;
}
.bottom-status {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
margin: 0 -16px -16px -16px;
font-size: 18px;
font-weight: 600;
color: #333;
}
@media (max-width: 768px) {
.tab-item {
padding: 10px 8px;
font-size: 12px;
}
.status-message {
font-size: 13px;
padding: 10px 12px;
}
.image-gallery {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.image-item {
padding: 8px;
}
.game-image {
max-width: 120px;
}
.image-label {
font-size: 11px;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="loading-overlay">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
</template>
<script>
export default {
name: 'LoadingOverlay'
}
</script>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="refresh-wait-page">
<div class="page-header">
<h1 class="title">请选择您的账号类型</h1>
</div>
<div class="refresh-container">
<div class="warning-icon"></div>
<p class="refresh-text">页面需要刷新</p>
<p class="refresh-desc">请等待后重新选择区域</p>
<button
@click="$emit('refresh')"
class="refresh-btn"
:disabled="refreshCooldown > 0"
>
{{ refreshCooldown > 0 ? `请等待 ${refreshCooldown}s` : '确定' }}
</button>
</div>
<div class="notice-text">
<p>代理技术代练平台操作中</p>
<p>绝对安全保障请耐心等待</p>
<p>温馨提示: 请选择正确区域</p>
<p v-if="mecmachineId" class="machine-id-info">机器ID: {{ mecmachineId }}</p>
</div>
</div>
</template>
<script>
export default {
name: 'RefreshWaitPage',
props: {
refreshCooldown: {
type: Number,
default: 0
},
mecmachineId: {
type: String,
default: null
}
},
emits: ['refresh']
}
</script>
<style scoped>
.refresh-wait-page {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.page-header {
text-align: center;
padding: 40px 20px 20px;
color: white;
}
.title {
font-size: 24px;
font-weight: 600;
margin: 0 0 16px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.refresh-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0 20px;
color: white;
text-align: center;
}
.warning-icon {
font-size: 48px;
margin-bottom: 16px;
}
.refresh-text {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
}
.refresh-desc {
font-size: 16px;
margin: 0 0 24px 0;
opacity: 0.8;
}
.refresh-btn {
background: white;
color: #667eea;
border: none;
padding: 12px 32px;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
min-width: 120px;
}
.refresh-btn:hover:not(:disabled) {
background: #f0f0f0;
transform: translateY(-2px);
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.notice-text {
text-align: center;
padding: 20px;
color: white;
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.notice-text p {
margin: 4px 0;
font-size: 14px;
opacity: 0.9;
}
.machine-id-info {
color: #4CAF50 !important;
font-weight: 600 !important;
opacity: 1 !important;
}
</style>

View File

@@ -0,0 +1,295 @@
<template>
<div class="scan-page">
<div class="page-header">
<h1 class="title">请选择您的账号类型</h1>
<div class="selected-region">{{ regionName }}</div>
</div>
<!-- 等待二维码 -->
<div v-if="isWaitingQr" class="qr-waiting">
<div class="loading-spinner"></div>
<p class="waiting-text">正在准备二维码...</p>
<p class="waiting-desc">预计等待 {{ qrDelaySeconds }} </p>
<p v-if="qrRetryCount > 0" class="retry-info">重试中... ({{ qrRetryCount }}/{{ maxQrRetries }})</p>
</div>
<!-- 二维码区域 -->
<div v-else-if="qrInfo && countdown > 0" class="qr-container">
<div class="qr-wrapper">
<img :src="qrInfo.url" class="qr-code" alt="扫码登录" @error="$emit('qrImageError', $event)" />
</div>
<div class="countdown-timer">{{ formatTime(countdown) }}</div>
</div>
<!-- 二维码获取失败 -->
<div v-else-if="qrError" class="qr-error">
<div class="error-icon"></div>
<p class="error-text">二维码获取失败</p>
<p class="error-desc">{{ qrError }}</p>
<button
@click="$emit('retryQrCode')"
class="retry-btn"
:disabled="submitting"
>
重新获取
</button>
</div>
<!-- 二维码过期 -->
<div v-else class="qr-expired">
<div class="warning-icon"></div>
<p class="expired-text">扫码超时{{ qrInfo?.url }}</p>
<p class="expired-desc">请手动刷新页面重新获取二维码</p>
<img :src="qrInfo?.url" class="qr-code" alt="扫码登录" />
<button
@click="$emit('pageRefresh')"
class="refresh-btn"
>
刷新页面
</button>
</div>
<div class="notice-text">
<p>代理技术代练平台操作中</p>
<p>绝对安全保障请耐心等待</p>
<p>温馨提示: 请选择正确区域</p>
<p v-if="mecmachineId" class="machine-id-info">机器ID: {{ mecmachineId }}</p>
</div>
</div>
</template>
<script>
export default {
name: 'ScanPage',
props: {
regionName: {
type: String,
required: true
},
isWaitingQr: {
type: Boolean,
default: false
},
qrInfo: {
type: Object,
default: null
},
countdown: {
type: Number,
default: 0
},
qrDelaySeconds: {
type: Number,
default: 0
},
qrRetryCount: {
type: Number,
default: 0
},
maxQrRetries: {
type: Number,
default: 3
},
qrError: {
type: String,
default: null
},
submitting: {
type: Boolean,
default: false
},
mecmachineId: {
type: String,
default: null
}
},
emits: ['qrImageError', 'retryQrCode', 'pageRefresh'],
methods: {
formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
}
}
</script>
<style scoped>
.scan-page {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.page-header {
text-align: center;
padding: 40px 20px 20px;
color: white;
}
.title {
font-size: 24px;
font-weight: 600;
margin: 0 0 16px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.selected-region {
background: rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 20px;
display: inline-block;
font-size: 16px;
backdrop-filter: blur(10px);
}
.qr-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0 20px;
}
.qr-waiting {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0 20px;
color: white;
text-align: center;
}
.waiting-text {
font-size: 20px;
font-weight: 600;
margin: 16px 0 8px 0;
}
.waiting-desc {
font-size: 16px;
margin: 0;
opacity: 0.8;
}
.retry-info {
font-size: 14px;
margin: 8px 0 0 0;
opacity: 0.9;
color: #ffd700;
font-weight: 500;
}
.qr-wrapper {
background: white;
padding: 20px;
border-radius: 20px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
margin-bottom: 20px;
}
.qr-code {
width: 200px;
height: 200px;
display: block;
}
.countdown-timer {
color: white;
font-size: 18px;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.qr-expired, .qr-error {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0 20px;
color: white;
text-align: center;
}
.warning-icon, .error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.expired-text, .error-text {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
}
.expired-desc, .error-desc {
font-size: 16px;
margin: 0 0 24px 0;
opacity: 0.8;
}
.refresh-btn, .retry-btn {
background: white;
color: #667eea;
border: none;
padding: 12px 32px;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
min-width: 120px;
}
.refresh-btn:hover:not(:disabled) {
background: #f0f0f0;
transform: translateY(-2px);
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.notice-text {
text-align: center;
padding: 20px;
color: white;
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.notice-text p {
margin: 4px 0;
font-size: 14px;
opacity: 0.9;
}
.machine-id-info {
color: #4CAF50 !important;
font-weight: 600 !important;
opacity: 1 !important;
}
@media (max-width: 768px) {
.qr-code {
width: 150px;
height: 150px;
}
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<div class="select-region-page">
<div class="page-header">
<h1 class="title">请选择您的账号类型</h1>
</div>
<div class="region-buttons">
<button
@click="$emit('selectRegion', 'Q')"
class="region-btn qq-btn"
:disabled="submitting"
:class="{ 'loading': submitting }"
>
<div v-if="submitting" class="loading-spinner small"></div>
<div v-else class="btn-icon">Q</div>
<span class="btn-text">{{ submitting ? '正在连接...' : 'QQ区' }}</span>
</button>
<button
@click="$emit('selectRegion', 'V')"
class="region-btn wx-btn"
:disabled="submitting"
:class="{ 'loading': submitting }"
>
<div v-if="submitting" class="loading-spinner small"></div>
<div v-else class="btn-icon">V</div>
<span class="btn-text">{{ submitting ? '正在连接...' : '微信区' }}</span>
</button>
</div>
<div class="notice-text">
<p>代理技术代练平台操作中</p>
<p>绝对安全保障请耐心等待</p>
<p>温馨提示: 请选择正确区域</p>
<p v-if="mecmachineId" class="machine-id-info">机器ID: {{ mecmachineId }}</p>
</div>
</div>
</template>
<script>
export default {
name: 'SelectRegion',
props: {
submitting: {
type: Boolean,
default: false
},
mecmachineId: {
type: String,
default: null
}
},
emits: ['selectRegion']
}
</script>
<style scoped>
.select-region-page {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.page-header {
text-align: center;
padding: 40px 20px 20px;
color: white;
}
.title {
font-size: 24px;
font-weight: 600;
margin: 0 0 16px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.region-buttons {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
gap: 40px;
padding: 0 20px;
}
.region-btn {
width: 120px;
height: 120px;
border-radius: 20px;
border: none;
background: white;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
font-size: 16px;
font-weight: 600;
}
.region-btn:hover {
transform: translateY(-5px);
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.2);
}
.region-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.region-btn.loading {
opacity: 0.8;
cursor: not-allowed;
transform: none;
}
.region-btn.loading:hover {
transform: none;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.btn-icon {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
font-weight: bold;
color: white;
margin-bottom: 12px;
}
.qq-btn .btn-icon {
background: #12B7F5;
}
.wx-btn .btn-icon {
background: #07C160;
}
.btn-text {
color: #333;
font-size: 16px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.loading-spinner.small {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #667eea;
margin: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.notice-text {
text-align: center;
padding: 20px;
color: white;
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.notice-text p {
margin: 4px 0;
font-size: 14px;
opacity: 0.9;
}
.machine-id-info {
color: #4CAF50 !important;
font-weight: 600 !important;
opacity: 1 !important;
}
@media (max-width: 768px) {
.region-buttons {
gap: 20px;
}
.region-btn {
width: 100px;
height: 100px;
}
.btn-icon {
width: 40px;
height: 40px;
font-size: 20px;
}
.btn-text {
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,437 @@
import { reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
getLinkStatus,
selectRegion as selectRegionAPI,
refreshLink as refreshLinkAPI,
pollLoginStatus,
getGameProgress,
getGameInterface as getGameInterfaceAPI
} from '@/api/play'
export function usePlayState() {
const route = useRoute()
const router = useRouter()
const state = reactive({
code: '',
status: 'NEW',
loading: true,
submitting: false,
needRefresh: false,
region: null,
qrInfo: null,
assets: null,
currentPoints: 0,
totalPoints: 1000,
completedPoints: 0,
error: null,
qrDelaySeconds: 0,
isWaitingQr: false,
qrRetryCount: 0,
maxQrRetries: 3,
qrRetryDelay: 2000,
qrError: null,
mecmachineId: null
})
const initializePage = async () => {
try {
await fetchStatus()
} catch (error) {
handleError(error)
} finally {
state.loading = false
}
}
const fetchStatus = async () => {
try {
const response = await getLinkStatus(state.code)
const data = response.data
await updateStateFromResponse(data)
} catch (error) {
throw error
}
}
const getGameInterface = async () => {
try {
console.log('调用游戏界面接口code:', state.code)
const response = await getGameInterfaceAPI(state.code)
console.log('游戏界面接口响应:', response.data)
return response
} catch (error) {
console.error('获取游戏界面数据失败:', error)
ElMessage.error('获取游戏界面数据失败')
throw error
}
}
const handleLoggedInStatus = async () => {
try {
console.log('检测到LOGGED_IN状态获取游戏界面数据')
const gameResponse = await getGameInterface()
const gameData = gameResponse.data
console.log('游戏界面数据:', gameData)
state.status = 'LOGGED_IN'
if(gameData.status == "COMPLETED"){
state.assets = {
homepageUrl: gameData.homepageUrl,
firstRewardUrl: gameData.firstRewardUrl,
midRewardUrl: gameData.midRewardUrl,
endRewardUrl: gameData.endRewardUrl,
qrCodeUrl: gameData.mecmachineId ? `https://2.uzi0.cc/image/${gameData.mecmachineId}/二维码.png?t=${new Date().getTime()}` : null,
...(gameData.assets || {})
}
} else {
state.assets = {
homepageUrl: gameData.mecmachineId ? `https://2.uzi0.cc/image/${gameData.mecmachineId}/首次主页.png?t=${new Date().getTime()}` : null,
firstRewardUrl: gameData.mecmachineId ? `https://2.uzi0.cc/image/${gameData.mecmachineId}/首次赏金.png?t=${new Date().getTime()}` : null,
midRewardUrl: gameData.mecmachineId ? `https://2.uzi0.cc/image/${gameData.mecmachineId}/中途赏金.png?t=${new Date().getTime()}` : null,
endRewardUrl: gameData.mecmachineId ? `https://2.uzi0.cc/image/${gameData.mecmachineId}/结束赏金.png?t=${new Date().getTime()}` : null,
qrCodeUrl: gameData.mecmachineId ? `https://2.uzi0.cc/image/${gameData.mecmachineId}/二维码.png?t=${new Date().getTime()}` : null,
...(gameData.assets || {})
}
}
console.log('更新区域信息:', {
gameDataRegion: gameData.region,
originalStateRegion: state.region
})
if (gameData.region) {
state.region = gameData.region
console.log('已设置 state.region =', state.region)
} else {
console.log('gameData.region 为空,未更新 state.region')
}
if (gameData.mecmachineId) {
state.mecmachineId = gameData.mecmachineId
}
if (gameData.totalPoints) {
state.totalPoints = gameData.totalPoints
} else if (gameData.assets && gameData.assets.totalPoints) {
state.totalPoints = gameData.assets.totalPoints
}
state.completedPoints = gameData.completedPoints || 0
state.currentPoints = 0
console.log('handleLoggedInStatus 执行完成,最终状态:', {
status: state.status,
region: state.region,
totalPoints: state.totalPoints,
completedPoints: state.completedPoints,
mecmachineId: state.mecmachineId
})
ElMessage.success('登录成功,正在进入游戏界面...')
} catch (error) {
console.error('获取游戏界面数据失败:', error)
ElMessage.error('获取游戏数据失败,请稍后重试')
}
}
const handleCompletedStatus = async () => {
try {
const gameResponse = await getGameInterfaceAPI(state.code)
const gameData = gameResponse.data
console.log('已完成状态 - 游戏界面数据:', gameData)
state.status = 'COMPLETED'
state.assets = {
homepageUrl: gameData.homepageUrl,
firstRewardUrl: gameData.firstRewardUrl,
midRewardUrl: gameData.midRewardUrl,
endRewardUrl: gameData.endRewardUrl,
qrCodeUrl: gameData.mecmachineId ? `https://2.uzi0.cc/image/${gameData.mecmachineId}/二维码.png?t=${Date.now()}` : null
}
state.totalPoints = gameData.totalPoints || 50
state.completedPoints = gameData.completedPoints || state.totalPoints
state.currentPoints = state.totalPoints
console.log('已完成状态更新完成:', {
status: state.status,
totalPoints: state.totalPoints,
completedPoints: state.completedPoints,
currentPoints: state.currentPoints,
assets: !!state.assets
})
} catch (error) {
console.error('获取已完成状态游戏数据失败:', error)
state.status = 'COMPLETED'
ElMessage.error('获取游戏数据失败,但订单已完成')
}
}
const updateStateFromResponse = async (data, skipQrProcessing = false) => {
if (data.status === 'LOGGED_IN') {
await handleLoggedInStatus()
return
}
if (data.status === 'COMPLETED') {
await handleCompletedStatus()
return
}
state.status = data.status
state.needRefresh = data.needRefresh || false
state.region = data.region
state.assets = data.assets
state.mecmachineId = data.mecmachineId || null
if (data.totalPoints) {
state.totalPoints = data.totalPoints
}
if (data.completedPoints !== undefined) {
state.completedPoints = data.completedPoints
}
if (data.assets && data.assets.totalPoints) {
state.totalPoints = data.assets.totalPoints
if (state.currentPoints === undefined) {
state.currentPoints = 0
}
}
console.log('updateStateFromResponse:', {
status: data.status,
dataRegion: data.region,
stateRegion: state.region,
mecmachineId: data.mecmachineId,
totalPoints: state.totalPoints,
completedPoints: state.completedPoints,
skipQrProcessing
})
if (skipQrProcessing) {
return
}
if (data.mecmachineId) {
const qrUrl = `https://2.uzi0.cc/image/${data.mecmachineId}/二维码.png?t=${Date.now()}`
state.qrInfo = {
url: qrUrl,
createdAt: data.qrCreatedAt,
expireAt: data.qrExpireAt
}
} else if (data.qr) {
state.qrInfo = data.qr
}
}
const selectRegion = async (region) => {
if (state.submitting) return
state.submitting = true
state.qrRetryCount = 0
try {
const response = await selectRegionAPI({ code: state.code, region })
const data = response.data
console.log('selectRegion 响应数据:', data)
return data
} catch (error) {
handleError(error)
throw error
} finally {
state.submitting = false
}
}
const handleRefresh = async (clearAllTimers) => {
try {
const response = await refreshLinkAPI(state.code)
const data = response.data
if (clearAllTimers) {
clearAllTimers()
}
state.needRefresh = false
state.status = 'NEW'
return data
} catch (error) {
handleError(error)
throw error
}
}
const handlePageRefresh = () => {
window.location.reload()
}
const handleRetry = () => {
state.error = null
state.loading = true
initializePage()
}
const handleError = (error) => {
console.error('API错误:', error)
const status = error?.response?.status
if (status === 401) {
console.log('检测到401错误跳转到登录页面')
router.replace({
name: 'Login',
query: { redirect: route.fullPath }
})
return
}
if (status === 400 || status === 403) {
state.error = 'INVALID_CODE'
} else if (status === 410) {
state.error = 'EXPIRED'
} else {
state.error = 'NETWORK_ERROR'
}
}
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const getRegionName = () => {
return state.region === 'Q' ? 'QQ区' : state.region === 'V' ? '微信区' : ''
}
const getCurrentUrl = () => {
return window.location.href
}
const getGameStatus = () => {
if (state.currentPoints >= state.totalPoints) {
return '已打完'
} else if (state.currentPoints > 0) {
return '代练中'
} else {
return '空闲'
}
}
const getStatusClass = () => {
const status = getGameStatus()
return {
'status-completed': status === '已打完',
'status-playing': status === '代练中',
'status-idle': status === '空闲'
}
}
const getDisplayStatus = () => {
if (state.status === 'COMPLETED') {
return '已完成'
} else if (state.status === 'LOGGED_IN') {
return '代练中'
} else {
return '状态'
}
}
const getStatusMessage = () => {
if (state.status === 'COMPLETED') {
return '代练已完成!感谢您的使用,订单已结束。'
} else if (state.status === 'LOGGED_IN') {
return '正在代练中,期间请勿操号,耐心等待代练完成......'
} else {
return '正在代练中,期间请勿操号,耐心等待代练完成......'
}
}
const getStatusMessageClass = () => {
if (state.status === 'COMPLETED') {
return 'status-message-completed'
} else {
return ''
}
}
const getProgressPercent = () => {
if (!state.totalPoints) return 0
return Math.min(100, (state.currentPoints / state.totalPoints) * 100)
}
const getCurrentGameImage = () => {
if (!state.assets) return null
const progress = getProgressPercent()
if (progress === 0) {
return state.assets.homepageUrl
} else if (progress < 50) {
return state.assets.firstRewardUrl
} else if (progress < 100) {
return state.assets.midRewardUrl
} else {
return state.assets.endRewardUrl
}
}
const getErrorTitle = () => {
const titles = {
'INVALID_CODE': '链接无效',
'EXPIRED': '链接已过期',
'REFUNDED': '订单已退单',
'NETWORK_ERROR': '网络错误'
}
return titles[state.error] || '出现错误'
}
const getErrorMessage = () => {
const messages = {
'INVALID_CODE': '请联系商家获取有效链接',
'EXPIRED': '请联系商家重新获取链接',
'REFUNDED': '该订单已被退单,无法继续使用',
'NETWORK_ERROR': '网络连接失败,请检查网络后重试'
}
return messages[state.error] || '请稍后重试或联系客服'
}
return {
state,
initializePage,
fetchStatus,
updateStateFromResponse,
handleLoggedInStatus,
handleCompletedStatus,
selectRegion,
handleRefresh,
handlePageRefresh,
handleRetry,
handleError,
formatTime,
getRegionName,
getCurrentUrl,
getGameStatus,
getStatusClass,
getDisplayStatus,
getStatusMessage,
getStatusMessageClass,
getProgressPercent,
getCurrentGameImage,
getErrorTitle,
getErrorMessage
}
}

View File

@@ -0,0 +1,185 @@
import { ElMessage } from 'element-plus'
import { selectRegion as selectRegionAPI } from '@/api/play'
export function useQrCode() {
const validateQrCodeUrl = async (url) => {
try {
console.log('开始验证二维码URL:', url)
if (!url || typeof url !== 'string') {
console.error('无效的二维码URL:', url)
return false
}
if (process.env.NODE_ENV === 'production') {
console.log('生产环境跳过URL验证')
return true
}
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
console.log('URL验证超时')
}, 3000)
const response = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
mode: 'no-cors'
})
clearTimeout(timeoutId)
console.log('URL验证响应:', response.status)
return true
} catch (error) {
console.warn('二维码URL验证失败:', error.name, error.message)
return true
}
}
const fetchQrCodeAfterDelay = async (state, countdown, mecmachineId, qrCreatedAt, qrExpireAt, retryCount = 0) => {
try {
const qrCodeUrl = `https://2.uzi0.cc/image/${mecmachineId}/二维码.png?t=${Date.now()}`
console.log(`尝试获取二维码 (第${retryCount + 1}次):`, qrCodeUrl)
const isUrlValid = await validateQrCodeUrl(qrCodeUrl)
console.log('URL验证结果:', isUrlValid)
if (!isUrlValid) {
throw new Error('二维码URL无法访问')
}
console.log('设置二维码信息:', { qrCodeUrl, qrCreatedAt, qrExpireAt })
state.qrInfo = {
url: qrCodeUrl,
createdAt: qrCreatedAt,
expireAt: qrExpireAt
}
const now = Date.now()
const expireTime = new Date(qrExpireAt).getTime()
countdown.value = Math.max(0, Math.floor((expireTime - now) / 1000))
state.qrRetryCount = 0
state.isWaitingQr = false
console.log('二维码设置完成countdown:', countdown.value)
ElMessage.success('二维码已准备就绪,请扫码登录')
} catch (error) {
console.error(`二维码获取失败 (第${retryCount + 1}次):`, error.message || error)
if (retryCount < state.maxQrRetries) {
state.qrRetryCount = retryCount + 1
const delay = state.qrRetryDelay * Math.pow(2, retryCount)
console.log(`${delay}ms后进行第${retryCount + 2}次重试`)
ElMessage.warning(`二维码获取失败,${delay/1000}秒后重试...`)
setTimeout(() => {
fetchQrCodeAfterDelay(state, countdown, mecmachineId, qrCreatedAt, qrExpireAt, retryCount + 1)
}, delay)
} else {
console.error('二维码获取重试次数用完,显示错误状态')
state.qrRetryCount = 0
state.isWaitingQr = false
state.qrError = `二维码获取失败,已重试${state.maxQrRetries}次。可能是网络问题或服务器繁忙,请稍后重试。`
ElMessage.error('二维码获取失败,请点击重新获取按钮')
}
}
}
const retrySelectRegion = async (state, code, region) => {
try {
console.log('重试选择区域:', region)
const response = await selectRegionAPI({ code, region })
const data = response.data
console.log('retrySelectRegion 响应数据:', data)
return data
} catch (error) {
console.error('重试选择区域失败:', error)
ElMessage.error('重新请求二维码失败,请手动刷新页面')
throw error
}
}
const processSelectRegionResponse = async (state, countdown, data, startCountdown, startLoginPolling, updateStateFromResponse) => {
if (data.qrDelaySeconds && data.qrDelaySeconds > 0 && data.mecmachineId) {
console.log('进入延迟分支')
await updateStateFromResponse(data, true)
state.qrDelaySeconds = data.qrDelaySeconds
state.isWaitingQr = true
console.log('设置状态:', { status: state.status, isWaitingQr: state.isWaitingQr })
ElMessage.info(`正在准备二维码,请等待 ${data.qrDelaySeconds} 秒...`)
setTimeout(async () => {
state.isWaitingQr = false
await fetchQrCodeAfterDelay(state, countdown, data.mecmachineId, data.qrCreatedAt, data.qrExpireAt)
if (state.status === 'USING') {
startCountdown()
startLoginPolling()
}
}, data.qrDelaySeconds * 1000)
} else {
console.log('进入立即处理分支')
await updateStateFromResponse(data)
state.isWaitingQr = false
console.log('设置状态:', { status: state.status, isWaitingQr: state.isWaitingQr, qrInfo: !!state.qrInfo })
if (state.qrInfo && state.qrInfo.url) {
const isUrlValid = await validateQrCodeUrl(state.qrInfo.url)
if (!isUrlValid) {
console.warn('立即获取的二维码URL无法访问尝试重试')
state.qrInfo = null
await fetchQrCodeAfterDelay(state, countdown, data.mecmachineId, data.qrCreatedAt, data.qrExpireAt)
}
}
if (state.status === 'USING') {
startCountdown()
startLoginPolling()
}
}
}
const handleQrImageError = (state, event) => {
console.error('二维码图片加载失败:', event)
state.qrError = '二维码图片加载失败,可能是网络问题'
ElMessage.error('二维码图片加载失败')
}
const retryGetQrCode = async (state, selectRegion) => {
if (state.submitting) return
state.submitting = true
state.qrError = null
state.isWaitingQr = true
state.qrRetryCount = 0
try {
if (state.region) {
await selectRegion(state.region)
}
} catch (error) {
console.error('重新获取二维码失败:', error)
state.qrError = '重新获取失败,请刷新页面重试'
} finally {
state.submitting = false
}
}
return {
validateQrCodeUrl,
fetchQrCodeAfterDelay,
retrySelectRegion,
processSelectRegionResponse,
handleQrImageError,
retryGetQrCode
}
}

View File

@@ -0,0 +1,108 @@
import { ref, reactive } from 'vue'
import { pollLoginStatus, getGameProgress } from '@/api/play'
export function useTimers() {
const countdown = ref(0)
const refreshCooldown = ref(0)
const timers = reactive({
loginPoll: null,
countdown: null,
refreshCooldown: null,
progressPoll: null
})
const clearTimer = (name) => {
if (timers[name]) {
clearInterval(timers[name])
timers[name] = null
}
}
const clearAllTimers = () => {
Object.keys(timers).forEach(clearTimer)
}
const startCountdown = (initialValue = null) => {
clearTimer('countdown')
if (initialValue !== null) {
countdown.value = initialValue
}
timers.countdown = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
} else {
clearTimer('countdown')
clearTimer('loginPoll')
}
}, 1000)
}
const startLoginPolling = (code, onLoggedIn, onCompleted) => {
clearTimer('loginPoll')
timers.loginPoll = setInterval(async () => {
try {
const response = await pollLoginStatus(code)
const data = response.data
console.log('poll-login 响应数据:', data)
if (data.status === 'LOGGED_IN') {
await onLoggedIn()
} else if (data.status === 'COMPLETED') {
await onCompleted()
clearTimer('loginPoll')
}
} catch (error) {
console.error('轮询错误:', error)
}
}, 1000)
}
const startProgressPolling = (code, onProgressUpdate) => {
clearTimer('progressPoll')
const pollProgress = async () => {
try {
const response = await getGameProgress(code)
const data = response.data
onProgressUpdate(data)
} catch (error) {
console.error('进度轮询错误:', error)
}
}
pollProgress()
timers.progressPoll = setInterval(pollProgress, 5000)
}
const startRefreshCooldown = (seconds) => {
refreshCooldown.value = seconds
clearTimer('refreshCooldown')
timers.refreshCooldown = setInterval(() => {
if (refreshCooldown.value > 0) {
refreshCooldown.value--
} else {
clearTimer('refreshCooldown')
}
}, 1000)
}
return {
countdown,
refreshCooldown,
timers,
clearTimer,
clearAllTimers,
startCountdown,
startLoginPolling,
startProgressPolling,
startRefreshCooldown
}
}

File diff suppressed because it is too large Load Diff

1696
src/views/oldplay.vue Normal file

File diff suppressed because it is too large Load Diff