Files
shoot-miniprograms/src/pages/ota-wifi.vue
T

1262 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import ScreenHint from "@/components/ScreenHint.vue";
import {
connectDeviceWifiAPI,
getDeviceBatteryAPI,
getHardwareBoxTaskStatusAPI,
getHardwareBoxVersionAPI,
sendHardwareBoxUpdateAPI,
} from "@/apis";
const STATES = {
SCANNING: "SCANNING",
LIST: "LIST",
CONNECTING: "CONNECTING",
CONNECTED: "CONNECTED",
UPDATING: "UPDATING",
DONE: "DONE",
FAILED: "FAILED",
};
const isIOS = uni.getDeviceInfo().osName === "ios";
const currentState = ref(STATES.SCANNING);
const connectedWifi = ref(null);
const wifiList = ref([]);
const showWifiBanner = ref(false);
const connectingWifi = ref(null);
const connectInput = ref({ ssid: "", password: "" });
const connectMode = ref("secure"); // secure | open | manual
const connectError = ref("");
const connectStatusText = ref("");
const isSubmittingWifi = ref(false);
const keyboardHeight = ref(0);
// 控制密码输入框是否显示明文
const showPassword = ref(false);
// 刷新防抖标志:扫描进行中为 true,禁止重复点击;扫描结束(成功/失败)后重置为 false。
const isRefreshing = ref(false);
const isStartingUpdate = ref(false);
const routeOtaInfo = ref({
versionNumber: "",
resourceUrl: "",
});
const progress = ref(0);
let progressTimer = null;
let timeoutTimer = null;
let statusTimer = null;
let wifiConnectTimer = null;
let wifiConnectRequestId = 0;
let wifiConnectPollCount = 0;
const WIFI_CONNECT_POLL_INTERVAL = 2000;
const WIFI_CONNECT_MAX_POLL_COUNT = 30;
const WIFI_CONNECT_FAILED_TEXT = "连接失败,请检查WiFi密码或WiFi状态";
const OTA_MIN_BATTERY = 50;
const OTA_LOW_BATTERY_TEXT = "电量不足 50%,暂不支持 OTA 升级";
// 控制授权拒绝弹窗显示/隐藏
const wifiAuthDeniedVisible = ref(false);
// 判断 WiFi 列表失败是否由用户拒绝授权引起(兼容 errno:103 及各平台 errMsg 变体)。
const isWifiPermissionDenied = (err) => {
if (err?.errno === 103) return true;
const errMsg = err?.errMsg || "";
return /auth den|authorize|permission denied|user denied|scope\.userLocation/i.test(errMsg);
};
// 显示授权拒绝弹窗,通过 ScreenHint 组件呈现。
const showWifiPermissionDeniedModal = () => {
wifiAuthDeniedVisible.value = true;
};
// 用户点击「重新授权」时,打开小程序权限设置页,引导用户开启位置权限后重新扫描。
const handleReauthorize = () => {
wifiAuthDeniedVisible.value = false;
uni.openSetting({
success(res) {
// 若用户在设置页开启了位置权限,则重新启动 WiFi 扫描
if (res.authSetting["scope.userLocation"]) {
startScanning();
}
},
});
};
// 将 iOS 当前手机连接的 WiFi 写入候选列表,供用户继续填写设备连接密码;扫描结束后重置刷新防抖标志。
const applyIOSConnectedWifi = (wifi) => {
const ssid = wifi?.SSID;
if (!ssid) {
wifiList.value = [];
currentState.value = STATES.LIST;
isRefreshing.value = false;
return;
}
wifiList.value = [
{
...wifi,
SSID: ssid,
secure: wifi.secure !== false,
fromCurrentWifi: true,
},
];
currentState.value = STATES.LIST;
isRefreshing.value = false;
};
// 获取 iOS 当前手机连接的 WiFi,并把它作为可选网络展示。
const syncIOSConnectedWifi = () => {
wx.getConnectedWifi({
success(res) {
console.log("[OTA WiFi] wx.getConnectedWifi success:", res);
applyIOSConnectedWifi(res.wifi || res);
},
fail(err) {
console.warn("[OTA WiFi] wx.getConnectedWifi fail:", {
errCode: err?.errCode,
errMsg: err?.errMsg,
err,
});
wifiList.value = [];
currentState.value = STATES.LIST;
isRefreshing.value = false;
},
});
};
// 启动 iOS WiFi 能力;iOS 只用当前手机连接的 WiFi 作为候选网络。
const startIOSScanning = () => {
wx.startWifi({
success(res) {
console.log("[OTA WiFi] wx.startWifi success:", res);
syncIOSConnectedWifi();
},
fail(err) {
console.error("[OTA WiFi] wx.startWifi fail:", {
errCode: err?.errCode,
errMsg: err?.errMsg,
err,
});
if (err.errCode === 12005) showWifiBanner.value = true;
currentState.value = STATES.LIST;
isRefreshing.value = false;
},
});
};
// 启动小程序 WiFi 能力并按平台获取可用网络;也作为刷新按钮的点击处理器,重新获取最新 WiFi 列表。
// 扫描进行中时防抖:若上次扫描尚未完成则直接 return,避免重复触发导致弹窗异常。
const startScanning = () => {
if (isRefreshing.value) return;
isRefreshing.value = true;
currentState.value = STATES.SCANNING;
wifiList.value = [];
showWifiBanner.value = false;
connectError.value = "";
if (isIOS) {
startIOSScanning();
return;
}
wx.startWifi({
success(res) {
console.log("[OTA WiFi] wx.startWifi success:", res);
wx.getWifiList({
success(res) {
console.log("[OTA WiFi] wx.getWifiList request success:", res);
},
fail(err) {
console.error("[OTA WiFi] wx.getWifiList fail:", {
errCode: err?.errCode,
errMsg: err?.errMsg,
err,
});
if (isWifiPermissionDenied(err)) showWifiPermissionDeniedModal();
if (err.errCode === 12005) showWifiBanner.value = true;
isRefreshing.value = false;
},
});
},
fail(err) {
console.error("[OTA WiFi] wx.startWifi fail:", {
errCode: err?.errCode,
errMsg: err?.errMsg,
err,
});
if (isWifiPermissionDenied(err)) showWifiPermissionDeniedModal();
if (err.errCode === 12005) showWifiBanner.value = true;
isRefreshing.value = false;
},
});
wx.onGetWifiList((res) => {
const realWifiList = res.wifiList || [];
if (realWifiList.length) {
console.log("[OTA WiFi] wx.onGetWifiList received wifiList:", realWifiList);
} else {
console.warn("[OTA WiFi] wx.onGetWifiList received empty wifiList:", res);
}
wifiList.value = realWifiList;
currentState.value = STATES.LIST;
isRefreshing.value = false;
});
};
// 选择列表中的 WiFi,并打开密码输入弹窗。
const selectWifi = (wifi) => {
cancelWifiConnectPolling();
connectingWifi.value = wifi;
connectInput.value = { ssid: wifi.SSID, password: "" };
connectMode.value = wifi.secure ? "secure" : "open";
connectError.value = "";
currentState.value = STATES.CONNECTING;
};
// 选择手动输入 WiFi,并打开手动输入弹窗。
const selectOther = () => {
cancelWifiConnectPolling();
connectingWifi.value = null;
connectInput.value = { ssid: "", password: "" };
connectMode.value = "manual";
connectError.value = "";
currentState.value = STATES.CONNECTING;
};
// 关闭连接弹窗,并停止当前 WiFi 连接轮询。
const closeConnectSheet = () => {
cancelWifiConnectPolling();
connectError.value = "";
currentState.value = connectedWifi.value ? STATES.CONNECTED : STATES.LIST;
};
const hasChinese = (str) => /[\u4e00-\u9fa5]/.test(str);
const ssidWarning = computed(() => {
if (connectMode.value === "manual" && hasChinese(connectInput.value.ssid)) {
return "网络名称仅支持英文字符及数字,请连接英文名网络或把网络改为英文";
}
return "";
});
// 判断当前是否禁止点击加入按钮,提交中也禁止重复点击。
const joinDisabled = computed(() => {
if (isSubmittingWifi.value) return true;
if (connectMode.value === "secure") return !connectInput.value.password;
if (connectMode.value === "manual") return !connectInput.value.ssid;
return false;
});
// 按当前页面状态限制 WiFi 列表高度,避免列表撑开整页。
const wifiListScrollHeight = computed(() => {
if (currentState.value === STATES.SCANNING) return "auto";
const itemCount = wifiList.value.length + 1;
const maxHeight = currentState.value === STATES.CONNECTED ? 276 : 420;
return `${Math.min(itemCount * 92, maxHeight)}rpx`;
});
// 清理 WiFi 连接轮询定时器。
const clearWifiConnectTimer = () => {
clearTimeout(wifiConnectTimer);
wifiConnectTimer = null;
wifiConnectPollCount = 0;
};
// 取消当前 WiFi 连接轮询,并恢复弹窗提交状态。
const cancelWifiConnectPolling = () => {
wifiConnectRequestId += 1;
clearWifiConnectTimer();
connectStatusText.value = "";
isSubmittingWifi.value = false;
uni.hideLoading();
};
// 判断设备电量接口返回的 online/netType 字段,确定设备是否已通过 WiFi 在线。
// 返回值含义:true → WiFi 在线成功;"net_fail" → 设备走 4g 失败;false → 未就绪,需继续轮询。
const isDeviceConnectedByWifi = (deviceStatus) => {
// online 不为 true → 设备不在线,需继续轮询
if (deviceStatus?.online !== true) return false;
const netType = String(deviceStatus?.netType || "").toLowerCase();
// online:true + netType:4g → 设备已切 4gWiFi 连接失败
if (netType === "4g") return "net_fail";
// online:true + netType:wifi → WiFi 连接成功
// online:true + netType:"" → 设备在线但 netType 暂未上报,继续轮询等待
return netType === "wifi";
};
// 轮询设备电量接口,等待设备切到 WiFi 在线;netType:4g 快速失败,超时 30 次后放弃。
const waitForDeviceWifiConnected = (requestId) => {
return new Promise((resolve) => {
const poll = async () => {
if (requestId !== wifiConnectRequestId) {
resolve(false);
return;
}
wifiConnectPollCount += 1;
try {
const deviceStatus = await getDeviceBatteryAPI();
if (requestId !== wifiConnectRequestId) {
resolve(false);
return;
}
const connResult = isDeviceConnectedByWifi(deviceStatus);
// online:true + netType:wifi → 成功
if (connResult === true) {
resolve(true);
return;
}
// online:true + netType:4g → 立即失败(设备已切 4g,WiFi 连不上)
if (connResult === "net_fail") {
resolve(false);
return;
}
// online:false + netType:"" → 继续轮询
// online:true + netType:"" → 忽略,继续轮询(netType 暂未上报)
} catch (err) {
if (requestId !== wifiConnectRequestId) {
resolve(false);
return;
}
}
if (wifiConnectPollCount >= WIFI_CONNECT_MAX_POLL_COUNT) {
resolve(false);
return;
}
wifiConnectTimer = setTimeout(poll, WIFI_CONNECT_POLL_INTERVAL);
};
poll();
});
};
// 提交 WiFi 配置给游戏设备,并轮询确认设备真实连上 WiFi 后再展示成功。
const submitDeviceWifiConfig = async ({ ssid, password }) => {
if (isSubmittingWifi.value) return;
wifiConnectRequestId += 1;
const requestId = wifiConnectRequestId;
clearWifiConnectTimer();
isSubmittingWifi.value = true;
connectStatusText.value = "WiFi连接中...";
uni.showLoading({
title: "WiFi连接中",
mask: true,
});
try {
await connectDeviceWifiAPI(ssid, password);
const isConnected = await waitForDeviceWifiConnected(requestId);
if (requestId !== wifiConnectRequestId) return;
if (!isConnected) {
connectError.value = WIFI_CONNECT_FAILED_TEXT;
return;
}
connectedWifi.value = {
...(connectingWifi.value || {}),
SSID: ssid,
password,
secure: connectMode.value === "secure" || !!password,
};
connectError.value = "";
currentState.value = STATES.CONNECTED;
} catch (err) {
if (requestId === wifiConnectRequestId) {
connectError.value =
err?.code === -1 && err?.message ? err.message : WIFI_CONNECT_FAILED_TEXT;
}
} finally {
if (requestId === wifiConnectRequestId) {
clearWifiConnectTimer();
connectStatusText.value = "";
isSubmittingWifi.value = false;
uni.hideLoading();
}
}
};
// 校验用户输入并提交 WiFi 配置,不再把手机连接结果当作设备连接成功。
const joinNetwork = () => {
if (joinDisabled.value) return;
connectError.value = "";
const ssid = connectInput.value.ssid;
const password = connectInput.value.password;
submitDeviceWifiConfig({ ssid, password });
};
// 清理 OTA 更新相关定时器,避免页面退出或状态结束后继续执行。
const clearUpdateTimers = () => {
clearInterval(progressTimer);
clearTimeout(timeoutTimer);
clearTimeout(statusTimer);
progressTimer = null;
timeoutTimer = null;
statusTimer = null;
};
// 启动本地进度条动画,真实完成状态以后端任务轮询结果为准。
const startProgressAnimation = () => {
clearInterval(progressTimer);
progressTimer = setInterval(() => {
if (progress.value >= 90) {
clearInterval(progressTimer);
return;
}
const increment = Math.max(0.5, 2 - progress.value / 60);
progress.value = Math.min(90, progress.value + increment);
}, 500);
};
// 将 OTA 更新流程标记为失败并切换到失败页面。
const failUpdate = () => {
clearUpdateTimers();
isStartingUpdate.value = false;
currentState.value = STATES.FAILED;
};
// 将 OTA 更新流程标记为成功,进度补到 100% 后进入完成页面。
const completeUpdate = () => {
clearUpdateTimers();
isStartingUpdate.value = false;
progress.value = 100;
setTimeout(() => {
currentState.value = STATES.DONE;
}, 300);
};
// 轮询 OTA 更新任务状态,状态为处理中则继续轮询,成功或失败则结束流程。
const pollUpdateTaskStatus = (taskId) => {
clearTimeout(statusTimer);
statusTimer = setTimeout(async () => {
try {
const taskStatus = await getHardwareBoxTaskStatusAPI(taskId);
const status = Number(taskStatus?.status);
if (status === 2) {
completeUpdate();
return;
}
if (status === 3) {
failUpdate();
return;
}
if (status === 0 || status === 1) {
pollUpdateTaskStatus(taskId);
return;
}
failUpdate();
} catch (err) {
failUpdate();
}
}, 3000);
};
// 判断设备是否满足 OTA 更新条件,不满足时返回精确提示文案。
const getUpdateDisabledReason = (deviceStatus) => {
if (deviceStatus?.online !== true) return "请先开启智能弓";
if (Number(deviceStatus?.battery) <= OTA_MIN_BATTERY) return OTA_LOW_BATTERY_TEXT;
if (String(deviceStatus?.netType || "").toLowerCase() !== "wifi") return "设备当前未连接 WiFi,请先连接 WiFi 后再更新";
return "";
};
// 获取 OTA 更新版本信息,优先使用首页跳转传入的数据,没有传参时再请求后端版本接口。
const getOtaVersionInfo = async () => {
if (routeOtaInfo.value.versionNumber && routeOtaInfo.value.resourceUrl) {
return {
needUpdate: true,
versionNumber: routeOtaInfo.value.versionNumber,
resourceUrl: routeOtaInfo.value.resourceUrl,
};
}
return getHardwareBoxVersionAPI();
};
// 点击开始更新时先判断设备状态和版本信息,满足条件才发送 OTA 指令并开始轮询任务状态。
const startUpdate = async () => {
if (isStartingUpdate.value) return;
if (!connectedWifi.value) return;
isStartingUpdate.value = true;
try {
const deviceStatus = await getDeviceBatteryAPI();
const disabledReason = getUpdateDisabledReason(deviceStatus);
if (disabledReason) {
isStartingUpdate.value = false;
uni.showToast({
title: disabledReason,
icon: "none",
});
return;
}
let versionInfo;
try {
versionInfo = await getOtaVersionInfo();
} catch (err) {
isStartingUpdate.value = false;
uni.showToast({
title: "获取更新版本失败,请重试",
icon: "none",
});
return;
}
if (!versionInfo?.needUpdate) {
isStartingUpdate.value = false;
uni.showToast({
title: "当前已是最新版本",
icon: "none",
});
return;
}
currentState.value = STATES.UPDATING;
progress.value = 0;
startProgressAnimation();
timeoutTimer = setTimeout(() => {
if (currentState.value === STATES.UPDATING) {
failUpdate();
}
}, 5 * 60 * 1000);
const updateResult = await sendHardwareBoxUpdateAPI({
versionNumber: versionInfo.versionNumber,
wifiSsid: connectedWifi.value.SSID,
wifiPassword: connectedWifi.value.password || "",
resourceUrl: versionInfo.resourceUrl,
});
if (!updateResult?.taskId) {
failUpdate();
return;
}
pollUpdateTaskStatus(updateResult.taskId);
} catch (err) {
failUpdate();
}
};
// WebSocket 成功回调保留兜底能力,触发后直接按更新完成处理。
const handleWsDone = () => {
completeUpdate();
};
// WebSocket 失败回调保留兜底能力,触发后直接按更新失败处理。
const handleWsFail = () => {
failUpdate();
};
// 处理更新完成返回,兼容首页 OTA 弹窗入口和设备页普通入口。
const handleDone = () => {
const pages = getCurrentPages();
const prevPage = pages[pages.length - 2];
const prevVm = prevPage?.$vm;
if (prevVm && "otaState" in prevVm && "otaVisible" in prevVm) {
prevVm.otaState = "update_success";
prevVm.otaVisible = true;
}
uni.navigateBack({ delta: 1 });
};
const handleRetry = () => {
if (connectedWifi.value) {
currentState.value = STATES.CONNECTED;
} else {
startScanning();
}
};
// 监听系统输入法高度,用于让底部弹窗避开键盘遮挡。
const handleKeyboardHeightChange = (res) => {
keyboardHeight.value = res?.height || 0;
};
// 切换密码显示状态前先收起键盘,避免安全键盘和普通输入法切换时复用旧高度产生黑条。
const togglePasswordVisibility = () => {
uni.hideKeyboard({
complete() {
keyboardHeight.value = 0;
showPassword.value = !showPassword.value;
},
});
};
// 页面加载时接收首页传入的 OTA 版本号和固件地址。
onLoad((options = {}) => {
routeOtaInfo.value = {
versionNumber: decodeURIComponent(options.versionNumber || ""),
resourceUrl: decodeURIComponent(options.resourceUrl || ""),
};
});
// 页面挂载后启动 WiFi 扫描流程。
onMounted(() => {
if (typeof uni.onKeyboardHeightChange === "function") {
uni.onKeyboardHeightChange(handleKeyboardHeightChange);
}
startScanning();
});
// iOS 从系统 WiFi 设置返回小程序后,同步当前手机连接的 WiFi。
onShow(() => {
if (isIOS && currentState.value === STATES.LIST) {
syncIOSConnectedWifi();
}
});
onUnmounted(() => {
if (typeof uni.offKeyboardHeightChange === "function") {
uni.offKeyboardHeightChange(handleKeyboardHeightChange);
}
cancelWifiConnectPolling();
clearUpdateTimers();
wx.offGetWifiList && wx.offGetWifiList();
});
</script>
<template>
<Container title="连接无线网络">
<!-- WiFi 不可用 Banner -->
<view v-if="showWifiBanner" class="wifi-banner">
<text class="wifi-banner-text">请先开启 WiFi 后刷新重试</text>
</view>
<!-- SCANNING / LIST / CONNECTED -->
<view
v-if="currentState === 'SCANNING' || currentState === 'LIST' || currentState === 'CONNECTED'"
class="wifi-page"
>
<!-- Hero: wifi1.png 单图已内含吉祥物 -->
<view class="hero-area">
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/wifi1.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
</view>
<text class="page-title" :class="{ 'connected-page-title': currentState === 'CONNECTED' }">
{{ currentState === 'CONNECTED' ? '连接成功' : '连接无线网络' }}
</text>
<text v-if="currentState !== 'CONNECTED'" class="page-subtitle">网络名称仅支持英文字符及数字</text>
<view v-if="isIOS && currentState !== 'CONNECTED'" class="ios-guide">
<text class="ios-guide-text">请先在系统 WiFi 设置中连接目标网络返回后选择当前 WiFi 并填写密码</text>
</view>
<!-- 已连接网络卡片 -->
<view v-if="currentState === 'CONNECTED'" class="section-label-row connected-section-label-row">
<text class="section-label">连接网络</text>
</view>
<view v-if="currentState === 'CONNECTED'" class="wifi-list-card connected-wifi-card">
<view class="wifi-item connected-wifi-item">
<image class="check-icon" src="../static/sicon/check.png" mode="aspectFit" />
<text class="wifi-ssid connected-wifi-ssid">{{ connectedWifi?.SSID }}</text>
<view class="wifi-icons connected-wifi-icons">
<image
v-if="connectedWifi?.secure"
class="security-icon"
src="../static/sicon/pwd.png"
mode="aspectFit"
/>
<image class="signal-icon" src="../static/sicon/wifi.png" mode="aspectFit" />
</view>
</view>
</view>
<!-- 网络列表标题 -->
<view class="section-label-row">
<text class="section-label">网络</text>
<image
src="../static/sicon/refresh.png"
mode="aspectFit"
:style="{ width: '34rpx', height: '34rpx', marginLeft: '8rpx', opacity: isRefreshing ? 0.3 : 0.7 }"
@click="startScanning"
/>
</view>
<!-- 网络列表卡片 -->
<scroll-view
scroll-y
class="wifi-list-card"
:class="{ 'wifi-list-scroll': currentState !== 'SCANNING' }"
:style="{ height: wifiListScrollHeight }"
>
<block v-if="currentState === 'SCANNING'">
<view class="wifi-item" @click="selectOther">
<text class="wifi-ssid other-text">其他...</text>
</view>
</block>
<block v-else>
<view
v-for="(wifi, idx) in wifiList"
:key="wifi.SSID"
class="wifi-item"
@click="selectWifi(wifi)"
>
<text class="wifi-ssid">{{ wifi.SSID }}</text>
<view class="wifi-icons">
<image
v-if="wifi.secure"
class="security-icon"
src="../static/sicon/pwd.png"
mode="aspectFit"
/>
<image class="signal-icon" src="../static/sicon/wifi.png" mode="aspectFit" />
</view>
</view>
<view class="wifi-item" @click="selectOther">
<text class="wifi-ssid other-text">其他...</text>
</view>
</block>
</scroll-view>
<!-- CONNECTED开始更新按钮 -->
<view v-if="currentState === 'CONNECTED'" class="bottom-btn-area connected-bottom-btn-area">
<view class="primary-btn update-btn" @click="startUpdate">
<text class="primary-btn-text">开始更新</text>
</view>
</view>
</view>
<!-- UPDATING -->
<view v-else-if="currentState === 'UPDATING'" class="center-page">
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/target-char.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<text class="page-title" style="margin-top: 24rpx;">更新中,请稍等片刻...</text>
<view class="progress-wrap">
<view class="progress-track">
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
</view>
<text class="progress-pct">{{ Math.floor(progress) }}%</text>
</view>
</view>
<!-- DONE -->
<view v-else-if="currentState === 'DONE'" class="center-page">
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/check-char.png" mode="aspectFit" style="width: 194rpx; height: 166rpx;" />
<text class="page-title" style="margin-top: 24rpx;">更新完成</text>
<text class="page-desc-white">请关机并重启智能弓</text>
<view class="primary-btn done-btn" style="margin-top:20px" @click="handleDone">
<text class="primary-btn-text">完成</text>
</view>
</view>
<!-- FAILED -->
<view v-else-if="currentState === 'FAILED'" class="center-page">
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/close-char.png" mode="aspectFit" style="width: 194rpx; height: 164rpx;" />
<text class="page-title fail-title" style="margin-top: 24rpx;">更新失败</text>
<text class="page-desc-white">请确保</text>
<text class="page-desc-white">1智能弓已开启</text>
<text class="page-desc-white">2网路连接稳定</text>
<view class="primary-btn done-btn" style="margin-top: 40rpx;" @click="handleRetry">
<text class="primary-btn-text">重试</text>
</view>
</view>
<!-- CONNECTING 底部弹窗 -->
<view v-if="currentState === 'CONNECTING'" class="sheet-mask">
<view class="sheet" :style="{ marginBottom: keyboardHeight + 'px' }" @click.stop="">
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-bg.png" mode="aspectFill" class="sheet-bg" />
<view class="sheet-inner">
<!-- 有密码网络 -->
<block v-if="connectMode === 'secure'">
<view class="sheet-header">
<view class="sheet-nav-btn" @click="closeConnectSheet">
<image src="../static/sicon/arrow-left.png" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
</view>
<text class="sheet-title">加入"{{ connectInput.ssid }}"</text>
<view
class="sheet-nav-btn"
:class="{ 'nav-disabled': joinDisabled }"
@click="joinNetwork"
>
<image src="../static/sicon/check.png" mode="aspectFit" style="width: 28rpx; height: 24rpx;" />
</view>
</view>
<view class="input-row-card">
<text class="input-label">密码</text>
<input
class="input-field"
:password="!showPassword"
:adjust-position="false"
v-model="connectInput.password"
placeholder="输入网络密码"
placeholder-class="input-placeholder"
confirm-type="done"
@confirm="joinNetwork"
/>
<!-- 密码显示/隐藏切换按钮 -->
<view class="pwd-eye-btn" @click="togglePasswordVisibility">
<image
:src="showPassword ? '../static/sicon/eye-on.png' : '../static/sicon/eye-off.png'"
mode="aspectFit"
style="width: 40rpx; height: 40rpx;"
/>
</view>
</view>
<text v-if="connectStatusText" class="connect-status">{{ connectStatusText }}</text>
<text v-if="connectError" class="connect-error">{{ connectError }}</text>
</block>
<!-- 无密码网络 -->
<block v-else-if="connectMode === 'open'">
<view class="sheet-header">
<view class="sheet-nav-btn" @click="closeConnectSheet">
<image src="../static/sicon/arrow-left.png" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
</view>
<text class="sheet-title">加入"{{ connectInput.ssid }}"</text>
<view class="sheet-nav-btn" @click="joinNetwork">
<image src="../static/sicon/check.png" mode="aspectFit" style="width: 28rpx; height: 24rpx;" />
</view>
</view>
<text v-if="connectStatusText" class="connect-status">{{ connectStatusText }}</text>
<text v-else class="sheet-hint">该网络为开放网络点击 加入</text>
<text v-if="connectError" class="connect-error">{{ connectError }}</text>
</block>
<!-- 手动输入 -->
<block v-else-if="connectMode === 'manual'">
<view class="sheet-header">
<view class="sheet-nav-btn" @click="closeConnectSheet">
<image src="../static/sicon/arrow-left.png" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
</view>
<text class="sheet-title">加入无线网络</text>
<view
class="sheet-nav-btn"
:class="{ 'nav-disabled': joinDisabled }"
@click="joinNetwork"
>
<image src="../static/sicon/check.png" mode="aspectFit" style="width: 28rpx; height: 24rpx;" />
</view>
</view>
<view class="input-row-card">
<text class="input-label">名称</text>
<input
class="input-field"
v-model="connectInput.ssid"
placeholder="输入网络名称"
placeholder-class="input-placeholder"
/>
</view>
<view class="input-row-card">
<text class="input-label">密码</text>
<input
class="input-field"
:password="!showPassword"
:adjust-position="false"
v-model="connectInput.password"
placeholder="输入网络密码"
placeholder-class="input-placeholder"
confirm-type="done"
@confirm="joinNetwork"
/>
<!-- 密码显示/隐藏切换按钮 -->
<view class="pwd-eye-btn" @click="togglePasswordVisibility">
<image
:src="showPassword ? '../static/sicon/eye-on.png' : '../static/sicon/eye-off.png'"
mode="aspectFit"
style="width: 40rpx; height: 40rpx;"
/>
</view>
</view>
<text v-if="ssidWarning" class="connect-error">{{ ssidWarning }}</text>
<text v-else-if="connectStatusText" class="connect-status">{{ connectStatusText }}</text>
<text v-else-if="connectError" class="connect-error">{{ connectError }}</text>
</block>
</view>
</view>
</view>
<!-- 授权拒绝弹窗 -->
<ScreenHint :show="wifiAuthDeniedVisible" :onClose="() => (wifiAuthDeniedVisible = false)">
<view class="wifi-auth-denied">
<text class="wifi-auth-denied-text">拒绝授权获取wifi列表失败请重新授权或手动输入</text>
<view class="wifi-auth-denied-btns">
<view class="wifi-auth-denied-btn cancel-btn" @click="wifiAuthDeniedVisible = false">
<text>取消授权</text>
</view>
<view class="wifi-auth-denied-btn confirm-btn" @click="handleReauthorize">
<text>重新授权</text>
</view>
</view>
</view>
</ScreenHint>
</Container>
</template>
<style scoped>
.wifi-auth-denied {
display: flex;
flex-direction: column;
align-items: center;
padding: 32rpx 40rpx 24rpx;
gap: 32rpx;
}
.wifi-auth-denied-text {
color: #fff;
font-size: 28rpx;
text-align: center;
line-height: 1.6;
}
.wifi-auth-denied-btns {
display: flex;
flex-direction: row;
gap: 24rpx;
width: 100%;
justify-content: center;
}
.wifi-auth-denied-btn {
flex: 1;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
}
.wifi-auth-denied-btn.cancel-btn {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.wifi-auth-denied-btn.confirm-btn {
background: rgba(254, 216, 71, 1);
color: #1a1a2e;
font-weight: 600;
}
.wifi-banner {
background-color: rgba(255, 200, 0, 0.12);
border: 1rpx solid rgba(254, 216, 71, 0.5);
border-radius: 8rpx;
padding: 16rpx 24rpx;
margin: 16rpx 24rpx 0;
}
.wifi-banner-text {
color: rgba(254, 216, 71, 1);
font-size: 26rpx;
}
.wifi-page {
padding: 0 67rpx 40rpx;
display: flex;
flex-direction: column;
}
.hero-area {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx 0 24rpx;
}
.page-title {
color: rgba(91, 196, 255, 1);
font-size: 36rpx;
font-weight: 600;
text-align: center;
margin-bottom: 8rpx;
}
.connected-page-title {
color: #4DD9C8;
}
.page-subtitle {
color: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
text-align: center;
margin-bottom: 32rpx;
}
.fail-title {
color: rgba(255, 100, 100, 1);
}
.ios-guide {
background-color: rgba(95, 173, 255, 0.12);
border-radius: 8rpx;
padding: 16rpx 24rpx;
margin-bottom: 20rpx;
}
.ios-guide-text {
color: rgba(95, 173, 255, 1);
font-size: 26rpx;
}
.section-label-row {
display: flex;
align-items: center;
margin-bottom: 24rpx;
padding: 0 22rpx;
}
.connected-section-label-row {
margin-top: 32rpx;
}
.section-label {
color: rgba(255, 255, 255, 0.56);
font-size: 28rpx;
}
.wifi-list-card {
background-color: rgba(30, 35, 50, 0.96);
border-radius: 24rpx;
overflow: hidden;
margin-bottom: 20rpx;
}
.wifi-list-scroll {
flex-shrink: 0;
}
.connected-wifi-card {
background-color: rgba(35, 40, 56, 0.96);
margin-bottom: 44rpx;
}
.wifi-item {
position: relative;
display: flex;
align-items: center;
min-height: 92rpx;
padding: 0 26rpx 0 66rpx;
}
.wifi-item:not(:last-child)::after {
content: "";
position: absolute;
left: 66rpx;
right: 26rpx;
bottom: 0;
height: 1rpx;
background-color: rgba(255, 255, 255, 0.08);
}
.connected-wifi-item {
min-height: 88rpx;
padding-left: 28rpx;
}
.wifi-ssid {
color: rgba(255, 255, 255, 1);
font-size: 28rpx;
line-height: 36rpx;
flex: 1;
}
.connected-wifi-ssid {
font-weight: 500;
}
.other-text {
color: rgba(255, 255, 255, 0.86);
}
.wifi-icons {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.connected-wifi-icons {
gap: 14rpx;
}
.check-icon {
width: 28rpx;
height: 24rpx;
margin-right: 16rpx;
flex-shrink: 0;
}
.security-icon {
width: 24rpx;
height: 30rpx;
flex-shrink: 0;
}
.signal-icon {
width: 36rpx;
height: 32rpx;
flex-shrink: 0;
}
.bottom-btn-area {
margin-top: 24rpx;
}
.connected-bottom-btn-area {
display: flex;
justify-content: center;
margin-top: 20rpx;
}
.primary-btn {
background-color: rgba(254, 216, 71, 1);
border-radius: 50rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.update-btn {
width: 412rpx;
}
.primary-btn-text {
color: rgba(0, 0, 0, 1);
font-size: 30rpx;
font-weight: 500;
}
.center-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 32rpx 40rpx;
}
.page-desc-white {
color: rgba(255, 255, 255, 1);
font-size: 28rpx;
line-height: 52rpx;
text-align: center;
}
.done-btn {
width: 80%;
}
.progress-wrap {
width: 80%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
margin-top: 40rpx;
}
.progress-track {
width: 100%;
height: 10rpx;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 5rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: rgba(91, 196, 255, 1);
border-radius: 5rpx;
transition: width 0.4s ease;
}
.progress-pct {
color: rgba(91, 196, 255, 1);
font-size: 28rpx;
font-weight: 600;
}
.sheet-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.sheet {
position: relative;
background-color: rgba(57, 47, 29, 1);
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
overflow: hidden;
min-height: 320rpx;
border: 1rpx solid rgba(249, 213, 161, 0.4);
border-bottom: none;
}
.sheet-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.sheet-inner {
position: relative;
z-index: 1;
padding: 32rpx 32rpx 112rpx;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.sheet-nav-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-disabled {
opacity: 0.3;
}
.sheet-title {
color: rgba(255, 255, 255, 1);
font-size: 30rpx;
font-weight: 600;
flex: 1;
text-align: center;
}
.sheet-hint {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
text-align: center;
margin-bottom: 24rpx;
}
.connect-status {
color: rgba(255, 255, 255, 0.72);
font-size: 26rpx;
line-height: 40rpx;
margin-top: 16rpx;
display: block;
text-align: center;
}
.connect-error {
color: rgba(254, 216, 71, 1);
font-size: 26rpx;
line-height: 40rpx;
margin-top: 16rpx;
display: block;
/* 错误提示文案居中展示 */
text-align: center;
}
.input-row-card {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 16rpx;
display: flex;
align-items: center;
padding: 24rpx 24rpx;
margin-left: 48rpx;
margin-right: 48rpx;
}
.input-row-card + .input-row-card {
margin-top: 16rpx;
}
.input-label {
color: rgba(255, 255, 255, 0.8);
font-size: 28rpx;
width: 80rpx;
flex-shrink: 0;
}
.input-field {
flex: 1;
color: rgba(255, 255, 255, 1);
font-size: 28rpx;
background: transparent;
border: none;
padding: 0 8rpx;
}
.input-placeholder {
color: rgba(255, 255, 255, 0.3);
}
/* 密码查看切换按钮 */
.pwd-eye-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
</style>