Compare commits

...
2 Commits
Author SHA1 Message Date
fenghp 143df07f51 添加设备管理功能以及任务分发功能 2026-05-10 21:06:48 +08:00
fenghp ff92ab75ef 联调登录功能 2026-05-09 22:41:14 +08:00
12 changed files with 522 additions and 59 deletions
+22 -1
View File
@@ -3,7 +3,28 @@
</template>
<script>
export default {}
import { getToken } from './utils/storage'
import { initVConsole } from './utils/vconsole'
export default {
/**
* App 启动时按登录态决定首屏入口,避免用户手动判断跳转。
*/
onLaunch() {
initVConsole()
if (getToken()) {
uni.reLaunch({
url: '/pages/home/index'
})
return
}
uni.reLaunch({
url: '/pages/login/index'
})
}
}
</script>
<style lang="scss">
+1 -2
View File
@@ -19,8 +19,7 @@ export function login(data) {
url: '/login',
method: 'POST',
data,
needToken: false,
contentType: 'application/x-www-form-urlencoded;charset=utf-8'
needToken: false
})
}
+13 -2
View File
@@ -12,7 +12,18 @@ export function pullTask(params) {
}
/**
* 上报任务步骤状态。
* 确认任务已被当前设备接收。
*/
export function receiveTask(data) {
return request({
url: '/af/mobile/task/receive',
method: 'POST',
data
})
}
/**
* 上报任务步骤状态(二期预留)。
*/
export function reportTaskStep(data) {
return request({
@@ -23,7 +34,7 @@ export function reportTaskStep(data) {
}
/**
* 上报任务最终结果。
* 上报任务最终结果(二期预留)。
*/
export function reportTaskResult(data) {
return request({
+2 -2
View File
@@ -5,8 +5,8 @@
export const appConfig = {
appName: '充值自动化',
appVersion: '0.1.0',
baseUrl: 'http://your-backend-domain',
wsBaseUrl: 'ws://your-backend-domain',
baseUrl: 'http://10.0.2.2:8111',
wsBaseUrl: 'ws://10.0.2.2:8111',
rechargeUrl: 'https://your-recharge-page',
primaryColor: '#007aff'
}
+10 -1
View File
@@ -45,10 +45,19 @@
"backgroundColor": "#f2f2f7"
}
},
{
"path": "pages/task/index",
"style": {
"navigationBarTitleText": "任务列表",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f2f2f7"
}
},
{
"path": "pages/log/index",
"style": {
"navigationBarTitleText": "执行日志",
"navigationBarTitleText": "接收日志",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f2f2f7"
+299 -44
View File
@@ -2,7 +2,7 @@
<view class="page">
<view class="page-header">
<view class="page-title">工作台</view>
<view class="page-subtitle">展示账号、设备和连接状态,后续任务和 WebSocket 都从这里进入。</view>
<view class="page-subtitle">登录后自动绑定账号设备,持续心跳并在空闲时拉取分发任务。</view>
</view>
<view class="section">
@@ -33,7 +33,7 @@
<view class="row">
<view class="row-left">
<view class="row-title">设备在线状态</view>
<view class="row-desc">用于后续设备注册和心跳结果展示</view>
<view class="row-desc">依据绑定和心跳刷新</view>
</view>
<view class="tag">{{ onlineStatusText }}</view>
</view>
@@ -41,17 +41,17 @@
<view class="list-item">
<view class="row">
<view class="row-left">
<view class="row-title">WebSocket 连接状态</view>
<view class="row-desc">任务下发和服务端通知入口</view>
<view class="row-title">账号设备绑定状态</view>
<view class="row-desc">登录后自动调用 register</view>
</view>
<view class="tag">{{ wsStatusText }}</view>
<view class="tag">{{ registerStatus }}</view>
</view>
</view>
<view class="list-item">
<view class="row">
<view class="row-left">
<view class="row-title">当前任务状态</view>
<view class="row-desc">后续由任务拉取和步骤上报驱动</view>
<view class="row-desc">空闲时轮询 pull,收到后自动 receive</view>
</view>
<view class="tag">{{ taskStatusText }}</view>
</view>
@@ -59,26 +59,44 @@
<view class="list-item">
<view class="row">
<view class="row-left">
<view class="row-title">抖音登录状态</view>
<view class="row-desc">账号切换页后续会更新这里的值</view>
<view class="row-title">当前任务号</view>
<view class="row-desc">本期只做到接收确认,不做执行回调</view>
</view>
<view class="tag">{{ douyinStatusText }}</view>
<view class="field-value">{{ currentTaskNo }}</view>
</view>
</view>
<view class="list-item">
<view class="row">
<view class="row-left">
<view class="row-title">支付宝登录状态</view>
<view class="row-desc">支付环节的账号状态入口</view>
<view class="row-title">订单号</view>
<view class="row-desc">分发任务里携带的订单信息</view>
</view>
<view class="tag">{{ alipayStatusText }}</view>
<view class="field-value">{{ currentOrderNo }}</view>
</view>
</view>
<view class="list-item">
<view class="row">
<view class="row-left">
<view class="row-title">充值金额</view>
<view class="row-desc">用于首页快速确认任务内容</view>
</view>
<view class="field-value">{{ currentAmount }}</view>
</view>
</view>
<view class="list-item">
<view class="row">
<view class="row-left">
<view class="row-title">充值账号</view>
<view class="row-desc">任务指定充值目标账号</view>
</view>
<view class="field-value">{{ currentRechargeAccount }}</view>
</view>
</view>
<view class="list-item">
<view class="row">
<view class="row-left">
<view class="row-title">最近心跳时间</view>
<view class="row-desc">设备心跳模块后续会回写这个时间</view>
<view class="row-desc">心跳上报成功后刷新</view>
</view>
<view class="field-value">{{ lastHeartbeatTime }}</view>
</view>
@@ -87,22 +105,50 @@
<view class="section">
<view class="section-title">功能入口</view>
<button class="button-primary" @tap="goRecharge">开始充值</button>
<button class="button-primary" @tap="manualPullTask">立即拉取任务</button>
<button class="button-secondary button-space" @tap="goTask">任务列表</button>
<button class="button-secondary button-space" @tap="goLog">接收日志</button>
<button class="button-secondary button-space" @tap="goRecharge">开始充值</button>
<button class="button-secondary button-space" @tap="goDouyin">切换抖音账号</button>
<button class="button-secondary button-space" @tap="goAlipay">切换支付宝账号</button>
<button class="button-secondary button-space" @tap="reconnectWebSocket">重新连接 WebSocket</button>
<button class="button-danger button-space" @tap="logout">退出登录</button>
</view>
</view>
</template>
<script>
import appConfig from '../../config/index'
import { heartbeatDevice, registerDevice } from '../../api/device'
import { pullTask, receiveTask } from '../../api/task'
import { getDeviceProfile } from '../../services/device'
import { clearAuth, getUserInfo } from '../../utils/storage'
import { clearAuth, getUserInfo, setDeviceInfo } from '../../utils/storage'
const HEARTBEAT_INTERVAL = 15000
const PULL_INTERVAL = 8000
function formatTime(value) {
if (!value) {
return '--'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value)
}
const pad = (num) => (num < 10 ? `0${num}` : `${num}`)
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function resolveTaskId(task) {
if (!task) {
return ''
}
return task.taskId || task.id || task.task_no || task.taskNo || ''
}
/**
* 首页负责展示设备、账号和连接状态,并提供后续功能入口。
* 首页负责设备自动绑定、心跳上报和任务接收一期闭环。
*/
export default {
data() {
@@ -110,9 +156,15 @@ export default {
deviceInfo: {
deviceCode: '',
appVersion: '',
systemVersion: ''
systemVersion: '',
platform: ''
},
lastHeartbeatTime: '--'
registerStatus: '未绑定',
deviceOnlineStatus: 'OFFLINE',
lastHeartbeatTime: '--',
heartbeatTimer: null,
pullTimer: null,
pulling: false
}
},
computed: {
@@ -120,20 +172,32 @@ export default {
const userInfo = this.$store.state.userInfo || getUserInfo() || {}
return userInfo.userName || userInfo.nickName || userInfo.username || '未登录'
},
onlineStatusText() {
return this.deviceInfo.deviceCode ? '在线' : '未注册'
currentTask() {
return this.$store.state.currentTask || null
},
wsStatusText() {
return this.$store.state.wsStatus === 'connected' ? '已连接' : '未连接'
onlineStatusText() {
return this.deviceOnlineStatus === 'ONLINE' ? '在线' : '离线'
},
taskStatusText() {
return this.$store.state.currentTask ? '执行中' : '空闲'
const status = this.currentTask && this.currentTask.status
return status || 'IDLE'
},
douyinStatusText() {
return this.$store.state.douyinLoginStatus === 'success' ? '已登录' : '未知'
currentTaskNo() {
const task = this.currentTask || {}
return task.taskNo || task.task_no || '--'
},
alipayStatusText() {
return this.$store.state.alipayLoginStatus === 'success' ? '已登录' : '未知'
currentOrderNo() {
const task = this.currentTask || {}
return task.orderNo || task.order_no || '--'
},
currentAmount() {
const task = this.currentTask || {}
const amount = task.amount
return amount === undefined || amount === null || amount === '' ? '--' : String(amount)
},
currentRechargeAccount() {
const task = this.currentTask || {}
return task.rechargeAccount || task.recharge_account || '--'
}
},
onShow() {
@@ -146,16 +210,220 @@ export default {
}
this.syncDeviceInfo()
this.startDispatchFlow()
},
onHide() {
this.stopDispatchFlow()
},
onUnload() {
this.stopDispatchFlow()
},
methods: {
/**
* 刷新当前设备信息,供首页和后续注册接口复用。
* 刷新当前设备信息,供绑定、心跳和任务拉取统一复用。
*/
syncDeviceInfo() {
const profile = getDeviceProfile()
this.deviceInfo = profile
setDeviceInfo(profile)
this.$store.commit('setDeviceInfo', profile)
},
/**
* 启动一期分发流程:先绑定,再心跳和轮询任务。
*/
async startDispatchFlow() {
this.stopDispatchFlow()
await this.registerCurrentDevice()
this.startHeartbeatTimer()
this.startPullTimer()
},
/**
* 停止心跳和拉取定时器,避免页面切换后重复上报。
*/
stopDispatchFlow() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.pullTimer) {
clearInterval(this.pullTimer)
this.pullTimer = null
}
},
/**
* 登录后自动绑定账号与设备,满足一期账号设备登记要求。
*/
async registerCurrentDevice() {
const userName = this.userName
const deviceCode = this.deviceInfo.deviceCode
if (!userName || userName === '未登录' || !deviceCode) {
this.registerStatus = '绑定失败'
return
}
try {
await registerDevice({
userName,
deviceCode,
appVersion: this.deviceInfo.appVersion,
systemVersion: this.deviceInfo.systemVersion,
platform: this.deviceInfo.platform
})
this.registerStatus = '已绑定'
this.pushTraceLog('DEVICE_REGISTER', `账号 ${userName} 绑定设备 ${deviceCode} 成功`)
} catch (error) {
this.registerStatus = '绑定失败'
this.pushTraceLog('DEVICE_REGISTER_FAIL', error.msg || error.message || '账号设备绑定失败')
}
},
/**
* 开启心跳定时上报,并在首次进入页面时立即上报一次。
*/
startHeartbeatTimer() {
this.reportHeartbeat()
this.heartbeatTimer = setInterval(() => {
this.reportHeartbeat()
}, HEARTBEAT_INTERVAL)
},
/**
* 上报当前账号设备心跳,用于后端判断在线与空闲状态。
*/
async reportHeartbeat() {
const userName = this.userName
const deviceCode = this.deviceInfo.deviceCode
if (!userName || userName === '未登录' || !deviceCode) {
return
}
try {
const result = await heartbeatDevice({
userName,
deviceCode,
taskStatus: this.currentTask ? 'HAS_TASK' : 'IDLE',
currentTaskId: resolveTaskId(this.currentTask)
})
const heartbeatTime = (result.data && result.data.lastHeartbeatTime) || result.lastHeartbeatTime || Date.now()
this.lastHeartbeatTime = formatTime(heartbeatTime)
if (this.deviceOnlineStatus !== 'ONLINE') {
this.pushTraceLog('HEARTBEAT_ONLINE', '心跳恢复成功,设备在线')
}
this.deviceOnlineStatus = 'ONLINE'
} catch (error) {
if (this.deviceOnlineStatus !== 'OFFLINE') {
this.pushTraceLog('HEARTBEAT_FAIL', error.msg || error.message || '心跳上报失败')
}
this.deviceOnlineStatus = 'OFFLINE'
}
},
/**
* 启动空闲轮询,按一期要求通过 pull 接收任务。
*/
startPullTimer() {
this.tryPullTask()
this.pullTimer = setInterval(() => {
this.tryPullTask()
}, PULL_INTERVAL)
},
/**
* 空闲时拉取任务,拿到任务后立即确认接收。
*/
async tryPullTask() {
if (this.currentTask || this.pulling) {
return
}
const userName = this.userName
const deviceCode = this.deviceInfo.deviceCode
if (!userName || userName === '未登录' || !deviceCode) {
return
}
this.pulling = true
try {
const result = await pullTask({ userName, deviceCode })
const task = result.data || result
const taskId = resolveTaskId(task)
// 无任务返回时直接结束本次轮询。
if (!taskId) {
return
}
const nextTask = {
...task,
taskId,
status: task.status || 'PUSHED'
}
this.$store.commit('setCurrentTask', nextTask)
this.$store.commit('upsertTaskHistory', nextTask)
this.pushTraceLog('TASK_PULL', `收到任务 ${nextTask.taskNo || nextTask.taskId}`)
await this.confirmTaskReceive(nextTask)
} catch (error) {
this.pushTraceLog('TASK_PULL_FAIL', error.msg || error.message || '任务拉取失败')
} finally {
this.pulling = false
}
},
/**
* 对拉取到的任务调用 receive,明确任务已送达当前账号设备。
*/
async confirmTaskReceive(task) {
const userName = this.userName
const deviceCode = this.deviceInfo.deviceCode
const taskId = resolveTaskId(task)
if (!taskId || !userName || userName === '未登录' || !deviceCode) {
return
}
try {
await receiveTask({
taskId,
userName,
deviceCode
})
const receivedTask = {
...task,
status: 'RECEIVED',
receiveTime: Date.now()
}
this.$store.commit('setCurrentTask', receivedTask)
this.$store.commit('upsertTaskHistory', receivedTask)
this.pushTraceLog('TASK_RECEIVE', `任务 ${task.taskNo || taskId} 已确认接收`)
} catch (error) {
this.pushTraceLog('TASK_RECEIVE_FAIL', error.msg || error.message || '任务接收确认失败')
}
},
/**
* 手动触发一次任务拉取,方便联调时快速验证拉取接口。
*/
manualPullTask() {
this.tryPullTask()
},
/**
* 写入接收轨迹日志,供日志页和排查联调问题使用。
*/
pushTraceLog(event, text) {
this.$store.commit('pushLog', {
event,
time: formatTime(Date.now()),
taskNo: this.currentTaskNo,
text
})
},
goTask() {
uni.navigateTo({
url: '/pages/task/index'
})
},
goLog() {
uni.navigateTo({
url: '/pages/log/index'
})
},
goRecharge() {
uni.navigateTo({
url: '/pages/recharge/index'
@@ -171,21 +439,8 @@ export default {
url: '/pages/account/alipay'
})
},
reconnectWebSocket() {
if (!appConfig.wsBaseUrl || appConfig.wsBaseUrl.indexOf('your-backend-domain') > -1) {
uni.showToast({
title: '请先配置 WebSocket 地址',
icon: 'none'
})
return
}
uni.showToast({
title: 'WebSocket 待接入',
icon: 'none'
})
},
logout() {
this.stopDispatchFlow()
clearAuth()
this.$store.commit('resetState')
uni.reLaunch({
+12 -6
View File
@@ -1,26 +1,32 @@
<template>
<view class="page">
<view class="page-header">
<view class="page-title">执行日志</view>
<view class="page-subtitle">后续任务、步骤、失败原因都会在这里按时间顺序展示。</view>
<view class="page-title">接收日志</view>
<view class="page-subtitle">记录设备绑定、心跳、任务拉取和接收确认轨迹。</view>
</view>
<view class="section">
<view class="section-title">日志列表</view>
<view v-if="logs.length" class="log-list">
<view v-for="(item, index) in logs" :key="index" class="list-item">
<view class="log-time">{{ item.time }}</view>
<view class="log-text">{{ item.text }}</view>
<view class="row">
<view class="log-time">{{ item.time || '--' }}</view>
<view class="tag">{{ item.event || 'TRACE' }}</view>
</view>
<view class="log-text">{{ item.text || '--' }}</view>
<view class="row" style="margin-top: 8rpx;">
<view class="row-desc">任务号:{{ item.taskNo || '--' }}</view>
</view>
</view>
<view v-else class="empty-state">当前还没有日志记录</view>
</view>
<view v-else class="empty-state">当前还没有接收轨迹</view>
</view>
</view>
</template>
<script>
/**
* 日志页负责承载后续任务步骤和失败现场的本地展示。
* 日志页负责展示一期任务接收轨迹,便于联调排查。
*/
export default {
computed: {
+12 -1
View File
@@ -58,6 +58,7 @@
<script>
import { getCaptcha, getInfo, login } from '../../api/auth'
import {
getToken,
getRememberUsername,
removeRememberUsername,
setRememberUsername,
@@ -82,6 +83,16 @@ export default {
}
},
onShow() {
/**
* 已有登录态时直接进入首页,避免重复展示登录页。
*/
if (getToken()) {
uni.reLaunch({
url: '/pages/home/index'
})
return
}
this.username = getRememberUsername()
this.rememberAccount = !!this.username
this.refreshCaptcha()
@@ -97,7 +108,7 @@ export default {
this.uuid = captcha.uuid || ''
this.captchaEnabled = captcha.captchaEnabled !== false
const img = captcha.img || ''
this.captchaImg = img.indexOf('data:image') === 0 ? img : `data:image/gif;base64,${img}`
this.captchaImg = img.indexOf('data:image') === 0 ? img : `data:image/jpeg;base64,${img}`
} catch (error) {
this.captchaImg = ''
}
+100
View File
@@ -0,0 +1,100 @@
<template>
<view class="page">
<view class="page-header">
<view class="page-title">任务列表</view>
<view class="page-subtitle">展示当前任务和最近接收任务,便于一期联调核对任务状态。</view>
</view>
<view class="section">
<view class="section-title">当前任务</view>
<view v-if="currentTask" class="summary-grid">
<view class="summary-item">
<view class="summary-label">任务号</view>
<view class="summary-value">{{ formatTaskNo(currentTask) }}</view>
</view>
<view class="summary-item">
<view class="summary-label">任务状态</view>
<view class="summary-value">{{ formatStatus(currentTask) }}</view>
</view>
<view class="summary-item">
<view class="summary-label">订单号</view>
<view class="summary-value">{{ formatOrderNo(currentTask) }}</view>
</view>
<view class="summary-item">
<view class="summary-label">金额</view>
<view class="summary-value">{{ formatAmount(currentTask) }}</view>
</view>
</view>
<view v-else class="empty-state">当前没有执行中的任务</view>
</view>
<view class="section">
<view class="section-title">历史任务</view>
<view v-if="taskHistory.length">
<view v-for="item in taskHistory" :key="item.taskId" class="list-item">
<view class="row">
<view class="row-left">
<view class="row-title">{{ formatTaskNo(item) }}</view>
<view class="row-desc">订单号:{{ formatOrderNo(item) }}</view>
</view>
<view class="tag">{{ formatStatus(item) }}</view>
</view>
<view class="row" style="margin-top: 10rpx;">
<view class="row-desc">金额:{{ formatAmount(item) }}</view>
<view class="row-desc">充值账号:{{ formatRechargeAccount(item) }}</view>
</view>
</view>
</view>
<view v-else class="empty-state">暂未接收到任务</view>
</view>
</view>
</template>
<script>
/**
* 任务页负责展示当前任务和最近任务接收结果。
*/
export default {
computed: {
currentTask() {
return this.$store.state.currentTask || null
},
taskHistory() {
return this.$store.state.taskHistory || []
}
},
methods: {
/**
* 统一读取任务号字段,兼容不同接口字段命名。
*/
formatTaskNo(task) {
return task.taskNo || task.task_no || task.taskId || '--'
},
/**
* 统一读取订单号字段,便于列表展示。
*/
formatOrderNo(task) {
return task.orderNo || task.order_no || '--'
},
/**
* 统一读取状态字段,保证接收状态可见。
*/
formatStatus(task) {
return task.status || '--'
},
/**
* 统一读取金额字段,展示任务关键金额信息。
*/
formatAmount(task) {
const amount = task.amount
return amount === undefined || amount === null || amount === '' ? '--' : String(amount)
},
/**
* 统一读取充值账号字段,便于核对任务目标账号。
*/
formatRechargeAccount(task) {
return task.rechargeAccount || task.recharge_account || '--'
}
}
}
</script>
+30
View File
@@ -11,6 +11,7 @@ export default new Vuex.Store({
deviceInfo: null,
wsStatus: 'disconnected',
currentTask: null,
taskHistory: [],
currentStep: '',
douyinLoginStatus: 'unknown',
alipayLoginStatus: 'unknown',
@@ -32,6 +33,34 @@ export default new Vuex.Store({
setCurrentTask(state, task) {
state.currentTask = task
},
/**
* 记录最近接收到的任务,供任务页展示当前任务和历史任务。
*/
upsertTaskHistory(state, task) {
if (!task) {
return
}
const taskId = task.taskId || task.id || task.task_no || task.taskNo
if (!taskId) {
return
}
const nextTask = {
...task,
taskId
}
const index = state.taskHistory.findIndex((item) => (item.taskId || item.id || item.task_no || item.taskNo) === taskId)
if (index > -1) {
state.taskHistory.splice(index, 1)
}
state.taskHistory.unshift(nextTask)
if (state.taskHistory.length > 50) {
state.taskHistory.length = 50
}
},
setCurrentStep(state, step) {
state.currentStep = step
},
@@ -53,6 +82,7 @@ export default new Vuex.Store({
state.deviceInfo = null
state.wsStatus = 'disconnected'
state.currentTask = null
state.taskHistory = []
state.currentStep = ''
state.douyinLoginStatus = 'unknown'
state.alipayLoginStatus = 'unknown'
+1
View File
@@ -63,6 +63,7 @@ export function request(options) {
resolve(result)
},
fail: (error) => {
console.error('[request fail]', buildUrl(url), error)
uni.showToast({
title: '后端接口连接异常',
icon: 'none'
+20
View File
@@ -0,0 +1,20 @@
let vConsoleLoaded = false
/**
* 仅在 H5 开发环境注入 vConsole,方便排查接口、路由和运行时错误。
*/
export function initVConsole() {
if (process.env.UNI_PLATFORM !== 'h5' || process.env.NODE_ENV !== 'development' || vConsoleLoaded) {
return
}
const script = document.createElement('script')
script.src = 'https://unpkg.com/vconsole@3.15.1/dist/vconsole.min.js'
script.onload = () => {
// 只在开发环境挂载,不影响正式包。
// eslint-disable-next-line no-new
new window.VConsole()
vConsoleLoaded = true
}
document.head.appendChild(script)
}